const SUPABASE_URL = 'https://cmvjrinpmdghikbtykdn.supabase.co'; const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNtdmpyaW5wbWRnaGlrYnR5a2RuIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzMxMzQzMzgsImV4cCI6MjA4ODcxMDMzOH0.VuylhOJiypFGBosM-R2CjezkqB4SBGR4PyepWlF8GVA'; const API_BASE = 'https://betterquote-api-production.up.railway.app'; const SESSION_KEY = 'betterquote_session'; document.addEventListener('DOMContentLoaded', function() { // The dashboard no longer talks to Supabase directly — all data goes through // the backend API (service role). Once the public-read policy on `quotes` is // dropped, the anon key can't read anything. // ── AUTH: attach the signed token to every BetterQuote API call ────────── // Reads the token from the stored session and sends it as a Bearer header. // The backend issues a fresh token on each authed request (sliding 30-day // session) and returns it in X-Refreshed-Token; we save it so an active // user is never logged out. A 401 means the token is missing/expired/invalid // → clear the session and send them to log in again. Only API_BASE requests // get the header. const _bqOrigFetch = window.fetch.bind(window); window.fetch = async (resource, options = {}) => { const url = typeof resource === 'string' ? resource : (resource && resource.url) || ''; if (!url.startsWith(API_BASE)) return _bqOrigFetch(resource, options); const sess = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); const opts = { ...options, headers: { ...(options.headers || {}) } }; if (sess && sess.token) opts.headers['Authorization'] = 'Bearer ' + sess.token; const res = await _bqOrigFetch(resource, opts); const refreshed = res.headers.get('X-Refreshed-Token'); if (refreshed && sess) { sess.token = refreshed; localStorage.setItem(SESSION_KEY, JSON.stringify(sess)); } if (res.status === 401) { localStorage.removeItem(SESSION_KEY); window.location.href = '/login'; } return res; }; let profile = null; let allQuotes = []; let quotesFilter = 'all'; let quotesSearch = ''; let pendingQuote = null; // Tracks which display mode the tradie has selected for the current quote. // Sent to backend on confirm; backend persists to quotes.display_mode and // passes to PDF generator. Defaults to 'full' on every new quote. let pendingDisplayMode = 'full'; // When non-null, the user is editing an already-confirmed quote (via "Back // to edit" on the post-confirm screen). Confirm uses update-and-regenerate // instead of save-and-confirm so the SAME quote_number is reused. Cleared // when the tradie starts a fresh new quote or successfully re-confirms. let editingQuoteId = null; // ── BOOT ───────────────────────────────────────────────── async function init() { const sess = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); if (!sess || !sess.profile_id) { window.location.href = '/login'; return; } // Kick off the quotes fetch immediately, in parallel with the profile // fetch, using the profile_id we already have from the session — quotes // doesn't need anything FROM the profile response, just the same id. // Previously these ran one after another (await loadProfile, then await // loadQuotes), so their times added together; on a slow connection that // was ~2 sequential round trips instead of the slower of the two run // together. loadQuotes() itself is left untouched — it's called from // several other places in this file as a refetch+rerender, and this // change only affects the very first load. const quotesPromise = fetch(API_BASE + '/api/quotes?profile_id=' + encodeURIComponent(sess.profile_id)) .then((res) => (res.ok ? res.json() : Promise.reject(new Error('API ' + res.status)))) .catch((err) => { console.error('Quotes prefetch error:', err); return null; }); await loadProfile(sess.profile_id); if (!profile) return; // loadProfile already logged the user out on failure const quotesBody = await quotesPromise; if (quotesBody) { allQuotes = quotesBody.quotes || []; renderStats(allQuotes); renderRecent(allQuotes.slice(0, 6)); renderQuotesList(allQuotes); if (typeof renderStatsPage === 'function') { try { renderStatsPage(); } catch (e) { /* OK — DOM might not be ready */ } } } else { document.getElementById('recent-list').innerHTML = emptyEl('Could not load quotes.'); document.getElementById('quotes-list').innerHTML = emptyEl('Could not load quotes.'); } renderSetupChecklist(); // Fetch email connection status in the background — non-blocking, // so the dashboard renders immediately and the email UI updates as soon // as the call completes. emailConnection.refreshStatus(); // Handle ?email_connected= / ?email_error= from the OAuth redirect handleOAuthRedirectParams(); // Deep-link: ?quote= opens that quote (e.g. from a client-response email) handleQuoteDeepLink(); // Deep-link: ?brief= pre-fills a job brief captured on WhatsApp handleBriefDeepLink(); } function logout() { localStorage.removeItem(SESSION_KEY); window.location.href = '/login'; } // ── PROFILE ────────────────────────────────────────────── async function loadProfile(id) { let resp, body; try { resp = await fetch(API_BASE + `/api/profile/get?profile_id=${encodeURIComponent(id)}`); body = await resp.json().catch(() => ({})); } catch (err) { // Network failure — log out so the user can re-authenticate logout(); return; } if (!resp.ok || !body || !body.success || !body.profile) { logout(); return; } profile = body.profile; profile._has_pricing = !!body.has_pricing; document.getElementById('hdr-name').textContent = profile.business_name || profile.owner_name || ''; if (profile.owner_name) { const first = profile.owner_name.split(' ')[0]; const greetEl = document.getElementById('home-greeting'); if (greetEl) greetEl.textContent = `Hi, ${first}`; } // Settings document.getElementById('s-biz').value = profile.business_name || ''; document.getElementById('s-owner').value = profile.owner_name || ''; document.getElementById('s-phone').value = profile.phone || ''; document.getElementById('s-email').value = profile.email || ''; document.getElementById('s-abn').value = profile.abn || ''; document.getElementById('s-trade').value = profile.trade || 'other'; document.getElementById('s-margin').value = profile.default_margin != null ? profile.default_margin : 20; document.getElementById('s-bname').value = profile.bank_name || ''; document.getElementById('s-bsb').value = profile.bank_bsb || ''; document.getElementById('s-bacc').value = profile.bank_account || ''; document.getElementById('s-whatsapp').value = profile.whatsapp_number || ''; document.getElementById('s-reminders').checked = profile.reminder_emails_enabled !== false; // Brand colours. Sync both the swatch picker (which controls Hex via // the input element's native colour picker UI) and the hex text input // (which the tradie can type into directly). renderBrandColours() // also handles showing/hiding the whole block based on logo presence. renderBrandColours(); // Trade-specific example in the new-quote textarea so the example reads // as relevant to the tradie. Falls back to a generic line if trade unset // or unrecognised. const tradeExamples = { electrician: 'e.g. Replace switchboard, supply and install single phase 18-pole', plumber: 'e.g. Replace hot water system Rheem 315L, supply and install', carpenter: 'e.g. New blackbutt deck 20m², supply and install', builder: 'e.g. New blackbutt deck 20m², supply and install', painter: 'e.g. Repaint interior 3 bedroom home, walls and ceilings, 2 coats', concreter: 'e.g. New concrete driveway 60m², 100mm thick with reinforcement', roofer: 'e.g. Replace tiled roof with Colorbond, 180m²', tiler: 'e.g. Bathroom retile, walls and floor, ~12m² total', landscaper: 'e.g. Backyard turf installation 80m² + raised garden beds', removalist: 'e.g. 3 bedroom house move, Sydney to Melbourne, no piano', handyman: 'e.g. Hang 5 doors and replace 3 powerpoints', bricklayer: 'e.g. New retaining wall 8m × 1.2m high, besser blocks', plasterer: 'e.g. Plaster repair after water damage, ceiling 4m × 5m', cabinet_maker: 'e.g. New kitchen cabinets, 6 base units and 4 wall units', glazier: 'e.g. Replace 2 sliding door panels, toughened glass', hvac: 'e.g. Install Daikin 7.1kW split system in lounge', fencing: 'e.g. New paling fence 30m, 1.8m height, treated pine', paving: 'e.g. New paved patio 40m² with bullnose edge', flooring: 'e.g. New engineered oak flooring 60m², supply and install', stonemason: 'e.g. Sandstone retaining wall 6m × 600mm', solar: 'e.g. 6.6kW solar system with 5kW inverter', welder: 'e.g. Custom steel handrail for stairs, 3m run', arborist: 'e.g. Remove 2 large gum trees + stump grinding', }; const example = tradeExamples[profile.trade]; const ph = example ? `Describe the job — the more detail you give, the better the quote.\n\n${example}` : 'Describe the job — the more detail you give, the better the quote.'; const descEl = document.getElementById('nq-desc'); if (descEl) descEl.placeholder = ph; // Account populateAccount(profile); renderLogo(); } const SUBSCRIBE_URL = 'https://buy.stripe.com/cNi00j7bA4zwcrY0qOabK00'; // Trial state derived from the loaded profile. Mirrors the server's rule: // active = fine; a trial still within its window = fine; anything else = blocked. function trialInfo() { if (!profile) return { blocked: false, endingSoon: false, daysLeft: null }; const status = profile.subscription_status || 'trial'; if (status === 'active') return { blocked: false, endingSoon: false, daysLeft: null }; let daysLeft = null; if (profile.trial_ends_at) { daysLeft = Math.ceil((new Date(profile.trial_ends_at) - Date.now()) / 86400000); } const blocked = status === 'expired' || status === 'cancelled' || status === 'past_due' || (status === 'trial' && daysLeft != null && daysLeft <= 0); const endingSoon = !blocked && status === 'trial' && daysLeft != null && daysLeft <= 5; return { blocked, endingSoon, daysLeft }; } // Banner above the new-quote form: amber when the trial is nearly up, red + // generate disabled once it's ended. Existing quotes stay fully accessible. function renderTrialBanner() { const host = document.getElementById('nq-input-area'); if (!host) return; const gb = document.getElementById('gen-btn'); const { blocked, endingSoon, daysLeft } = trialInfo(); let banner = document.getElementById('trial-banner'); if (!blocked && !endingSoon) { if (banner) banner.remove(); if (gb) gb.disabled = false; return; } if (!banner) { banner = document.createElement('div'); banner.id = 'trial-banner'; banner.style.cssText = 'margin:0 0 16px;padding:14px 16px;border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;'; host.insertAdjacentElement('beforebegin', banner); } const cta = ''; if (blocked) { banner.style.background = '#fef2f2'; banner.style.borderLeft = '4px solid #dc2626'; banner.innerHTML = 'Your free trial has ended — subscribe to keep creating quotes. Your existing quotes stay available.' + cta; if (gb) gb.disabled = true; } else { banner.style.background = '#fffbeb'; banner.style.borderLeft = '4px solid #f59e0b'; banner.innerHTML = '' + daysLeft + ' day' + (daysLeft !== 1 ? 's' : '') + ' left on your free trial.' + cta; if (gb) gb.disabled = false; } } function populateAccount(p) { const status = p.subscription_status || 'trial'; const badge = document.getElementById('plan-badge'); badge.className = `plan-badge ${status}`; badge.textContent = status.charAt(0).toUpperCase() + status.slice(1); document.getElementById('acc-status').textContent = status.charAt(0).toUpperCase() + status.slice(1); if (p.trial_ends_at) { const end = new Date(p.trial_ends_at); const daysLeft = Math.max(0, Math.ceil((end - Date.now()) / 86400000)); const pct = Math.min(100, Math.max(0, (daysLeft / 30) * 100)); document.getElementById('acc-trial-end').textContent = fmtDate(end); document.getElementById('trial-days').textContent = `${daysLeft} day${daysLeft !== 1 ? 's' : ''} remaining`; document.getElementById('trial-fill').style.width = pct + '%'; if (status !== 'trial') document.getElementById('trial-wrap').style.display = 'none'; } else { document.getElementById('acc-trial-end').textContent = 'N/A'; document.getElementById('trial-wrap').style.display = 'none'; } if (p.created_at) { document.getElementById('acc-since').textContent = fmtDate(new Date(p.created_at)); } renderAccountActions(status); renderTrialBanner(); } function renderAccountActions(status) { const el = document.getElementById('acc-actions'); if (status === 'active') { el.innerHTML = ` `; document.getElementById('manage-btn').addEventListener('click', openBillingPortal); } else if (status === 'cancelled' || status === 'expired' || status === 'past_due') { el.innerHTML = ` `; } else { // 'trial' or unknown el.innerHTML = ` `; } } async function openBillingPortal() { if (!profile) return; const btn = document.getElementById('manage-btn'); btn.disabled = true; btn.textContent = 'Opening...'; try { const res = await fetch(API_BASE + '/api/billing/portal', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: profile.id }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Could not open portal'); } const data = await res.json(); window.location.href = data.url; } catch (err) { console.error('Portal failed:', err); showToast(err.message || 'Could not open billing portal.'); btn.disabled = false; btn.textContent = 'Manage subscription'; } } // ── SETUP CHECKLIST ───────────────────────────────────── function renderSetupChecklist() { if (!profile) return; const items = [ { key: 'info', label: 'Add your business info', done: !!(profile.business_name && profile.owner_name && profile.phone), action: () => { showPage('settings'); setTimeout(() => document.getElementById('s-biz').focus(), 200); }, }, { key: 'logo', label: 'Upload your business logo', done: !!profile.logo_url, action: () => { showPage('settings'); setTimeout(() => document.getElementById('logo-upload-area')?.scrollIntoView({ behavior: 'smooth', block: 'center' }), 200); }, }, { key: 'bank', label: 'Add bank details for client payment', done: !!(profile.bank_bsb && profile.bank_account), action: () => { showPage('settings'); setTimeout(() => document.getElementById('s-bname').focus(), 200); }, }, { key: 'email', label: 'Connect your email account', done: !!emailConnection.isConnected(), action: () => { showPage('settings'); setTimeout(() => { const el = document.getElementById('email-conn-disconnected') || document.getElementById('email-conn-connected'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 200); }, }, { key: 'abn', label: 'Add your ABN to appear on quotes', done: !!profile.abn, action: () => { showPage('settings'); setTimeout(() => document.getElementById('s-abn').focus(), 200); }, }, { key: 'pricing', label: 'Upload your pricing sheet', done: !!profile._has_pricing, action: () => openPricingModal(), }, { key: 'whatsapp', label: 'Connect BetterQuote on WhatsApp', done: !!profile.whatsapp_number, action: () => { showPage('settings'); setTimeout(() => document.getElementById('s-whatsapp').focus(), 200); }, }, ]; // Soon items are shown in the list (so users see what's coming) but // excluded from the completion count — otherwise the bar can never reach // 100% even when the user has done everything currently available. const countable = items.filter(i => !i.soon); const done = countable.filter(i => i.done).length; document.getElementById('setup-progress').textContent = `${done} of ${countable.length}`; document.getElementById('setup-bar-fill').style.width = `${(done / countable.length) * 100}%`; // Hide the setup card + "Complete setup" button when everything's done. // CSS handles the layout shift (Create a quote expands to full width). document.body.classList.toggle('setup-needed', done < countable.length); const list = document.getElementById('setup-list'); list.innerHTML = ''; items.forEach(item => { const btn = document.createElement('button'); btn.className = 'setup-item' + (item.done ? ' done' : '') + (item.soon ? ' soon' : ''); const soonPill = item.soon ? 'Soon' : ''; const arrow = item.soon ? '' : ''; btn.innerHTML = ` ${item.label} ${soonPill} ${arrow} `; if (!item.done && !item.soon) btn.addEventListener('click', item.action); if (item.soon) btn.disabled = true; list.appendChild(btn); }); } function goSettings(message) { showPage('settings'); if (message) showToast(message); } // ── QUOTES ─────────────────────────────────────────────── async function loadQuotes() { if (!profile) return; let data; try { // Load via the backend (service-role read, scoped to this profile by the // token). No direct Supabase access, so the public-read policy can go. const res = await fetch(API_BASE + '/api/quotes?profile_id=' + encodeURIComponent(profile.id)); if (!res.ok) throw new Error('API ' + res.status); const body = await res.json(); data = body.quotes || []; } catch (err) { console.error('Quotes load error:', err); document.getElementById('recent-list').innerHTML = emptyEl('Could not load quotes.'); document.getElementById('quotes-list').innerHTML = emptyEl('Could not load quotes.'); return; } allQuotes = data || []; renderStats(allQuotes); renderRecent(allQuotes.slice(0, 6)); renderQuotesList(allQuotes); // Refresh Stats page too (it might be the currently-visible page) if (typeof renderStatsPage === 'function') { try { renderStatsPage(); } catch (e) { /* OK — DOM might not be ready */ } } } function renderStats(qs) { // Drafts don't count toward analytics — only quotes actually sent to clients const sent = qs.filter(q => q.status === 'sent'); const won = qs.filter(q => q.status === 'won'); const lost = qs.filter(q => q.status === 'lost'); const totalSentQuotes = sent.length + won.length + lost.length; const decided = won.length + lost.length; // Sums use inc-GST for tracking (matches the figure customers paid on the PDF). // Storage stays ex-GST — we multiply on the way out via displayTotal(). const sumOf = arr => arr.reduce((s, q) => s + displayTotal(q), 0); const sentTotal = sumOf(sent) + sumOf(won) + sumOf(lost); const wonTotal = sumOf(won); const lostTotal = sumOf(lost); // Legacy stat-grid (hidden div, kept for backwards compat) document.getElementById('st-sent').textContent = '$' + fmtNum(sentTotal); document.getElementById('st-won').textContent = '$' + fmtNum(wonTotal); document.getElementById('st-lost').textContent = '$' + fmtNum(lostTotal); document.getElementById('st-rate').textContent = decided > 0 ? Math.round(won.length / decided * 100) + '%' : '—'; document.getElementById('st-sent-sub').textContent = totalSentQuotes === 0 ? 'No quotes yet' : `${totalSentQuotes} quote${totalSentQuotes !== 1 ? 's' : ''}`; document.getElementById('st-won-sub').textContent = won.length === 0 ? '—' : `${won.length} quote${won.length !== 1 ? 's' : ''}`; document.getElementById('st-lost-sub').textContent = lost.length === 0 ? '—' : `${lost.length} quote${lost.length !== 1 ? 's' : ''}`; document.getElementById('st-rate-sub').textContent = decided === 0 ? '—' : `of ${decided} decided`; // QUOTES HOME — navy stats strip (This week only) const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); const wkQs = qs.filter(q => new Date(q.created_at) >= weekAgo); const wkSent = wkQs.filter(q => q.status === 'sent'); const wkWon = wkQs.filter(q => q.status === 'won'); const wkLost = wkQs.filter(q => q.status === 'lost'); const wkQuotedDollars = sumOf(wkSent) + sumOf(wkWon) + sumOf(wkLost); const wkWonDollars = sumOf(wkWon); const wkDecided = wkWon.length + wkLost.length; const wkRate = wkDecided > 0 ? Math.round(wkWon.length / wkDecided * 100) : null; const qstripQ = document.getElementById('qstrip-quoted'); const qstripW = document.getElementById('qstrip-won'); const qstripR = document.getElementById('qstrip-rate'); if (qstripQ) qstripQ.textContent = '$' + fmtNum(wkQuotedDollars); if (qstripW) qstripW.textContent = '$' + fmtNum(wkWonDollars); if (qstripR) qstripR.textContent = wkRate === null ? '—' : wkRate + '%'; // QUOTES HOME — attention card (quotes sent 7+ days ago, not followed up, not won/lost) const attnCard = document.getElementById('attn-card'); if (attnCard) { const needsFollowUp = qs.filter(q => { if (q.status !== 'sent') return false; if (q.followed_up_at) return false; if (!q.sent_at) return false; const ageDays = (Date.now() - new Date(q.sent_at).getTime()) / 86400000; return ageDays >= 7; }); if (needsFollowUp.length > 0) { const n = needsFollowUp.length; document.getElementById('attn-title').textContent = `${n} quote${n !== 1 ? 's' : ''} need${n === 1 ? 's' : ''} a follow-up`; document.getElementById('attn-sub').textContent = 'Sent more than a week ago'; attnCard.style.display = ''; } else { attnCard.style.display = 'none'; } } } // ── STATS PAGE ─────────────────────────────────────────── // Powers the dedicated Stats tab. Different from renderStats (which fills // the legacy stat-grid). Computes period-filtered counts and dollar values, // pipeline breakdown, and last-period comparison. let statsPeriod = 'month'; function periodRange(period, offset = 0) { // Returns { from: Date, to: Date, label: string } for the given period. // offset = 0 → current period. offset = -1 → previous period. const now = new Date(); let from, to, label; if (period === 'week') { // ISO week starting Monday const day = now.getDay() || 7; const monday = new Date(now); monday.setHours(0, 0, 0, 0); monday.setDate(monday.getDate() - (day - 1) + (offset * 7)); from = monday; to = new Date(monday); to.setDate(to.getDate() + 7); label = offset === 0 ? 'this week' : 'last week'; } else if (period === 'month') { from = new Date(now.getFullYear(), now.getMonth() + offset, 1); to = new Date(now.getFullYear(), now.getMonth() + offset + 1, 1); const monthName = from.toLocaleDateString('en-AU', { month: 'long' }); label = offset === 0 ? `in ${monthName}` : `last month (${monthName})`; } else { // year — year-to-date from = new Date(now.getFullYear() + offset, 0, 1); if (offset === 0) { to = new Date(); // up to now } else { // For "last year to date" — same period a year ago to = new Date(now.getFullYear() + offset, now.getMonth(), now.getDate() + 1); } label = offset === 0 ? 'year to date' : 'last year to date'; } return { from, to, label }; } function quotesInRange(qs, from, to) { // A quote belongs to a period if it was created in that period return qs.filter(q => { const d = new Date(q.created_at); return d >= from && d < to; }); } function renderStatsPage() { const qs = allQuotes || []; const { from, to, label } = periodRange(statsPeriod, 0); const periodQs = quotesInRange(qs, from, to); const sent = periodQs.filter(q => q.status === 'sent'); const won = periodQs.filter(q => q.status === 'won'); const lost = periodQs.filter(q => q.status === 'lost'); const draft = periodQs.filter(q => q.status === 'draft'); // Tiles use COUNTS — activity volume const sentCount = sent.length + won.length + lost.length; const wonCount = won.length; const lostCount = lost.length; const decided = wonCount + lostCount; const winRate = decided > 0 ? Math.round(wonCount / decided * 100) : null; document.getElementById('stats-tile-sent').textContent = sentCount; document.getElementById('stats-tile-won').textContent = wonCount; document.getElementById('stats-tile-lost').textContent = lostCount; const rateEl = document.getElementById('stats-tile-rate'); if (winRate === null) { rateEl.textContent = '—'; rateEl.classList.remove('win'); } else { rateEl.innerHTML = winRate + '%'; rateEl.classList.add('win'); } // Hero — total quoted (inc-GST via displayTotal, consistency with dashboard tracking) const sumOf = arr => arr.reduce((s, q) => s + displayTotal(q), 0); const sentDollars = sumOf(sent); // still out const wonDollars = sumOf(won); const lostDollars = sumOf(lost); const totalQuoted = sentDollars + wonDollars + lostDollars; document.getElementById('stats-hero-lbl').textContent = 'Quoted ' + label; document.getElementById('stats-hero-num').textContent = '$' + fmtNum(totalQuoted); // Pipeline bar — dollar split const pipe = document.getElementById('stats-pipe'); const legend = document.getElementById('stats-pipe-legend'); if (totalQuoted === 0) { pipe.innerHTML = '
No quotes in this period.
'; legend.innerHTML = ''; } else { const wonPct = (wonDollars / totalQuoted) * 100; const pendPct = (sentDollars / totalQuoted) * 100; const lostPct = (lostDollars / totalQuoted) * 100; pipe.innerHTML = ` ${wonPct > 0 ? `
${wonPct >= 8 ? Math.round(wonPct) + '%' : ''}
` : ''} ${pendPct > 0 ? `
${pendPct >= 8 ? Math.round(pendPct) + '%' : ''}
` : ''} ${lostPct > 0 ? `
${lostPct >= 8 ? Math.round(lostPct) + '%' : ''}
` : ''} `; legend.innerHTML = `
Won
$${fmtNum(wonDollars)}
Pending
$${fmtNum(sentDollars)}
Lost
$${fmtNum(lostDollars)}
`; } // Comparison line — last period const { from: prevFrom, to: prevTo } = periodRange(statsPeriod, -1); const prevQs = quotesInRange(qs, prevFrom, prevTo); const prevSent = prevQs.filter(q => q.status === 'sent'); const prevWon = prevQs.filter(q => q.status === 'won'); const prevLost = prevQs.filter(q => q.status === 'lost'); const prevTotalDollars = sumOf(prevSent) + sumOf(prevWon) + sumOf(prevLost); const prevDecided = prevWon.length + prevLost.length; const prevWinRate = prevDecided > 0 ? Math.round(prevWon.length / prevDecided * 100) : null; const compareEl = document.getElementById('stats-compare'); const compareText = document.getElementById('stats-compare-text'); if (prevTotalDollars > 0) { const prevLabel = statsPeriod === 'week' ? 'Last week' : statsPeriod === 'month' ? 'Last month' : 'Last year to date'; const rateStr = prevWinRate !== null ? `, ${prevWinRate}% won` : ''; compareText.innerHTML = `${prevLabel}: $${fmtNum(prevTotalDollars)} quoted${rateStr}`; compareEl.style.display = ''; } else { compareEl.style.display = 'none'; } // Extras — average quote, most quoted job const extrasEl = document.getElementById('stats-extras'); const activeQuotes = periodQs.filter(q => q.status !== 'draft'); if (activeQuotes.length > 0) { const avg = sumOf(activeQuotes) / activeQuotes.length; document.getElementById('stats-avg').textContent = '$' + fmtNum(avg); // Most common job title const jobCounts = {}; activeQuotes.forEach(q => { const t = (q.job_title || '').trim(); if (t) jobCounts[t] = (jobCounts[t] || 0) + 1; }); const mostJob = Object.entries(jobCounts).sort((a, b) => b[1] - a[1])[0]; document.getElementById('stats-mostjob').textContent = mostJob ? mostJob[0] : '—'; extrasEl.style.display = ''; } else { extrasEl.style.display = 'none'; } } // Wire up period toggle function initStatsPeriodToggle() { document.querySelectorAll('.stats-period-opt').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.stats-period-opt').forEach(b => b.classList.remove('on')); btn.classList.add('on'); statsPeriod = btn.dataset.period; renderStatsPage(); }); }); } function renderRecent(qs) { const el = document.getElementById('recent-list'); if (qs.length === 0) { el.innerHTML = `

No quotes yet

Tap Create a quote above to send your first one.

`; return; } el.innerHTML = qs.map(qItemHtml).join(''); el.querySelectorAll('.q-item-clickable').forEach(btn => { btn.addEventListener('click', () => openQuoteDetail(btn.dataset.quoteId)); }); } // Sort by "last activity" — newest first. // last_activity = max(followed_up_at, sent_at, created_at) function lastActivityOf(q) { const candidates = [q.followed_up_at, q.sent_at, q.created_at].filter(Boolean); return candidates.reduce((latest, ts) => { const t = new Date(ts).getTime(); return t > latest ? t : latest; }, 0); } // Group quotes by date bucket for date-grouped rendering. // Returns ordered list of { label, quotes }. function groupQuotesByDate(qs) { const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const dayMs = 86400000; const dayOfWeek = now.getDay() || 7; // 1-7, Monday=1 const startOfThisWeek = startOfToday - (dayOfWeek - 1) * dayMs; const startOfLastWeek = startOfThisWeek - 7 * dayMs; const startOfThisMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime(); const buckets = new Map(); qs.forEach(q => { const t = lastActivityOf(q); let label; if (t >= startOfThisWeek) { label = 'This week'; } else if (t >= startOfLastWeek) { label = 'Last week'; } else if (t >= startOfThisMonth) { const monthName = new Date(t).toLocaleDateString('en-AU', { month: 'long' }); label = `Earlier in ${monthName}`; } else { const d = new Date(t); const monthName = d.toLocaleDateString('en-AU', { month: 'long' }); const year = d.getFullYear(); const thisYear = now.getFullYear(); label = year === thisYear ? monthName : `${monthName} ${year}`; } if (!buckets.has(label)) buckets.set(label, []); buckets.get(label).push(q); }); return Array.from(buckets.entries()).map(([label, quotes]) => ({ label, quotes })); } function emptyStateForFilter(filter) { const labels = { all: 'No quotes yet. Tap + to make one.', draft: 'No drafts.', sent: 'No quotes waiting on a reply.', won: 'No won quotes yet.', lost: 'No lost quotes.', }; return `

${labels[filter] || labels.all}

`; } function renderQuotesList(qs) { const el = document.getElementById('quotes-list'); // Update filter counts (always show against the unfiltered list) const setCount = (id, n) => { const e = document.getElementById(id); if (e) e.textContent = n; }; setCount('f-count-all', qs.length); const draftCount = qs.filter(q => q.status === 'draft').length; setCount('f-count-draft', draftCount); setCount('f-count-sent', qs.filter(q => q.status === 'sent').length); setCount('f-count-won', qs.filter(q => q.status === 'won').length); setCount('f-count-lost', qs.filter(q => q.status === 'lost').length); // Bulk action: show "Mark all drafts as sent" only when 2+ drafts. // For a single draft the per-row Mark sent button is faster. const bulkBtn = document.getElementById('bulk-mark-sent-btn'); const bulkLabel = document.getElementById('bulk-mark-sent-label'); if (bulkBtn && bulkLabel) { if (draftCount >= 2) { bulkLabel.textContent = `Mark all ${draftCount} drafts as sent`; bulkBtn.style.display = 'inline-flex'; } else { bulkBtn.style.display = 'none'; } } // Apply status filter let filtered = quotesFilter === 'all' ? qs : qs.filter(q => q.status === quotesFilter); // Apply search filter (matches name, job, quote number — case insensitive) const search = (quotesSearch || '').trim().toLowerCase(); if (search) { filtered = filtered.filter(q => { const name = String(q.client_name || '').toLowerCase(); const job = String(q.job_title || '').toLowerCase(); const num = String(q.quote_number || '').toLowerCase(); return name.includes(search) || job.includes(search) || num.includes(search); }); } // Sort by last activity (newest first) filtered = filtered.slice().sort((a, b) => lastActivityOf(b) - lastActivityOf(a)); // Update meta line const metaEl = document.getElementById('quotes-meta'); if (metaEl) { if (filtered.length === 0) { metaEl.textContent = ''; } else if (search) { metaEl.innerHTML = `${filtered.length} result${filtered.length !== 1 ? 's' : ''}`; } else { metaEl.innerHTML = `${filtered.length} quote${filtered.length !== 1 ? 's' : ''} · sorted newest first`; } } if (filtered.length === 0) { if (search) { el.innerHTML = `

No quotes match "${esc(search)}".

`; } else { el.innerHTML = emptyStateForFilter(quotesFilter); } return; } // Render with date grouping const groups = groupQuotesByDate(filtered); el.innerHTML = groups.map(g => `
${esc(g.label)}
${g.quotes.map(qRowHtml).join('')}` ).join(''); el.querySelectorAll('.q-item-clickable').forEach(btn => { btn.addEventListener('click', () => openQuoteDetail(btn.dataset.quoteId)); }); el.querySelectorAll('.q-quick-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const action = btn.dataset.action; const quoteId = btn.closest('.q-quick-actions').dataset.quoteId; handleQuickAction(quoteId, action); }); }); el.querySelectorAll('.q-copy-link-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); copyQuoteLink(btn.dataset.token, btn); }); }); el.querySelectorAll('.q-open-pdf-btn').forEach(btn => { btn.addEventListener('click', async e => { e.stopPropagation(); // Open the tab synchronously so popup blockers allow it, then point // it at the ensured-fresh URL once ready. const tab = window.open('', '_blank'); try { const url = await preparePdfWithButton(btn, btn.dataset.quoteId); if (tab) tab.location = url; else window.open(url, '_blank'); } catch (err) { if (tab) tab.close(); showToast('Could not prepare PDF. Try again.'); } }); }); } async function handleQuickAction(quoteId, action) { if (action === 'follow-up') { try { const res = await fetch(API_BASE + '/api/quote/follow-up', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: quoteId }), }); if (!res.ok) throw new Error(); showToast('Marked as followed up.'); await loadQuotes(); } catch { showToast('Could not update.'); } } else { // sent, won, lost — use mark-status try { const res = await fetch(API_BASE + '/api/quote/mark-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: quoteId, status: action }), }); if (!res.ok) throw new Error(); showToast(`Marked as ${action}.`); await loadQuotes(); } catch { showToast('Could not update.'); } } } // Bulk action: mark every quote with status='draft' as 'sent' in parallel. // No confirmation dialog — the toast confirms after. If some calls fail, // we still refresh from the server so the UI reflects whatever succeeded. async function markAllDraftsAsSent() { const drafts = (allQuotes || []).filter(q => q.status === 'draft'); if (drafts.length === 0) return; const btn = document.getElementById('bulk-mark-sent-btn'); const label = document.getElementById('bulk-mark-sent-label'); if (btn) btn.disabled = true; if (label) label.textContent = `Marking ${drafts.length}...`; const results = await Promise.allSettled(drafts.map(q => fetch(API_BASE + '/api/quote/mark-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: q.id, status: 'sent' }), }).then(r => { if (!r.ok) throw new Error('failed'); return r; }) )); const okCount = results.filter(r => r.status === 'fulfilled').length; const failCount = results.length - okCount; if (btn) btn.disabled = false; await loadQuotes(); if (failCount === 0) { showToast(`Marked ${okCount} ${okCount === 1 ? 'quote' : 'quotes'} as sent.`); } else if (okCount === 0) { showToast('Could not update. Try again.'); } else { showToast(`Marked ${okCount} as sent. ${failCount} failed.`); } } function qItemHtml(q) { const date = fmtDate(new Date(q.sent_at || q.created_at)); const total = q.total ? '$' + fmtNum(displayTotal(q)) : '—'; return ``; } // Returns a stale-level: 'fresh' | 'stale-1' (24h+) | 'stale-2' (5d+) | 'stale-3' (10d+) function quoteStaleness(q) { if (q.status !== 'sent' || q.followed_up_at) return 'fresh'; if (!q.sent_at) return 'fresh'; const ageDays = (Date.now() - new Date(q.sent_at).getTime()) / 86400000; if (ageDays >= 10) return 'stale-3'; if (ageDays >= 5) return 'stale-2'; if (ageDays >= 1) return 'stale-1'; return 'fresh'; } // Small inline-styled badge for the client's response (no CSS dependency). function clientRespBadge(cr) { if (cr === 'accepted') return '✓ Accepted'; if (cr === 'declined') return 'Declined'; return ''; } // Copy a quote's public share link. Use the apex domain + query-string form // (/quote?t=…) so the link doesn't depend on the /q rewrite or hit the www 404. // Pre-written message for sharing the quote link via the tradie's own // channel (WhatsApp / SMS / email). Customer-facing, so it leads with the // business name and leaves out the internal quote number. function clientMessage() { var biz = (profile && profile.business_name) ? profile.business_name : ''; return biz ? "Here's your quote from " + biz + ". View, download or accept it here:" : "Here's your quote. View, download or accept it here:"; } // Link-first send. On mobile, opens the native share sheet — the link is // passed as a separate url so it comes through as a tappable link/preview // rather than buried in the text. On desktop (no share sheet), copies the // message + link to paste anywhere. async function sendQuoteLink(token, quoteNumber) { if (!token) { showToast('No link for this quote'); return; } var url = 'https://betterquote.au/quote?t=' + token; var biz = (profile && profile.business_name) ? profile.business_name : ''; var text = clientMessage(); if (navigator.share) { try { await navigator.share({ title: biz ? 'Quote from ' + biz : 'Your quote', text: text, url: url }); return; } catch (err) { if (err && err.name === 'AbortError') return; } } try { await navigator.clipboard.writeText(text + ' ' + url); showToast('Quote link copied — paste it to your client'); } catch (e) { showToast('Could not share. Use Copy quote link instead.'); } } function copyQuoteLink(token, btnEl) { if (!token) { showToast('No link for this quote'); return; } const url = 'https://betterquote.au/quote?t=' + token; const done = () => { showToast('Quote link copied'); flashCopied(btnEl); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopyText(url, done)); } else { fallbackCopyText(url, done); } } function flashCopied(btn) { if (!btn) return; if (btn._origHtml == null) btn._origHtml = btn.innerHTML; clearTimeout(btn._copyTimer); btn.innerHTML = ' Copied'; btn.classList.add('copied'); btn._copyTimer = setTimeout(() => { btn.innerHTML = btn._origHtml; btn._origHtml = null; btn.classList.remove('copied'); }, 1600); } function fallbackCopyText(text, done) { const t = document.createElement('textarea'); t.value = text; t.style.position = 'fixed'; t.style.opacity = '0'; document.body.appendChild(t); t.focus(); t.select(); try { document.execCommand('copy'); done(); } catch (e) { showToast('Could not copy link'); } document.body.removeChild(t); } // Client-response banner + any messages, shown at the top of the quote detail. function clientActivityHtml(quote) { const cr = quote.client_response; const when = quote.responded_at ? fmtDate(new Date(quote.responded_at)) : ''; let banner = ''; if (cr === 'accepted') { banner = `
Client accepted this quote${when ? ' on ' + when : ''}
`; } else if (cr === 'declined') { banner = `
× Client declined this quote${when ? ' on ' + when : ''}
`; } const msgs = quote.messages || []; let messagesHtml = ''; if (msgs.length) { messagesHtml = `
Messages from client
` + msgs.map(m => `
${esc(m.body)}
${m.created_at ? fmtDate(new Date(m.created_at)) : ''}
`).join(''); } return banner + messagesHtml; } function qRowHtml(q) { const date = fmtDate(new Date(q.sent_at || q.created_at)); const total = q.total ? '$' + fmtNum(displayTotal(q)) : '—'; const stale = quoteStaleness(q); const followedUp = !!q.followed_up_at; let staleNote = ''; if (stale === 'stale-1') staleNote = 'Sent over 24 hours ago'; else if (stale === 'stale-2') staleNote = 'Sent over 5 days ago'; else if (stale === 'stale-3') staleNote = 'Sent over 10 days ago'; if (followedUp) staleNote = '✓ Followed up'; // Quick action buttons depending on current status let quickActions = ''; if (q.status === 'sent' && !followedUp) { quickActions = `
`; } else if (q.status === 'sent' && followedUp) { quickActions = `
`; } else if (q.status === 'draft') { quickActions = `
`; } return `
${q.pdf_url ? `` : ''} ${q.public_token ? ` View live quote ` : ''} ${q.public_token ? `` : ''}
${quickActions}
`; } // ── NEW QUOTE ──────────────────────────────────────────── let inputMode = 'text'; // 'text' | 'photo' | 'voice' let pendingPhoto = null; // { base64, name, dataUrl } // Voice recording state. Held at module scope so the recorder survives // across switchInputMode toggles without restarting. mediaRecorder is the // browser MediaRecorder instance; audioChunks accumulates Blob fragments // during recording; recordingTimer ticks the elapsed-time display. let mediaRecorder = null; let audioChunks = []; let recordingStartTime = 0; let recordingTimer = null; function switchInputMode(mode) { inputMode = mode; document.querySelectorAll('.nq-tab').forEach(t => t.classList.toggle('active', t.dataset.mode === mode)); document.querySelectorAll('.nq-mode').forEach(m => m.classList.remove('active')); if (mode === 'text') { document.getElementById('nq-mode-text').classList.add('active'); } else if (mode === 'photo') { document.getElementById('nq-mode-photo').classList.add('active'); } else if (mode === 'voice') { ensureVoiceModeUI(); document.getElementById('nq-mode-voice').classList.add('active'); } } // Build the Voice tab UI on first switch. Idempotent — if the section // already exists (e.g. another switch already created it), reuses it. // Sits inside the same .nq-input-modes container as text and photo. function ensureVoiceModeUI() { if (document.getElementById('nq-mode-voice')) return; // One-time stylesheet injection. Voice mode markup is created by JS so // the styles live here too rather than in dashboard.html. Style names // mirror the existing nq- prefix convention. if (!document.getElementById('nq-voice-styles')) { const style = document.createElement('style'); style.id = 'nq-voice-styles'; style.textContent = ` .nq-voice-wrap { padding: 24px 16px; text-align: center; } .nq-voice-record-btn, .nq-voice-stop-btn { width: 84px; height: 84px; border-radius: 50%; border: none; color: white; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; position: relative; margin: 4px auto 14px; transition: transform 0.15s, background 0.15s; } .nq-voice-record-btn { background: var(--navy, #0d1b2e); } .nq-voice-record-btn:hover { background: #1a3252; transform: scale(1.04); } .nq-voice-stop-btn { background: #d85a30; } .nq-voice-stop-btn:hover { background: #c44d22; } .nq-voice-pulse { position: absolute; inset: -6px; border-radius: 50%; border: 2px solid #d85a30; opacity: 0.6; animation: nqVoicePulse 1.4s ease-out infinite; } @keyframes nqVoicePulse { 0% { transform: scale(0.9); opacity: 0.6; } 70% { transform: scale(1.25); opacity: 0; } 100% { transform: scale(1.25); opacity: 0; } } .nq-voice-time { font-size: 22px; font-weight: 600; color: var(--navy, #0d1b2e); font-variant-numeric: tabular-nums; margin: 0 0 8px; } .nq-voice-hint { font-size: 13px; color: var(--grey-2, #6b7a8a); max-width: 320px; margin: 0 auto; line-height: 1.45; } .nq-voice-spinner { padding: 18px 0 10px; } .nq-voice-review-label { font-size: 13px; color: var(--grey-2, #6b7a8a); margin: 0 0 10px; text-align: left; line-height: 1.45; } #nq-voice-transcript { min-height: 120px; text-align: left; } .nq-voice-actions { display: flex; justify-content: flex-end; margin-top: 8px; } .nq-voice-redo-btn { background: transparent; border: 0.5px solid var(--border, #eef2f7); color: var(--grey-1, #4a5868); padding: 8px 14px; border-radius: 6px; font-size: 13px; cursor: pointer; } .nq-voice-redo-btn:hover { color: var(--navy, #0d1b2e); border-color: var(--grey-3, #9aabb8); } .nq-voice-error { padding: 18px; background: #fef3f0; border-radius: 8px; color: #8a2e10; font-size: 13.5px; line-height: 1.5; text-align: left; } .nq-voice-error p { margin: 0 0 12px; } `; document.head.appendChild(style); } const photoMode = document.getElementById('nq-mode-photo'); if (!photoMode || !photoMode.parentNode) { console.warn('Cannot find input modes container'); return; } const section = document.createElement('div'); section.id = 'nq-mode-voice'; section.className = 'nq-mode'; section.innerHTML = `

Tap to start recording. Describe the job in your own words.

`; photoMode.parentNode.insertBefore(section, photoMode.nextSibling); // Wire up the buttons (only after DOM insertion) document.getElementById('nq-voice-record-btn').addEventListener('click', startVoiceRecording); document.getElementById('nq-voice-stop-btn').addEventListener('click', stopVoiceRecording); document.getElementById('nq-voice-redo-btn').addEventListener('click', resetVoiceMode); document.getElementById('nq-voice-error-retry').addEventListener('click', resetVoiceMode); } function showVoicePanel(id) { ['nq-voice-idle', 'nq-voice-recording', 'nq-voice-processing', 'nq-voice-review', 'nq-voice-error'] .forEach(p => { const el = document.getElementById(p); if (el) el.style.display = p === id ? 'block' : 'none'; }); } function resetVoiceMode() { audioChunks = []; document.getElementById('nq-voice-transcript').value = ''; showVoicePanel('nq-voice-idle'); } function showVoiceError(msg) { document.getElementById('nq-voice-error-text').textContent = msg; showVoicePanel('nq-voice-error'); } // Request mic permission and start MediaRecorder. Browser support is solid // on iOS Safari 14.4+, Chrome Android, Firefox, all desktop browsers. The // recorded MIME type varies (audio/mp4 on iOS, audio/webm on Chrome); // backend handles both. async function startVoiceRecording() { try { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { showVoiceError("Your browser doesn't support voice recording. Try Chrome or Safari."); return; } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // Pick the best supported MIME type. iOS supports mp4/aac; Chrome // supports webm/opus. Letting MediaRecorder pick its default // (passing no options) works on all browsers and is simplest. mediaRecorder = new MediaRecorder(stream); audioChunks = []; mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) audioChunks.push(e.data); }; mediaRecorder.onstop = handleVoiceRecordingStop; // Pass a timeslice (1000ms) so MediaRecorder emits a chunk every second. // Without this, some browsers (notably iOS Safari) only emit a single // chunk at stop — and if anything happens to that chunk (stream error, // tab backgrounding, audio buffer overrun) you lose the whole recording. // Chunked recording also keeps memory pressure low for longer notes. mediaRecorder.start(1000); recordingStartTime = Date.now(); showVoicePanel('nq-voice-recording'); startRecordingTimer(); } catch (err) { console.error('getUserMedia failed:', err); if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { showVoiceError("Microphone permission denied. Check your browser's site settings to allow microphone access."); } else if (err.name === 'NotFoundError') { showVoiceError("No microphone found on this device."); } else { showVoiceError("Couldn't access the microphone. Try again."); } } } function startRecordingTimer() { stopRecordingTimer(); const tEl = document.getElementById('nq-voice-time'); if (!tEl) return; const tick = () => { const sec = Math.floor((Date.now() - recordingStartTime) / 1000); const m = Math.floor(sec / 60); const s = sec % 60; tEl.textContent = `${m}:${String(s).padStart(2, '0')}`; }; tick(); recordingTimer = setInterval(tick, 250); } function stopRecordingTimer() { if (recordingTimer) { clearInterval(recordingTimer); recordingTimer = null; } } function stopVoiceRecording() { if (!mediaRecorder || mediaRecorder.state === 'inactive') return; stopRecordingTimer(); showVoicePanel('nq-voice-processing'); mediaRecorder.stop(); // Release the mic mediaRecorder.stream.getTracks().forEach(t => t.stop()); } // Called when MediaRecorder stops and chunks are ready to upload. // Assembles chunks into one Blob, base64-encodes, posts to the backend. async function handleVoiceRecordingStop() { try { if (audioChunks.length === 0) { showVoiceError("No audio was recorded. Try again."); return; } const mimeType = audioChunks[0].type || 'audio/webm'; const blob = new Blob(audioChunks, { type: mimeType }); // Hard cap on duration — anything over 90s is almost certainly an // accidental hold, not a job description. Reject early to save a call. const recordedSec = (Date.now() - recordingStartTime) / 1000; if (recordedSec > 180) { showVoiceError("Recording too long. Keep voice notes under 3 minutes."); return; } if (recordedSec < 1) { showVoiceError("Recording too short. Tap and hold while you speak."); return; } // Convert Blob → base64. Browsers don't have a one-liner for this; // FileReader is the standard cross-browser approach. const base64 = await blobToBase64(blob); const res = await fetch(API_BASE + '/api/audio/transcribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audio_base64: base64, mime_type: mimeType }), }); if (!res.ok) { const errBody = await res.json().catch(() => ({})); showVoiceError(errBody.error || 'Could not transcribe. Try again.'); return; } const { transcript } = await res.json(); if (!transcript || !transcript.trim()) { showVoiceError("Couldn't understand the recording. Try again somewhere quieter."); return; } document.getElementById('nq-voice-transcript').value = transcript; showVoicePanel('nq-voice-review'); } catch (err) { console.error('Voice processing failed:', err); showVoiceError('Could not process recording. Try again.'); } } function blobToBase64(blob) { return new Promise((resolve, reject) => { const r = new FileReader(); r.onerror = () => reject(new Error('FileReader failed')); r.onload = () => { // Result is "data:;base64," — strip the prefix const s = r.result || ''; const i = s.indexOf(','); resolve(i >= 0 ? s.slice(i + 1) : s); }; r.readAsDataURL(blob); }); } function selectQuotePhoto() { document.getElementById('file-quote-photo').click(); } async function handleQuotePhotoSelected(file) { if (!file) return; if (file.size > 10 * 1024 * 1024) { showToast('Photo too large. Max 10MB.'); return; } const reader = new FileReader(); reader.onload = () => { pendingPhoto = { base64: reader.result.split(',')[1], name: file.name, dataUrl: reader.result, }; document.getElementById('nq-photo-img').src = pendingPhoto.dataUrl; document.getElementById('nq-photo-empty').style.display = 'none'; document.getElementById('nq-photo-preview').style.display = 'block'; }; reader.readAsDataURL(file); } function removeQuotePhoto() { pendingPhoto = null; document.getElementById('nq-photo-img').src = ''; document.getElementById('nq-photo-empty').style.display = 'block'; document.getElementById('nq-photo-preview').style.display = 'none'; document.getElementById('nq-photo-caption').value = ''; } async function generateQuote() { if (trialInfo().blocked) { renderTrialBanner(); document.getElementById('trial-banner')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } const btn = document.getElementById('gen-btn'); btn.disabled = true; btn.innerHTML = 'Building quote...'; try { let quote; const isManual = !!document.querySelector('.nqm-mode-btn[data-qmode="manual"].active'); if (isManual) { const items = collectManualItems(); if (!items.length) { showToast('Add at least one line item.'); throw new Error('no-items'); } const res = await fetch(API_BASE + '/api/quote-manual', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: (document.getElementById('nqm-title')?.value || '').trim(), items, client_name: (document.getElementById('nqm-client')?.value || '').trim(), site_address: (document.getElementById('nqm-address')?.value || '').trim(), polish: !!document.querySelector('#nqm-polish.on'), profile_id: profile?.id, }), }); if (res.status === 402) { if (profile) profile.subscription_status = 'expired'; renderTrialBanner(); throw new Error('trial_expired'); } if (!res.ok) throw new Error(`API ${res.status}`); quote = await res.json(); } else if (inputMode === 'photo') { if (!pendingPhoto) { showToast('Add a photo first.'); throw new Error('no-photo'); } const res = await fetch(API_BASE + '/api/quote-from-photo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: profile?.id, image_data: pendingPhoto.base64, image_name: pendingPhoto.name, caption: document.getElementById('nq-photo-caption').value.trim(), client_name: document.getElementById('nq-client').value.trim(), site_address: document.getElementById('nq-address').value.trim(), }), }); if (res.status === 402) { if (profile) profile.subscription_status = 'expired'; renderTrialBanner(); throw new Error('trial_expired'); } if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || `API ${res.status}`); } quote = await res.json(); } else { const desc = document.getElementById('nq-desc').value.trim(); if (!desc) { showToast('Describe the job first.'); throw new Error('no-desc'); } const res = await fetch(API_BASE + '/api/quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: desc, client_name: document.getElementById('nq-client').value.trim(), site_address: document.getElementById('nq-address').value.trim(), budget: document.getElementById('nq-budget').value.trim(), profile_id: profile?.id, }), }); if (res.status === 402) { if (profile) profile.subscription_status = 'expired'; renderTrialBanner(); throw new Error('trial_expired'); } if (!res.ok) throw new Error(`API ${res.status}`); quote = await res.json(); } pendingQuote = quote; renderPreview(quote); // Swap the page from input mode to review mode: hide the input form // so the user lands directly on the generated quote and doesn't have // to scroll past their typed description to see the result. document.getElementById('nq-input-area').style.display = 'none'; document.getElementById('nq-preview').style.display = 'block'; window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (err) { console.error('Quote generation failed:', err); if (err.message === 'trial_expired') { document.getElementById('trial-banner')?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } else if (err.message !== 'no-photo' && err.message !== 'no-desc' && err.message !== 'no-items') { showToast(err.message || 'Could not build quote. Please try again.'); } } btn.disabled = trialInfo().blocked; btn.innerHTML = 'Generate quote '; } function renderPreview(q) { // Reset confirm button to fresh state. This is defensive — after a // successful confirm, showPDFPreview hides the preview panel but // doesn't reset the button. So when a NEW quote is generated and the // preview re-shows, the button can carry over its stale "Saving..." // disabled state from the previous confirm. Label varies by edit mode. const cb = document.getElementById('confirm-btn'); if (cb) { cb.disabled = false; cb.textContent = editingQuoteId ? 'Save changes and update PDF' : 'Confirm and create PDF'; } // Fresh quote starts in 'full' mode; when re-entering an existing draft // via "Back to edit" we preserve the saved display_mode set in // backToEditQuote() before this function ran. if (!editingQuoteId) { pendingDisplayMode = 'full'; } syncDisplayModeUI(); document.getElementById('pv-title').textContent = q.job_title || 'Quote'; document.getElementById('pv-ref').textContent = q.client_name ? `Prepared for ${q.client_name}` : ''; document.getElementById('pv-scope').textContent = q.scope_of_works || ''; const container = document.getElementById('pv-items'); container.innerHTML = ''; (q.items || []).forEach((item, idx) => container.appendChild(buildLineItemEl(item, idx))); updatePreviewTotal(); // Budget headroom note // q.total is stored ex-GST. Budget input is inc-GST by default, but the // tradie can override with phrasing like "$20K + GST". We mirror the same // detection regex that BUDGET_PROMPT uses in claude.js so the comparison // happens on the same scale as the budget the tradie typed. const budgetNote = document.getElementById('pv-budget-note'); const budgetEl = document.getElementById('pv-budget-text'); const budgetInput = document.getElementById('nq-budget').value.trim(); const budgetNum = parseFloat((budgetInput || '').replace(/[^0-9.]/g, '')); const totalEx = q.total || 0; if (budgetNum > 0 && totalEx > 0) { const budgetTxt = budgetInput.toLowerCase(); const budgetIsExGst = /\bex[\s\-]?gst\b/.test(budgetTxt) || /\b\+\s*gst\b/.test(budgetTxt) || /\bplus\s+gst\b/.test(budgetTxt) || /\bbefore\s+gst\b/.test(budgetTxt) || /\bwithout\s+gst\b/.test(budgetTxt) || /\bnet\s+of\s+gst\b/.test(budgetTxt); // The number we display and compare must match the scale of the budget. // - Inc-GST budget (default): show inc-GST total = totalEx * 1.1 // - Ex-GST budget: show ex-GST total = totalEx const displayTotal = budgetIsExGst ? Math.round(totalEx) : Math.round(totalEx * 1.1); const diff = budgetNum - displayTotal; if (Math.abs(diff) < 50) { budgetEl.innerHTML = `Estimate matches your $${fmtNum(budgetNum)} budget.`; } else if (diff > 0) { budgetEl.innerHTML = `You set a $${fmtNum(budgetNum)} budget. Estimated cost is $${fmtNum(displayTotal)} — you have $${fmtNum(diff)} of headroom. Use the chat below to make any changes.`; } else { budgetEl.innerHTML = `Estimated cost is $${fmtNum(displayTotal)} — that's $${fmtNum(-diff)} over your budget of $${fmtNum(budgetNum)}. Use the chat below to make any changes.`; } budgetNote.style.display = 'flex'; } else { budgetNote.style.display = 'none'; } // Tradie-facing margin callout. Always visible so the tradie is // reminded what margin they're applying — useful during edits because // it prompts them to consider whether their changes are eating into // margin. Shows percentage from profile.default_margin as the // baseline; backend-computed dollar amount appears alongside it when // the quote was generated with cost_price tracking. const marginBlock = document.getElementById('pv-margin'); if (marginBlock && isManualQuote(q)) { // Self-priced quotes have no cost/sell split — hide the internal margin box. marginBlock.style.display = 'none'; } else if (marginBlock) { // Derive margin from the line items — cost_price is the persisted source // of truth (saved per item), whereas the top-level margin_amt is computed // at generation and never written to the quotes table. Reading it directly // is why "$ —" kept returning: it's absent on any quote reloaded from the // DB (open, back-to-edit, duplicate). Deriving here makes the box correct // on every path, with nothing to carry or persist. let marginAmt = 0, marginCost = 0, hasCostData = false; for (const it of (q.items || [])) { if (it.cost_price != null) hasCostData = true; const cost = Number(it.cost_price ?? it.unit_price ?? 0); const price = Number(it.unit_price ?? 0); const qty = Number(it.quantity ?? 0); marginAmt += (price - cost) * qty; marginCost += cost * qty; } const amt = Math.round(marginAmt); // Real margin % embedded in the saved prices — but only when at least one // item actually carries cost_price. Without this guard, the cost←unit_price // fallback above makes marginCost > 0 with zero margin, so the box showed // 0% and the intended fallback below was unreachable (the "Margin (0%)" // bug on duplicates and pre-cost-migration quotes). const pct = (hasCostData && marginCost > 0) ? Math.round((marginAmt / marginCost) * 100) : Number(q.margin_pct ?? profile?.default_margin ?? 20); document.getElementById('pv-margin-pct-label').textContent = `Margin (${pct}%)`; // Dollar amount only when we have it; for quotes generated before // the cost_price migration we just show the percentage on its own. const amtEl = document.getElementById('pv-margin-amt'); amtEl.textContent = amt > 0 ? '$' + fmtNum(amt) : '—'; // 'flex' (not 'block') — pv-margin is a single-line flex row marginBlock.style.display = 'flex'; } } function buildLineItemEl(item, idx) { const row = document.createElement('div'); row.className = 'li-row'; row.dataset.idx = idx; // Extend the CSS 4-col grid with a trailing cell holding edit + delete // controls (inline override — no stylesheet change needed). row.style.gridTemplateColumns = '1fr 46px 62px 62px 48px'; row.onclick = () => startEditItem(row, idx); row.innerHTML = `
${esc(item.description)}
${fmtQty(item.quantity)}
$${fmtNum(item.unit_price)}
$${fmtNum(item.amount)}
`; const editBtn = row.querySelector('.li-edit'); editBtn.addEventListener('click', (e) => { e.stopPropagation(); startEditItem(row, idx); }); const delBtn = row.querySelector('.li-del'); delBtn.addEventListener('click', (e) => { e.stopPropagation(); deleteLineItem(idx); }); return row; } // Remove a line item and re-render. Splicing shifts subsequent indices, so we // rebuild every row from the current array (fresh idx) rather than dropping a // single DOM node, then re-total. No confirm — it's a low-stakes, repeatable // edit and the tradie can re-add or regenerate. function deleteLineItem(idx) { if (!pendingQuote || !Array.isArray(pendingQuote.items)) return; pendingQuote.items.splice(idx, 1); const container = document.getElementById('pv-items'); if (container) { container.innerHTML = ''; pendingQuote.items.forEach((item, i) => container.appendChild(buildLineItemEl(item, i))); } updatePreviewTotal(); } function startEditItem(row, idx) { if (row.classList.contains('editing')) return; row.classList.add('editing'); row.onclick = null; const item = pendingQuote.items[idx]; row.innerHTML = `
`; const qtyEl = document.getElementById(`ei-qty-${idx}`); const priceEl = document.getElementById(`ei-price-${idx}`); const amtEl = document.getElementById(`ei-amt-${idx}`); function recalc() { const a = parseFloat(qtyEl.value) * parseFloat(priceEl.value) || 0; amtEl.value = a.toFixed(2); } qtyEl.oninput = priceEl.oninput = recalc; function commit() { item.description = document.getElementById(`ei-desc-${idx}`).value.trim() || item.description; item.quantity = parseFloat(qtyEl.value) || item.quantity; item.unit_price = parseFloat(priceEl.value) || item.unit_price; item.amount = parseFloat(amtEl.value) || item.amount; const newRow = buildLineItemEl(item, idx); row.replaceWith(newRow); updatePreviewTotal(); } [document.getElementById(`ei-desc-${idx}`), qtyEl, priceEl].forEach(el => { el.addEventListener('blur', () => setTimeout(() => { if (!row.contains(document.activeElement)) commit(); }, 120)); }); document.getElementById(`ei-desc-${idx}`).focus(); } function updatePreviewTotal() { if (!pendingQuote) return; // Items are ex-GST per Convention A. Compute breakdown matching the PDF. const subtotal = Math.round((pendingQuote.items || []).reduce((s, i) => s + (parseFloat(i.amount) || 0), 0)); const gst = Math.round(subtotal * 0.1); const totalInc = subtotal + gst; // pendingQuote.total stays ex-GST (Convention A) — it's what gets stored. // Budget headroom math (which multiplies q.total by 1.1) still works correctly. pendingQuote.subtotal = subtotal; pendingQuote.total = subtotal; document.getElementById('pv-subtotal').textContent = '$' + fmtNum(subtotal); document.getElementById('pv-gst').textContent = '$' + fmtNum(gst); document.getElementById('pv-total').textContent = '$' + fmtNum(totalInc); // Re-render the alt view too if we're in summary or scope_only mode if (pendingDisplayMode !== 'full') renderAltView(); } // Reflect the current pendingDisplayMode in the UI: highlight the active // toggle button and toggle the body-level class that drives which view // (full editable / summary / scope_only) is visible. // A self-priced ("I'll build it") quote: every line's cost equals its sell // price (no margin). The in-memory `manual` flag is set on generation but // isn't persisted, so we also infer it from the saved data — this keeps the // margin box and the customer-view toggle hidden after Back to edit / reopen. function isManualQuote(q) { if (!q) return false; if (q.manual) return true; return Array.isArray(q.items) && q.items.length > 0 && q.items.every(it => it.cost_price != null && Number(it.cost_price) === Number(it.unit_price)); } function syncDisplayModeUI() { // Self-priced quotes: the tradie built exactly what's delivered, so hide // the "what the customer sees" detail toggle and lock to the full breakdown. const dmToggle = document.querySelector('.dm-toggle:not(.qd-dm-toggle)'); if (isManualQuote(pendingQuote)) { pendingDisplayMode = 'full'; if (dmToggle) dmToggle.style.display = 'none'; } else if (dmToggle) { dmToggle.style.display = ''; } document.querySelectorAll('.dm-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.mode === pendingDisplayMode); }); // Body class drives single-view toggling via CSS: // .dm-mode-full → editable line items + Subtotal/GST/Total (default visible) // .dm-mode-summary → grouped category rows + totals // .dm-mode-scope_only→ centred AMOUNT DUE hero block document.body.classList.remove('dm-mode-full', 'dm-mode-summary', 'dm-mode-scope_only'); document.body.classList.add('dm-mode-' + pendingDisplayMode); } function setDisplayMode(mode) { if (!['full', 'summary', 'scope_only'].includes(mode)) return; pendingDisplayMode = mode; syncDisplayModeUI(); if (mode !== 'full') renderAltView(); } window._setDisplayMode = setDisplayMode; // Render the alternate view (summary or scope_only) into #pv-alt-view. // Mirrors what the customer will actually see on the PDF. function renderAltView() { const target = document.getElementById('pv-alt-view'); if (!target || !pendingQuote) return; const items = pendingQuote.items || []; const subtotal = Math.round(items.reduce((s, i) => s + (parseFloat(i.amount) || 0), 0)); const gst = Math.round(subtotal * 0.1); const totalInc = subtotal + gst; if (pendingDisplayMode === 'scope_only') { // Narrower centred navy block — matches the PDF design (Option 1) target.innerHTML = `

Amount due

$${fmtNum(totalInc)}

Includes GST of $${fmtNum(gst)}

`; return; } if (pendingDisplayMode === 'summary') { // Grouped view: TWO rows only — Labour and Materials. // The toggle button literally says "Labour & materials" so the output // must match. Internal category → display bucket mapping: // labour → Labour // subcontractors → Labour (people doing work, regardless of whose payroll) // materials → Materials // equipment → Materials (tangible, customer-readable as a "thing") // disposal → Materials (waste-removal feels material-adjacent // to a customer; keeping it visible would bloat the view) // The prompt enforces a SUBCONTRACTOR SPLIT RULE so a tiler engaged on a // bathroom reno comes back as two line items (tiles in materials, tiling // labour in labour) rather than one big subcontractor lump. That keeps // the materials figure healthy in the customer's view. const LABOUR_CATS = new Set(['labour', 'subcontractors']); let labourTotal = 0; let materialsTotal = 0; for (const it of items) { const amt = Number(it.amount || 0); const lab = Number(it.labour_amount); const mat = Number(it.material_amount); if (Number.isFinite(lab) && Number.isFinite(mat) && (lab + mat) > 0) { // Per-line split present (combined "supply and install" lines carry both) — use it labourTotal += lab; materialsTotal += mat; } else { // Fallback for older quotes / lines without a split: bucket by category if (LABOUR_CATS.has(it.category)) labourTotal += amt; else materialsTotal += amt; } } const rows = []; if (labourTotal > 0) { rows.push(`
Labour $${fmtNum(Math.round(labourTotal))}
`); } if (materialsTotal > 0) { rows.push(`
Materials $${fmtNum(Math.round(materialsTotal))}
`); } const groupedRows = rows.join(''); target.innerHTML = ` ${groupedRows || '
No categorised items yet
'}
Subtotal $${fmtNum(subtotal)}
GST (10%) $${fmtNum(gst)}
Total (inc GST) $${fmtNum(totalInc)}
`; } } // Tiny HTML escape for safety when injecting user-provided strings function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } async function confirmQuote() { if (!pendingQuote || !profile) return; const btn = document.getElementById('confirm-btn'); btn.disabled = true; btn.innerHTML = 'Saving and generating PDF...'; // If we're in duplicate mode, pull customer details from the duplicate inputs const dupCustomer = document.getElementById('dup-customer'); if (dupCustomer && dupCustomer.style.display !== 'none') { const dupClient = document.getElementById('dup-client'); const dupAddress = document.getElementById('dup-address'); if (dupClient) pendingQuote.client_name = dupClient.value.trim(); if (dupAddress) pendingQuote.site_address = dupAddress.value.trim(); } try { const rawMessage = inputMode === 'photo' ? document.getElementById('nq-photo-caption').value.trim() : inputMode === 'voice' ? (document.getElementById('nq-voice-transcript')?.value || '').trim() : document.getElementById('nq-desc').value.trim(); // Two paths: // - Fresh new quote → /api/quote/save-and-confirm (assigns new number) // - Editing existing draft via "Back to edit" → /api/quote/update-and-regenerate // (reuses the same quote_number; replaces items; regenerates PDF/PNG) const isEditing = !!editingQuoteId; const endpoint = isEditing ? '/api/quote/update-and-regenerate' : '/api/quote/save-and-confirm'; const body = isEditing ? { quote_id: editingQuoteId, profile_id: profile.id, quote: pendingQuote, display_mode: pendingDisplayMode, } : { profile_id: profile.id, quote: pendingQuote, raw_message: rawMessage, display_mode: pendingDisplayMode, }; const res = await fetch(API_BASE + endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Save failed'); } const { pdf_url, quote_number, quote_id, public_token } = await res.json(); if (!isEditing) { // Only bump the counter for new quotes — editing reuses the existing number profile.quote_counter = (profile.quote_counter || 1001) + 1; } // Clear edit state — the next confirm should be treated as a fresh quote // unless the tradie hits "Back to edit" again editingQuoteId = null; showPDFPreview(pdf_url, quote_number, quote_id, public_token); await loadQuotes(); } catch (err) { console.error('Confirm failed:', err); showToast(err.message || 'Could not save quote.'); btn.disabled = false; btn.textContent = 'Confirm and create PDF'; } } // Return to the editable preview with an already-confirmed quote loaded. // Fetches the quote + items from the database (rather than relying on // pendingQuote, which may have been cleared) so the user can edit and // re-confirm. Re-confirm uses /update-and-regenerate to overwrite in place. async function backToEditQuote(quoteId) { try { // Load via the backend (service-role read) so line items come back // reliably. Reading quote_items directly with the anon client returns // an empty set under RLS, which was wiping items on re-edit. const res = await fetch(API_BASE + '/api/quote/' + quoteId); if (!res.ok) throw new Error('Could not load quote'); const quote = await res.json(); // Reconstruct pendingQuote shape that renderPreview expects pendingQuote = { job_title: quote.job_title, scope_of_works: quote.scope_of_works, client_name: quote.client_name, site_address: quote.site_address, notes: quote.notes, whats_included: quote.whats_included, subtotal: quote.subtotal, total: quote.total, items: quote.items || [], }; editingQuoteId = quoteId; pendingDisplayMode = quote.display_mode || 'full'; // Hide the post-confirm screen, show the editable preview document.getElementById('nq-pdf-preview').style.display = 'none'; document.getElementById('nq-input-area').style.display = 'none'; document.getElementById('nq-preview').style.display = 'block'; renderPreview(pendingQuote); window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (err) { console.error('Back-to-edit failed:', err); showToast(err.message || 'Could not load quote for editing.'); } } function showPDFPreview(pdf_url, quote_number, quote_id, public_token) { document.getElementById('nq-input-area').style.display = 'none'; document.getElementById('nq-preview').style.display = 'none'; // Clear duplicate UI if it was showing hideDuplicateUI(); const wrap = document.getElementById('nq-pdf-preview'); wrap.style.display = 'block'; wrap.innerHTML = `

Quote #${quote_number} ready

Saved as draft. Send it to your client below.

Quote preview
Preview generating — open the PDF below to view.

Open PDF in new tab

View live quote
`; // Wire up the Share button via addEventListener (cleaner than escaping // pdf_url + quote_number into an inline onclick string, which broke // silently due to nested template-literal quoting). const sendBtn = document.getElementById('post-confirm-send-btn'); if (sendBtn) { sendBtn.addEventListener('click', () => sendQuoteLink(public_token, quote_number)); } const copyBtn = document.getElementById('post-confirm-copy-btn'); if (copyBtn) { copyBtn.addEventListener('click', () => copyQuoteLink(public_token, copyBtn)); } const sharePdfBtn = document.getElementById('post-confirm-sharepdf-btn'); if (sharePdfBtn) { sharePdfBtn.addEventListener('click', () => shareQuote(pdf_url, quote_number)); } // Save this job as a reusable bundle. pendingQuote still holds the // confirmed quote's data at this point, so its job_title makes a good // default name. const bundleBtn = document.getElementById('post-confirm-bundle-btn'); if (bundleBtn) { bundleBtn.addEventListener('click', () => saveAsBundle(quote_id, pendingQuote?.job_title || '')); } // "Back to edit" — return to the editable preview with the same data // loaded. Re-confirm will overwrite the PDF/PNG using the same // quote_number, so the tradie can iterate without quote-number sprawl. const editBtn = document.getElementById('post-confirm-edit-btn'); if (editBtn) { editBtn.addEventListener('click', () => backToEditQuote(quote_id)); } } // Expose for inline handlers // ── SHARE QUOTE ────────────────────────────────────────── // Triggers native share sheet on mobile (iOS/Android). Falls back to // clipboard copy on desktop or browsers without share support. When file // sharing is supported, shares the PDF directly so the recipient gets the // file attached (better UX in WhatsApp/Messages than a link they have to // tap and download). async function shareQuote(pdfUrl, quoteNumber) { if (!pdfUrl) { showToast('No PDF available to share.'); return; } const fileName = quoteFilename(quoteNumber); const shareTitle = `Quote #${quoteNumber}`; const shareText = profile?.business_name ? `Quote #${quoteNumber} from ${profile.business_name}` : `Quote #${quoteNumber}`; // Try native file share first (iOS 15+, Chrome on Android) if (navigator.canShare && navigator.share) { try { const res = await fetch(pdfUrl); if (!res.ok) throw new Error('PDF fetch failed'); const blob = await res.blob(); const file = new File([blob], fileName, { type: 'application/pdf' }); if (navigator.canShare({ files: [file] })) { try { await navigator.share({ title: shareTitle, text: shareText, files: [file], }); return; // success } catch (err) { // User cancelled — not an error, just stop silently if (err.name === 'AbortError') return; // Otherwise fall through to URL-only share } } } catch (err) { // Couldn't fetch the PDF as a file, fall through to URL share } } // Fall back to URL share (older browsers, or file share unsupported) if (navigator.share) { try { await navigator.share({ title: shareTitle, text: shareText, url: pdfUrl, }); return; } catch (err) { if (err.name === 'AbortError') return; // Fall through to clipboard } } // Final fallback: copy URL to clipboard (desktop, etc.) try { await navigator.clipboard.writeText(pdfUrl); showToast('Link copied to clipboard.'); } catch (err) { showToast('Could not share. Use "Open PDF in new tab" instead.'); } } window._shareQuote = (pdfUrl, quoteNumber) => shareQuote(pdfUrl, quoteNumber); // ── On-demand PDF: ensure-fresh helpers ───────────────────────────── // The stored PDF can be stale after a customer-view toggle (the fast // /api/quote/confirm marks pdf_stale instead of regenerating). Every // download/share path asks the backend to regenerate-if-stale first; in // the common (fresh) case this returns the stored URL in one fast call. async function ensureFreshPdfUrl(quoteId) { const res = await fetch(API_BASE + '/api/quote/' + quoteId + '/pdf', { method: 'POST' }); if (!res.ok) throw new Error('PDF prepare failed: ' + res.status); const body = await res.json(); if (!body.pdf_url) throw new Error('No pdf_url returned'); // Strip any query string — same convention as the old direct links. return body.pdf_url.split('?')[0]; } // Swap a button into a disabled "Preparing PDF…" state while ensuring. async function preparePdfWithButton(btn, quoteId) { const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = 'Preparing PDF…'; try { return await ensureFreshPdfUrl(quoteId); } finally { btn.disabled = false; btn.innerHTML = orig; } } window._toggleEmail = () => { const w = document.getElementById('email-wrap'); w.style.display = w.style.display === 'none' || !w.style.display ? 'block' : 'none'; if (w.style.display === 'block') document.getElementById('client-email').focus(); }; window._emailToClient = async (quote_id) => { const to_email = document.getElementById('client-email').value.trim(); if (!to_email || !to_email.includes('@')) { showToast('Enter a valid email.'); return; } showToast('Sending email...'); try { const res = await fetch(API_BASE + '/api/quote/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id, to_email }), }); if (res.status === 409) { // Connection went away between page load and now — refresh status, // hint the user toward Settings, and don't show generic error. showToast('Reconnect your email in Settings to send.'); emailConnection.refreshStatus(); return; } if (!res.ok) throw new Error(); showToast('Email sent. Quote marked as sent.'); document.getElementById('email-wrap').style.display = 'none'; // Update the mark-sent button to reflect new status const btn = document.getElementById('mark-sent-btn'); if (btn) { btn.textContent = '✓ Marked as sent'; btn.disabled = true; btn.style.opacity = '0.6'; } await loadQuotes(); } catch { showToast('Could not send email.'); } }; window._goToEmailSettings = () => { showPage('settings'); setTimeout(() => { const el = document.getElementById('email-conn-disconnected') || document.getElementById('email-conn-connected'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 200); }; window._markSent = async (quote_id) => { const btn = document.getElementById('mark-sent-btn'); btn.disabled = true; btn.textContent = 'Marking...'; try { const res = await fetch(API_BASE + '/api/quote/mark-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id, status: 'sent' }), }); if (!res.ok) throw new Error(); btn.textContent = '✓ Marked as sent'; btn.style.opacity = '0.6'; showToast('Quote marked as sent.'); await loadQuotes(); } catch (err) { showToast('Could not mark as sent.'); btn.disabled = false; btn.textContent = 'Mark as sent to client'; } }; window._newQuote = () => { document.getElementById('nq-pdf-preview').style.display = 'none'; document.getElementById('nq-input-area').style.display = 'block'; pendingQuote = null; editingQuoteId = null; document.getElementById('nq-desc').value = ''; document.getElementById('nq-client').value = ''; document.getElementById('nq-address').value = ''; document.getElementById('nq-budget').value = ''; removeQuotePhoto(); switchInputMode('text'); hideDuplicateUI(); window.scrollTo(0, 0); }; async function applyChatEdit() { const input = document.getElementById('chat-edit-input'); const instruction = input.value.trim(); if (!instruction || !pendingQuote) return; const btn = document.getElementById('chat-edit-btn'); btn.disabled = true; btn.innerHTML = ''; try { const res = await fetch(API_BASE + '/api/quote/edit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote: pendingQuote, instruction }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Edit failed'); } const edited = await res.json(); pendingQuote = edited; renderPreview(edited); input.value = ''; showToast('Quote updated.'); } catch (err) { console.error('Chat edit failed:', err); showToast(err.message || 'Could not apply edit.'); } btn.disabled = false; btn.innerHTML = ''; } // ── LOGO UPLOAD ────────────────────────────────────────── function renderLogo() { const empty = document.getElementById('logo-empty'); const preview = document.getElementById('logo-preview'); const img = document.getElementById('logo-img'); if (profile && profile.logo_url) { img.src = profile.logo_url; empty.style.display = 'none'; preview.style.display = 'flex'; } else { empty.style.display = 'block'; preview.style.display = 'none'; } } // Brand colour pickers in Settings. Auto-populated by the colour // extractor at logo upload time; tradie can override here if the // extraction got it wrong. Visible only when a logo has been uploaded — // before that, the BetterQuote defaults apply and there's nothing for // the tradie to customise yet (they have no logo to coordinate with). const BQ_DEFAULT_PRIMARY = '#0d1b2e'; const BQ_DEFAULT_ACCENT = '#85b7eb'; function renderBrandColours() { const block = document.getElementById('brand-colours'); if (!block) return; // Settings markup not present on this page const hasLogo = !!(profile && profile.logo_url); block.classList.toggle('show', hasLogo); if (!hasLogo) return; const primary = profile.brand_primary || BQ_DEFAULT_PRIMARY; const accent = profile.brand_accent || BQ_DEFAULT_ACCENT; document.getElementById('s-brand-primary').value = primary; document.getElementById('s-brand-primary-hex').value = primary.toUpperCase(); document.getElementById('s-brand-accent').value = accent; document.getElementById('s-brand-accent-hex').value = accent.toUpperCase(); updateBrandPreview(); } // Live preview strip — reflects whatever the pickers currently show. // Called whenever swatch or hex inputs change. function updateBrandPreview() { const primaryEl = document.getElementById('s-brand-primary'); const accentEl = document.getElementById('s-brand-accent'); if (!primaryEl || !accentEl) return; const preview = document.getElementById('brand-preview'); const accentLbl = document.querySelector('.brand-preview-accent'); preview.style.background = primaryEl.value; accentLbl.style.color = accentEl.value; // Pick readable text colour for the business-name part based on // primary's luminance. Mirrors the PDF's pickTextOn() logic so the // preview matches what the customer will actually see. const lum = hexLuminance(primaryEl.value); preview.querySelector('.brand-preview-business').style.color = lum > 0.45 ? '#1a1a1a' : '#ffffff'; } function hexLuminance(hex) { if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) return 0; const c = i => { const v = parseInt(hex.slice(i, i + 2), 16) / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * c(1) + 0.7152 * c(3) + 0.0722 * c(5); } function isValidHex(s) { return /^#[0-9A-Fa-f]{6}$/.test(s); } // Wire up two-way sync: swatch (native colour picker) and hex text // input represent the same value. Editing one updates the other and // refreshes the live preview. function wireBrandColourInputs() { const swatchPrimary = document.getElementById('s-brand-primary'); const hexPrimary = document.getElementById('s-brand-primary-hex'); const swatchAccent = document.getElementById('s-brand-accent'); const hexAccent = document.getElementById('s-brand-accent-hex'); if (!swatchPrimary) return; // Settings DOM not loaded yet swatchPrimary.addEventListener('input', () => { hexPrimary.value = swatchPrimary.value.toUpperCase(); updateBrandPreview(); }); swatchAccent.addEventListener('input', () => { hexAccent.value = swatchAccent.value.toUpperCase(); updateBrandPreview(); }); hexPrimary.addEventListener('input', () => { const v = hexPrimary.value.trim(); if (isValidHex(v)) { swatchPrimary.value = v; updateBrandPreview(); } }); hexAccent.addEventListener('input', () => { const v = hexAccent.value.trim(); if (isValidHex(v)) { swatchAccent.value = v; updateBrandPreview(); } }); document.getElementById('brand-reset-btn').addEventListener('click', () => { document.getElementById('s-brand-primary').value = BQ_DEFAULT_PRIMARY; document.getElementById('s-brand-primary-hex').value = BQ_DEFAULT_PRIMARY.toUpperCase(); document.getElementById('s-brand-accent').value = BQ_DEFAULT_ACCENT; document.getElementById('s-brand-accent-hex').value = BQ_DEFAULT_ACCENT.toUpperCase(); updateBrandPreview(); }); } async function handleLogoSelected(file) { if (!file || !profile) return; if (file.size > 2 * 1024 * 1024) { showToast('Logo too large. Max 2MB.'); return; } showToast('Uploading logo...'); try { const reader = new FileReader(); reader.onload = async () => { const base64 = reader.result.split(',')[1]; const res = await fetch(API_BASE + '/api/profile/logo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: profile.id, file_data: base64, file_name: file.name, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Upload failed'); } const data = await res.json(); profile.logo_url = data.logo_url; // The upload endpoint returns the just-extracted brand colours // (or undefined if extraction failed/timed out). Update locally so // renderBrandColours() shows the new values without needing a // full /api/profile/get round-trip. if (data.brand_primary) profile.brand_primary = data.brand_primary; if (data.brand_accent) profile.brand_accent = data.brand_accent; renderLogo(); renderBrandColours(); renderSetupChecklist(); showToast('Logo uploaded.'); }; reader.readAsDataURL(file); } catch (err) { console.error('Logo upload failed:', err); showToast(err.message || 'Could not upload logo.'); } } async function removeLogo() { if (!profile || !profile.logo_url) return; if (!confirm('Remove your logo?')) return; try { const res = await fetch(API_BASE + '/api/profile/logo-remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: profile.id }), }); if (!res.ok) throw new Error('Remove failed'); profile.logo_url = null; // Also clear the cached brand colours — extraction will rerun on // next upload. Leaving stale values would mean a new logo uploaded // before extraction completes would briefly show the old logo's // colours. profile.brand_primary = null; profile.brand_accent = null; renderLogo(); renderBrandColours(); renderSetupChecklist(); showToast('Logo removed.'); } catch (err) { console.error('Logo remove failed:', err); showToast('Could not remove logo.'); } } function hideDuplicateUI() { const banner = document.getElementById('dup-banner'); if (banner) banner.style.display = 'none'; const dupCustomer = document.getElementById('dup-customer'); if (dupCustomer) dupCustomer.style.display = 'none'; } function cancelQuote() { pendingQuote = null; editingQuoteId = null; document.getElementById('nq-preview').style.display = 'none'; document.getElementById('nq-input-area').style.display = 'block'; document.getElementById('pv-items').innerHTML = ''; hideDuplicateUI(); window.scrollTo({ top: 0, behavior: 'smooth' }); } // ── DUPLICATE QUOTE ────────────────────────────────────── // Tap "Duplicate quote" on an existing quote → open New Quote screen // pre-filled with the source quote's line items, scope, and totals. // Client/site/contact details are blanked out so the tradie fills them // in for the new job. A banner shows which quote was duplicated. // No backend endpoint required — uses the existing save-and-confirm path // when the tradie taps Confirm. async function duplicateQuote(srcQuote) { if (!srcQuote) return; console.log('[duplicate] srcQuote:', srcQuote); showToast('Duplicating...'); try { // Prefer the items already on srcQuote (fetched by GET /api/quote/:id via // service role). If they aren't present (e.g. duplicating from the list, // whose rows only carry summary columns), load the full quote via the // backend and use it as the source for everything below — otherwise // margin_amt/margin_pct/scope_of_works/display_mode come back empty. let items = Array.isArray(srcQuote.items) ? srcQuote.items : null; if (!items) { const res = await fetch(API_BASE + '/api/quote/' + srcQuote.id); if (!res.ok) throw new Error('Could not load quote to duplicate'); srcQuote = await res.json(); items = Array.isArray(srcQuote.items) ? srcQuote.items : []; } console.log('[duplicate] items source count:', items.length); // Shape items to match what renderPreview expects. // Stripping the id/quote_id so they get fresh ones on save. const cleanedItems = items.map(it => ({ description: it.description, quantity: it.quantity, unit: it.unit, unit_price: it.unit_price, // cost_price MUST carry across: the margin box derives from it, and the // backend save falls back to cost_price = unit_price when it's missing — // which permanently flattens the margin to 0 on the saved duplicate and // makes isManualQuote() misclassify it as a manual quote. cost_price: it.cost_price, amount: it.amount, category: it.category, sort_order: it.sort_order, })); console.log('[duplicate] cleanedItems:', cleanedItems); const subtotal = cleanedItems.reduce((s, i) => s + (parseFloat(i.amount) || 0), 0); // Build the duplicate pendingQuote — keep the work, strip the customer const dup = { job_title: srcQuote.job_title || '', scope_of_works: srcQuote.scope_of_works || '', client_name: '', // blank — new client site_address: '', // blank — new address contact_phone: '', contact_email: '', items: cleanedItems, subtotal: subtotal, total: subtotal, display_mode: srcQuote.display_mode || 'full', // Carry margin from source so the margin block shows the same // dollar value the original had. Without these, renderPreview's // margin block falls back to "—" because margin_amt is missing. margin_amt: srcQuote.margin_amt, margin_pct: srcQuote.margin_pct, }; // Close the detail modal const modal = document.getElementById('quote-detail-modal'); if (modal) modal.classList.remove('show'); // Open New Quote page in preview mode (skipping the input form) pendingQuote = dup; editingQuoteId = null; // it's a NEW quote, not an edit // Restore the source quote's view mode so the duplicate lands on the // same toggle the tradie was looking at — don't jump them back to Full. pendingDisplayMode = dup.display_mode || 'full'; syncDisplayModeUI(); renderPreview(dup); if (pendingDisplayMode !== 'full') renderAltView(); document.getElementById('nq-input-area').style.display = 'none'; document.getElementById('nq-preview').style.display = 'block'; // Show the duplicate banner const banner = document.getElementById('dup-banner'); const bannerText = document.getElementById('dup-banner-text'); if (banner && bannerText) { const srcLabel = srcQuote.client_name ? `#${srcQuote.quote_number} — ${esc(srcQuote.client_name)}` : `#${srcQuote.quote_number}`; bannerText.innerHTML = `Duplicated from ${srcLabel}`; banner.style.display = 'flex'; } // Show customer-detail inputs (blank — tradie fills in new client) const dupCustomer = document.getElementById('dup-customer'); const dupClient = document.getElementById('dup-client'); const dupAddress = document.getElementById('dup-address'); if (dupCustomer) dupCustomer.style.display = 'block'; if (dupClient) dupClient.value = ''; if (dupAddress) dupAddress.value = ''; // Navigate to New Quote tab showPage('newquote'); window.scrollTo({ top: 0, behavior: 'smooth' }); // Focus the client name field so the tradie can start typing immediately setTimeout(() => { if (dupClient) dupClient.focus(); }, 100); showToast('Edit the customer details and confirm when ready.'); } catch (err) { console.error('Duplicate failed:', err); showToast('Could not duplicate quote.'); } } // ── BUNDLES ────────────────────────────────────────────── // Reusable saved line-item sets ("Standard 20m² deck"): saved once from an // existing quote (post-confirm screen or detail view), applied from the // New Quote screen. Applying reuses the duplicate flow — build a // pendingQuote, blank the customer, land on the editable preview. Bundles // bypass the AI entirely. let bundlesCache = []; async function loadBundles() { // Fetch the tradie's bundles and toggle the "Start from a saved bundle" // entry. Hidden when they have none, so the New Quote screen is // unchanged for tradies who haven't used the feature. try { const res = await fetch(API_BASE + '/api/bundles'); if (!res.ok) throw new Error('bundles_load_failed'); const data = await res.json(); bundlesCache = Array.isArray(data.bundles) ? data.bundles : []; } catch (err) { // Keep whatever we had — a transient failure shouldn't hide the entry. console.warn('Bundles load failed:', err.message); } const bar = document.getElementById('nq-bundle-bar'); if (bar) bar.style.display = bundlesCache.length > 0 ? 'flex' : 'none'; } async function saveAsBundle(quoteId, suggestedName) { if (!quoteId) return; const name = (window.prompt('Name this bundle:', suggestedName || '') || '').trim(); if (!name) return; try { const res = await fetch(API_BASE + '/api/bundles/from-quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: quoteId, name }), }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) throw new Error(data.error || 'bundle_save_failed'); showToast(`Bundle "${name}" saved.`); loadBundles(); } catch (err) { console.error('Save bundle failed:', err); showToast('Could not save bundle.'); } } function openBundlePicker() { renderBundleList(); document.getElementById('bundle-modal').classList.add('show'); } function closeBundlePicker() { document.getElementById('bundle-modal').classList.remove('show'); } function renderBundleList() { const body = document.getElementById('bundle-modal-body'); if (!body) return; if (!bundlesCache.length) { body.innerHTML = '

No bundles yet. Open a quote and tap "Save as bundle".

'; return; } body.innerHTML = bundlesCache.map(b => `
${esc(b.name)}
${b.item_count} item${b.item_count === 1 ? '' : 's'} · $${fmtNum(b.total)}
`).join(''); body.querySelectorAll('.bundle-row').forEach(row => { row.addEventListener('click', () => applyBundle(row.dataset.id)); }); body.querySelectorAll('.bundle-row-delete').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); // don't also apply the bundle deleteBundle(btn.dataset.id); }); }); } async function deleteBundle(bundleId) { const b = bundlesCache.find(x => x.id === bundleId); if (!window.confirm(`Delete bundle "${b ? b.name : ''}"? This can't be undone.`)) return; try { const res = await fetch(API_BASE + '/api/bundles/' + bundleId, { method: 'DELETE' }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) throw new Error(data.error || 'bundle_delete_failed'); bundlesCache = bundlesCache.filter(x => x.id !== bundleId); showToast('Bundle deleted.'); if (bundlesCache.length === 0) { // Nothing left — close the picker and hide the entry. closeBundlePicker(); const bar = document.getElementById('nq-bundle-bar'); if (bar) bar.style.display = 'none'; } else { renderBundleList(); } } catch (err) { console.error('Delete bundle failed:', err); showToast('Could not delete bundle.'); } } async function applyBundle(bundleId) { closeBundlePicker(); showToast('Loading bundle...'); try { const res = await fetch(API_BASE + '/api/bundles/' + bundleId); if (!res.ok) throw new Error('Could not load bundle'); const data = await res.json(); const bundle = data.bundle || {}; const items = Array.isArray(bundle.items) ? bundle.items : []; if (!items.length) throw new Error('Bundle has no items'); // Shape items exactly as renderPreview expects — same mapping as // duplicateQuote. cost_price MUST carry across: the margin box derives // from it, and the backend save falls back to cost_price = unit_price // when it's missing — which permanently flattens the margin to 0 on // the saved quote and makes isManualQuote() misclassify it. const cleanedItems = items.map(it => ({ description: it.description, quantity: it.quantity, unit: it.unit, unit_price: it.unit_price, cost_price: it.cost_price, amount: it.amount, category: it.category, sort_order: it.sort_order, })); const subtotal = cleanedItems.reduce((s, i) => s + (parseFloat(i.amount) || 0), 0); // Build the pendingQuote — the bundle's job, a blank customer. No // margin fields needed: renderPreview derives margin from the items' // cost_price directly. pendingQuote = { job_title: bundle.job_title || bundle.name || '', scope_of_works: bundle.scope_of_works || '', client_name: '', // blank — new client site_address: '', // blank — new address contact_phone: '', contact_email: '', items: cleanedItems, subtotal: subtotal, total: subtotal, display_mode: 'full', }; editingQuoteId = null; // it's a NEW quote, not an edit pendingDisplayMode = 'full'; syncDisplayModeUI(); renderPreview(pendingQuote); document.getElementById('nq-input-area').style.display = 'none'; document.getElementById('nq-preview').style.display = 'block'; // Reuse the duplicate banner + blank customer inputs. const banner = document.getElementById('dup-banner'); const bannerText = document.getElementById('dup-banner-text'); if (banner && bannerText) { bannerText.innerHTML = `Started from bundle ${esc(bundle.name)}`; banner.style.display = 'flex'; } const dupCustomer = document.getElementById('dup-customer'); const dupClient = document.getElementById('dup-client'); const dupAddress = document.getElementById('dup-address'); if (dupCustomer) dupCustomer.style.display = 'block'; if (dupClient) dupClient.value = ''; if (dupAddress) dupAddress.value = ''; showPage('newquote'); window.scrollTo({ top: 0, behavior: 'smooth' }); setTimeout(() => { if (dupClient) dupClient.focus(); }, 100); showToast('Edit the customer details and confirm when ready.'); } catch (err) { console.error('Apply bundle failed:', err); showToast('Could not load bundle.'); } } // ── SETTINGS ───────────────────────────────────────────── async function saveSettings() { if (!profile) return; const btn = document.getElementById('save-btn'); const msg = document.getElementById('save-msg'); msg.textContent = ''; msg.className = 'save-msg'; btn.disabled = true; btn.textContent = 'Saving...'; const updates = { business_name: document.getElementById('s-biz').value.trim(), owner_name: document.getElementById('s-owner').value.trim(), phone: document.getElementById('s-phone').value.trim(), email: document.getElementById('s-email').value.trim().toLowerCase(), abn: document.getElementById('s-abn').value.trim(), trade: document.getElementById('s-trade').value, default_margin: parseInt(document.getElementById('s-margin').value) || 20, bank_name: document.getElementById('s-bname').value.trim(), bank_bsb: document.getElementById('s-bsb').value.trim(), bank_account: document.getElementById('s-bacc').value.trim(), whatsapp_number: document.getElementById('s-whatsapp').value.trim(), reminder_emails_enabled: document.getElementById('s-reminders').checked, }; // Brand colours — only included in the save payload if the user has a // logo (block is hidden otherwise, fields contain stale values). // Hex input wins over the colour-swatch picker if it's a valid hex; // swatch acts as the fallback (always valid because the browser // enforces it). Lowercased to match server convention. if (profile.logo_url) { const hexPrimary = document.getElementById('s-brand-primary-hex').value.trim(); const swatchPrimary = document.getElementById('s-brand-primary').value; const hexAccent = document.getElementById('s-brand-accent-hex').value.trim(); const swatchAccent = document.getElementById('s-brand-accent').value; updates.brand_primary = (isValidHex(hexPrimary) ? hexPrimary : swatchPrimary).toLowerCase(); updates.brand_accent = (isValidHex(hexAccent) ? hexAccent : swatchAccent).toLowerCase(); } let resp, body; try { resp = await fetch(API_BASE + '/api/profile/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: profile.id, updates }), }); body = await resp.json().catch(() => ({})); } catch (err) { btn.disabled = false; btn.textContent = 'Save changes'; msg.textContent = 'Network error. Please try again.'; msg.className = 'save-msg err'; return; } btn.disabled = false; btn.textContent = 'Save changes'; if (!resp.ok || !body || !body.success || !body.profile) { msg.textContent = body && body.error ? `Could not save: ${body.error}` : 'Could not save. Please try again.'; msg.className = 'save-msg err'; return; } // Verify the round-trip — fields we sent should match what came back. // This catches silent failures (e.g. RLS regressions, server bugs). const fresh = body.profile; const mismatches = []; for (const k of Object.keys(updates)) { // whatsapp_number is rewritten server-side (04… → 614…), so a // sent-vs-got difference there is expected, not a failure. if (k === 'whatsapp_number') continue; // Compare loosely — server may normalise (trim, lowercase, etc.) const sent = updates[k]; const got = fresh[k]; if (typeof sent === 'string' && typeof got === 'string') { if (sent.trim() !== got.trim()) mismatches.push(k); } else if (sent !== got) { mismatches.push(k); } } if (mismatches.length > 0) { console.warn('[saveSettings] field mismatch after save:', mismatches, { sent: updates, got: fresh }); // Don't block the user — the server response is still authoritative, // but surface that something didn't round-trip cleanly so we can debug. } // Replace local profile from server response (most accurate) Object.assign(profile, fresh); document.getElementById('hdr-name').textContent = fresh.business_name || fresh.owner_name || ''; // Reflect the server-normalised WhatsApp number (04… → 614…) so what the // tradie sees matches what inbound matching will use. document.getElementById('s-whatsapp').value = fresh.whatsapp_number || ''; msg.textContent = 'Changes saved.'; msg.className = 'save-msg ok'; renderSetupChecklist(); setTimeout(() => { msg.textContent = ''; }, 3000); } // ── NAVIGATION ─────────────────────────────────────────── function showPage(name) { document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.bnav-item').forEach(b => b.classList.remove('active')); document.getElementById('pg-' + name).classList.add('active'); // Map page → nav button. 'quotes' (the all-quotes sub-page) keeps the // 'home' nav button active because they're both part of the Quotes tab. const navName = (name === 'quotes') ? 'home' : name; document.querySelector(`.bnav-item[data-page="${navName}"]`)?.classList.add('active'); window.scrollTo(0, 0); // Refresh email connection status when entering Settings (handles // returning to Settings after an OAuth round-trip). if (name === 'settings') { emailConnection.refreshStatus(); ensureRatesSettingsEntry(); } // Refresh the "Start from a saved bundle" entry when entering New Quote. // Cheap GET, fire-and-forget — keeps the bar's visibility current after // saves/deletes (including on another device). Hidden for tradies with // no bundles, so the screen is unchanged for them. if (name === 'newquote') { loadBundles(); } // Render Stats page on entry if (name === 'stats' && typeof renderStatsPage === 'function') { renderStatsPage(); } } // ── EMAIL CONNECTION ───────────────────────────────────── // Tracks whether the tradie has connected an email account (Gmail today, // Outlook later). Used to gate "Send by email" actions and render the // Connect/Connected UI in Settings. const emailConnection = (function() { let state = { connected: false, provider: null, email_address: null, connected_at: null }; // Tracks whether we have a real status from the server (vs. the initial // hardcoded default). Subscribers can use this to gate UI that should // only appear once we know the true state (e.g. the setup card). let statusKnown = false; const listeners = []; function notify() { listeners.forEach(fn => { try { fn(state, { statusKnown }); } catch (e) {} }); } function onChange(fn) { listeners.push(fn); // Immediately fire with current state so subscribers can render. // We pass statusKnown=false on this initial call if we haven't fetched yet. try { fn(state, { statusKnown }); } catch (e) {} } async function refreshStatus() { const sess = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); if (!sess?.profile_id) return; const dis = document.getElementById('email-conn-disconnected'); const con = document.getElementById('email-conn-connected'); const load = document.getElementById('email-conn-loading'); // Show loading state only on first call (when nothing else is visible) if (dis && con && load && dis.style.display === 'none' && con.style.display === 'none') { load.style.display = 'block'; } try { const res = await fetch(API_BASE + `/api/auth/email/status?profile_id=${encodeURIComponent(sess.profile_id)}`); if (!res.ok) throw new Error('Status fetch failed'); const data = await res.json(); state = { connected: !!data.connected, provider: data.provider || null, email_address: data.email_address || null, connected_at: data.connected_at || null, }; } catch (err) { // On error, fall through to disconnected state — safer to under-report // than pretend a broken connection is working. state = { connected: false, provider: null, email_address: null, connected_at: null }; } // We now have a real answer from the server (whether connected or not). statusKnown = true; renderUI(); notify(); } function providerLabel(p) { if (p === 'google') return 'Connected via Google'; if (p === 'microsoft') return 'Connected via Microsoft'; return 'Connected'; } function renderUI() { const dis = document.getElementById('email-conn-disconnected'); const con = document.getElementById('email-conn-connected'); const load = document.getElementById('email-conn-loading'); if (!dis || !con || !load) return; load.style.display = 'none'; if (state.connected) { con.style.display = 'flex'; dis.style.display = 'none'; const addrEl = document.getElementById('email-conn-address'); const provEl = document.getElementById('email-conn-provider'); if (addrEl) addrEl.textContent = state.email_address || ''; if (provEl) provEl.textContent = providerLabel(state.provider); } else { dis.style.display = 'block'; con.style.display = 'none'; } } function startGoogleConnect() { const sess = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); if (!sess?.profile_id) { showToast('Please log in again.'); return; } // Full-page redirect — Google's consent screen needs a top-level navigation window.location.href = API_BASE + `/api/auth/google/start?profile_id=${encodeURIComponent(sess.profile_id)}`; } async function disconnect() { const ok = confirm('Disconnect this email account? Your quotes will no longer be sent by email until you connect another account. You can still download or copy the link.'); if (!ok) return; const sess = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); if (!sess?.profile_id) { showToast('Please log in again.'); return; } const btn = document.getElementById('email-disconnect-btn'); if (btn) { btn.disabled = true; btn.textContent = 'Disconnecting…'; } try { const res = await fetch(API_BASE + '/api/auth/email/disconnect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: sess.profile_id }), }); if (!res.ok) throw new Error(); showToast('Email disconnected.'); await refreshStatus(); } catch (err) { showToast('Could not disconnect. Try again.'); } finally { if (btn) { btn.disabled = false; btn.textContent = 'Disconnect'; } } } function isConnected() { return state.connected; } function get() { return Object.assign({}, state); } return { refreshStatus, startGoogleConnect, disconnect, isConnected, get, onChange }; })(); // Handle the OAuth redirect: on dashboard load, if we see ?email_connected // or ?email_error, show feedback and clean the URL. function handleOAuthRedirectParams() { const params = new URLSearchParams(window.location.search); const ok = params.get('email_connected'); const err = params.get('email_error'); if (ok) { const providerName = ok === 'google' ? 'Gmail' : (ok === 'microsoft' ? 'Outlook' : 'Email'); showToast(`${providerName} connected. Quotes will now send from your account.`); } else if (err) { const map = { access_denied: 'You declined access — connection not completed.', connection_failed: 'Could not complete the connection. Please try again.', }; showToast(map[err] || 'Could not connect email. Please try again.'); } if (ok || err) { // Strip params so we don't re-trigger on refresh const cleanUrl = window.location.pathname + window.location.hash; window.history.replaceState({}, '', cleanUrl); // Land on Settings so they can see the new state showPage('settings'); } } // Deep-link from a notification email: ?quote= opens that quote's detail. // The param is stripped afterwards so a refresh doesn't reopen it. function handleQuoteDeepLink() { const id = new URLSearchParams(window.location.search).get('quote'); if (!id) return; const cleanUrl = window.location.pathname + window.location.hash; window.history.replaceState({}, '', cleanUrl); openQuoteDetail(id); } // Deep-link from WhatsApp: ?brief= loads the job brief the tradie // sent on WhatsApp and pre-fills the new-quote flow — text/voice briefs go // into the job description exactly as if typed (so the normal stated-price // routing runs unchanged); photo briefs open photo mode with the stored // image and caption. The token is single-use and expires server-side; a // dead link toasts and the dashboard loads normally. Param is stripped // immediately so a refresh doesn't re-fire (and re-burn) anything. async function handleBriefDeepLink() { const token = new URLSearchParams(window.location.search).get('brief'); if (!token) return; const cleanUrl = window.location.pathname + window.location.hash; window.history.replaceState({}, '', cleanUrl); let resp, body; try { resp = await fetch(API_BASE + '/api/whatsapp/brief/' + encodeURIComponent(token)); body = await resp.json().catch(() => ({})); } catch (err) { showToast('That link has expired — send the job again on WhatsApp'); return; } if (!resp.ok || !body || !body.success) { showToast('That link has expired — send the job again on WhatsApp'); return; } if (body.source === 'photo' && body.photo_url) { // Photo brief: pull the stored image and feed it through the same // pendingPhoto shape the file picker produces, caption included. try { const imgResp = await fetch(body.photo_url); if (!imgResp.ok) throw new Error('photo fetch ' + imgResp.status); const blob = await imgResp.blob(); const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('photo read failed')); reader.readAsDataURL(blob); }); const ext = (body.photo_url.split('?')[0].split('.').pop() || 'jpg').toLowerCase(); pendingPhoto = { base64: dataUrl.split(',')[1], name: 'whatsapp-photo.' + ext, dataUrl, }; document.getElementById('nq-photo-img').src = dataUrl; document.getElementById('nq-photo-empty').style.display = 'none'; document.getElementById('nq-photo-preview').style.display = 'block'; if (body.brief_text) document.getElementById('nq-photo-caption').value = body.brief_text; showPage('newquote'); switchInputMode('photo'); } catch (err) { console.error('Brief photo load failed:', err); showToast('Could not load that photo — send the job again on WhatsApp'); } return; } // Text / voice brief → the job-description input, as if typed. if (body.brief_text) { document.getElementById('nq-desc').value = body.brief_text; showPage('newquote'); switchInputMode('text'); } } // ── QUOTE DETAIL MODAL ─────────────────────────────────── let currentQuoteDetail = null; async function openQuoteDetail(quoteId) { const modal = document.getElementById('quote-detail-modal'); const body = document.getElementById('qd-body'); document.getElementById('qd-title').textContent = 'Loading...'; document.getElementById('qd-sub').textContent = ''; body.innerHTML = '
Loading quote...
'; modal.classList.add('show'); try { const res = await fetch(API_BASE + '/api/quote/' + quoteId); if (!res.ok) throw new Error('Could not load quote'); const quote = await res.json(); currentQuoteDetail = quote; renderQuoteDetail(quote); } catch (err) { body.innerHTML = `

Could not load

${err.message}

`; } } function renderQuoteDetail(quote) { document.getElementById('qd-title').textContent = quote.job_title || 'Untitled job'; document.getElementById('qd-sub').textContent = `Quote #${quote.quote_number} · ${fmtDate(new Date(quote.created_at))}`; const total = quote.total ? '$' + fmtNum(displayTotal(quote)) : '—'; const status = quote.status || 'draft'; const pdfUrl = quote.pdf_url || ''; // Strip cache-buster query string (Supabase Storage doesn't like ?t= on PDFs) const cleanPdfUrl = pdfUrl.split('?')[0]; const body = document.getElementById('qd-body'); body.innerHTML = ` ${quote.public_token ? `
Live preview — exactly what your customer sees at the quote link.
` : '

Preview not available for this quote.

'} ${cleanPdfUrl ? `

Open PDF in new tab

` : ''}
Total
${total}
Client
${esc(quote.client_name || '—')}
Site
${esc(quote.site_address || '—')}
Sent
${quote.sent_at ? fmtDate(new Date(quote.sent_at)) : 'Not yet'}
${clientActivityHtml(quote)}
Status
Customer view

Change what this quote looks like to the customer. Useful if they ask for more (or less) detail. The PDF is prepared fresh when it's downloaded or shared.

${(status === 'won' || quote.client_response === 'accepted') ? `
Supplier list

Generate a clean materials order you can paste straight into a message or email to your supplier. Pricing and customer details are left out — just the items.

` : ''}
View live quote
`; // "More options" expander. Every action inside stays in the DOM (collapsed, // not removed), so all existing listeners below wire up unchanged. const qdMoreToggle = document.getElementById('qd-more-toggle'); const qdMore = document.getElementById('qd-more'); if (qdMoreToggle && qdMore) { qdMoreToggle.addEventListener('click', () => { const open = qdMore.style.display !== 'none'; qdMore.style.display = open ? 'none' : 'flex'; qdMoreToggle.setAttribute('aria-expanded', String(!open)); qdMoreToggle.classList.toggle('open', !open); }); } // Live preview iframe: restore full opacity whenever a (re)load lands. const qdPreviewFrame = document.getElementById('qd-preview-frame'); if (qdPreviewFrame) qdPreviewFrame.addEventListener('load', () => { qdPreviewFrame.style.opacity = '1'; }); // Status buttons body.querySelectorAll('.qd-status-btn').forEach(btn => { btn.addEventListener('click', () => updateQuoteStatus(quote.id, btn.dataset.status)); }); const qdSendBtn = document.getElementById('qd-send-btn'); if (qdSendBtn) qdSendBtn.addEventListener('click', () => sendQuoteLink(quote.public_token, quote.quote_number)); document.getElementById('qd-share-btn').addEventListener('click', async () => { if (!quote.pdf_url) { shareQuote('', quote.quote_number); return; } // keeps the "No PDF" toast const btn = document.getElementById('qd-share-btn'); try { const url = await preparePdfWithButton(btn, quote.id); shareQuote(url, quote.quote_number); } catch (err) { showToast('Could not prepare PDF. Try again.'); } }); const qdDownloadBtn = document.getElementById('qd-download-btn'); if (qdDownloadBtn) qdDownloadBtn.addEventListener('click', async () => { try { const url = await preparePdfWithButton(qdDownloadBtn, quote.id); const a = document.createElement('a'); a.href = url; a.download = quoteFilename(quote.quote_number); document.body.appendChild(a); a.click(); a.remove(); } catch (err) { showToast('Could not prepare PDF. Try again.'); } }); const qdOpenPdfLink = document.getElementById('qd-open-pdf-link'); if (qdOpenPdfLink) qdOpenPdfLink.addEventListener('click', async (e) => { e.preventDefault(); // Open the tab synchronously so popup blockers allow it, then point it // at the ensured-fresh URL once ready. const tab = window.open('', '_blank'); const orig = qdOpenPdfLink.textContent; qdOpenPdfLink.textContent = 'Preparing PDF…'; try { const url = await ensureFreshPdfUrl(quote.id); if (tab) tab.location = url; else window.open(url, '_blank'); } catch (err) { if (tab) tab.close(); showToast('Could not prepare PDF. Try again.'); } finally { qdOpenPdfLink.textContent = orig; } }); const qdCopyLinkBtn = document.getElementById('qd-copy-link-btn'); if (qdCopyLinkBtn) qdCopyLinkBtn.addEventListener('click', () => copyQuoteLink(quote.public_token, qdCopyLinkBtn)); document.getElementById('qd-duplicate-btn').addEventListener('click', () => duplicateQuote(quote)); document.getElementById('qd-bundle-btn').addEventListener('click', () => saveAsBundle(quote.id, quote.job_title || '')); document.getElementById('qd-resend-email').addEventListener('click', () => { const w = document.getElementById('qd-email-wrap'); w.style.display = w.style.display === 'none' ? 'block' : 'none'; if (w.style.display === 'block') { document.getElementById('qd-client-email').focus(); } }); document.getElementById('qd-send-email-btn').addEventListener('click', () => resendEmail(quote.id)); document.getElementById('qd-delete').addEventListener('click', () => deleteQuote(quote.id)); // Supplier list (won quotes only): generate a copy-pasteable text block // of materials the tradie can send to their supplier. Materials only — // labour, compliance, disposal etc. are filtered out. No customer pricing. const supplierBtn = document.getElementById('qd-supplier-btn'); if (supplierBtn) { supplierBtn.addEventListener('click', async () => { const wrap = document.getElementById('qd-supplier-wrap'); const ta = document.getElementById('qd-supplier-text'); // Items are already on the quote (loaded by openQuoteDetail via // GET /api/quote/:id, a service-role read). Reading quote_items // directly with the anon client returns an empty set under RLS, // which produced an empty supplier list. const items = quote.items || []; ta.value = buildSupplierListText(quote, items, profile); wrap.style.display = 'block'; supplierBtn.style.display = 'none'; // Focus the textarea so iOS users can copy with the system menu if // the explicit copy button doesn't work in their browser context ta.focus(); ta.select(); }); document.getElementById('qd-supplier-copy').addEventListener('click', async () => { const ta = document.getElementById('qd-supplier-text'); try { await navigator.clipboard.writeText(ta.value); showToast('Copied to clipboard'); } catch (err) { // Fallback for browsers that block clipboard API ta.select(); document.execCommand('copy'); showToast('Copied'); } }); document.getElementById('qd-supplier-close').addEventListener('click', () => { document.getElementById('qd-supplier-wrap').style.display = 'none'; supplierBtn.style.display = 'block'; }); } // Customer-view toggle in the quote-detail modal. The change saves // instantly (fast /api/quote/confirm: persists display_mode + marks the // stored PDF stale) — the PDF itself is prepared fresh the next time it // is downloaded or shared. The live preview iframe (the public quote // page in preview mode) reloads after the save lands, so the tradie // sees exactly what the customer sees for the newly selected mode. // // Concurrency: rapid clicks (Full → Summary → Scope) shouldn't queue 3 // backend calls. We track the in-flight save; if a new mode is selected // while one is running, we remember the latest mode and run it once the // in-flight call completes. Intermediate selections are dropped. const initialMode = quote.display_mode || 'full'; let currentMode = initialMode; let inflightMode = null; let queuedMode = null; const reloadPreview = () => { const frame = document.getElementById('qd-preview-frame'); if (!frame || !quote.public_token) return; // Dim while the fresh render loads; the frame's load listener restores // opacity. Cache-buster forces a refetch of the updated payload. frame.style.opacity = '.5'; frame.src = 'https://betterquote.au/quote?t=' + encodeURIComponent(quote.public_token) + '&preview=1&r=' + Date.now(); }; const runModeChange = async (mode) => { if (mode === currentMode) return; if (inflightMode) { // Another save is in progress — queue this one (overwriting any // earlier queued mode, since the user only cares about the latest) queuedMode = mode; return; } inflightMode = mode; try { const res = await fetch(API_BASE + '/api/quote/confirm', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: quote.id, profile_id: profile.id, display_mode: mode, }), }); if (!res.ok) throw new Error('Could not save customer view'); currentMode = mode; // Keep the in-memory quote in sync so Duplicate / reopen use the new // mode without a refetch, then show the new mode in the live preview. quote.display_mode = mode; quote.pdf_stale = true; reloadPreview(); } catch (err) { console.error('Customer view change failed:', err); showToast('Could not update customer view. Try again.'); // Put the pills back on the mode that actually saved. body.querySelectorAll('.qd-dm-toggle .dm-btn').forEach(b => { b.classList.toggle('active', b.dataset.mode === currentMode); }); } finally { inflightMode = null; // If a new mode was queued during the in-flight call, run it now. if (queuedMode && queuedMode !== currentMode) { const next = queuedMode; queuedMode = null; runModeChange(next); } } }; body.querySelectorAll('.qd-dm-toggle .dm-btn').forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; console.log('[view-toggle] clicked mode:', mode); // Visually mark the new selection immediately — the save is fast, and // on failure runModeChange reverts the pills to the saved mode. body.querySelectorAll('.qd-dm-toggle .dm-btn').forEach(b => { b.classList.toggle('active', b === btn); }); runModeChange(mode); }); }); } async function updateQuoteStatus(quoteId, status) { try { const res = await fetch(API_BASE + '/api/quote/mark-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: quoteId, status }), }); if (!res.ok) throw new Error(); // Update active state immediately document.querySelectorAll('#qd-status-row .qd-status-btn').forEach(b => { b.classList.toggle('active', b.dataset.status === status); }); currentQuoteDetail.status = status; showToast(`Marked as ${status}.`); await loadQuotes(); } catch (err) { showToast('Could not update status.'); } } async function resendEmail(quoteId) { const to_email = document.getElementById('qd-client-email').value.trim(); if (!to_email || !to_email.includes('@')) { showToast('Enter a valid email.'); return; } showToast('Sending email...'); try { const res = await fetch(API_BASE + '/api/quote/email', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: quoteId, to_email }), }); if (res.status === 409) { showToast('Reconnect your email in Settings to send.'); emailConnection.refreshStatus(); return; } if (!res.ok) throw new Error(); showToast('Email sent. Marked as sent.'); document.getElementById('qd-email-wrap').style.display = 'none'; document.getElementById('qd-client-email').value = ''; // Refresh detail and list await openQuoteDetail(quoteId); await loadQuotes(); } catch { showToast('Could not send email.'); } } async function deleteQuote(quoteId) { if (!confirm('Delete this quote? This cannot be undone.')) return; try { const res = await fetch(API_BASE + '/api/quote/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quote_id: quoteId, profile_id: profile.id }), }); if (!res.ok) throw new Error(); showToast('Quote deleted.'); closeQuoteDetail(); await loadQuotes(); } catch { showToast('Could not delete.'); } } function closeQuoteDetail() { document.getElementById('quote-detail-modal').classList.remove('show'); currentQuoteDetail = null; } // ── PRICING UPLOAD MODAL ───────────────────────────────── let extractedItems = []; // Settings → "Your rates" entry. The rates editor below (openPricingModal) // was only reachable from the onboarding checklist, which disappears once // setup is complete — leaving no way back to it. This injects a permanent // card at the top of the Settings page that opens the same editor. function ensureRatesSettingsEntry() { const wrap = document.querySelector('#pg-settings .wrap') || document.getElementById('pg-settings'); if (!wrap) return; let group = document.getElementById('s-group-rates'); if (!group) { group = document.createElement('div'); group.className = 's-group'; group.id = 's-group-rates'; group.innerHTML = '

Your rates

' + '

The rates and prices BetterQuote uses to build your quotes.

' + ''; const firstGroup = wrap.querySelector('.s-group'); if (firstGroup) wrap.insertBefore(group, firstGroup); else wrap.appendChild(group); document.getElementById('open-rates-btn').addEventListener('click', openPricingModal); } // Reflect whether rates have been added yet. const sub = document.getElementById('rates-sub'); const btn = document.getElementById('open-rates-btn'); const hasRates = !!(profile && profile._has_pricing); if (sub) sub.textContent = hasRates ? 'Your rates are set. Edit or add to them any time.' : "You haven't added your rates yet — add them so your quotes price accurately."; if (btn) btn.textContent = hasRates ? 'Edit rates' : 'Add your rates'; } function openPricingModal() { showPricingStep('upload'); document.getElementById('pricing-modal').classList.add('show'); } function closePricingModal() { document.getElementById('pricing-modal').classList.remove('show'); extractedItems = []; } function showPricingStep(step) { document.getElementById('pricing-step-upload').style.display = step === 'upload' ? 'block' : 'none'; document.getElementById('pricing-step-extracting').style.display = step === 'extracting' ? 'block' : 'none'; document.getElementById('pricing-step-review').style.display = step === 'review' ? 'block' : 'none'; document.getElementById('pricing-modal-foot').style.display = step === 'review' ? 'flex' : 'none'; const titles = { upload: { t: 'Upload your pricing', s: 'Choose how you would like to add your rates.' }, extracting: { t: 'Working on it...', s: 'AI is reading your file.' }, review: { t: 'Review your rates', s: 'Edit anything that looks wrong, then save.' }, }; document.getElementById('pricing-modal-title').textContent = titles[step].t; document.getElementById('pricing-modal-sub').textContent = titles[step].s; } function triggerUpload(type) { if (type === 'xlsx') document.getElementById('file-xlsx').click(); else if (type === 'csv') document.getElementById('file-csv').click(); else if (type === 'photo') document.getElementById('file-photo').click(); } function fileToBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { const result = reader.result; const base64 = result.split(',')[1]; resolve(base64); }; reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } async function handleFileUpload(file, type) { if (!file || !profile) return; // Size check — 10MB limit if (file.size > 10 * 1024 * 1024) { showToast('File too large. Maximum is 10MB.'); return; } showPricingStep('extracting'); const subEl = document.getElementById('extract-sub'); if (type === 'photo') { subEl.textContent = 'Reading the image with AI. This usually takes 5 to 15 seconds.'; } else { subEl.textContent = 'Reading your file with AI. This usually takes 5 to 15 seconds.'; } try { const base64 = await fileToBase64(file); const res = await fetch(API_BASE + '/api/pricing/extract', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: profile.id, file_type: type, file_name: file.name, file_data: base64, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Extraction failed'); } const data = await res.json(); if (!data.items || data.items.length === 0) { showToast('No rates could be extracted. Please try a different file.'); showPricingStep('upload'); return; } extractedItems = data.items; renderReviewTable(); showPricingStep('review'); } catch (err) { console.error('Pricing extract error:', err); showToast(err.message || 'Could not process file. Please try again.'); showPricingStep('upload'); } } function renderReviewTable() { const tbl = document.getElementById('review-table'); document.getElementById('review-count').textContent = extractedItems.length; const lowCount = extractedItems.filter(i => i.confidence === 'low').length; document.getElementById('review-low-count').textContent = lowCount > 0 ? `${lowCount} need checking` : ''; let html = `
Type
Description
Rate
Unit
`; extractedItems.forEach((item, idx) => { const conf = item.confidence || 'high'; html += `
`; }); tbl.innerHTML = html; // Wire up inputs tbl.querySelectorAll('.rev-input, .rev-select').forEach(el => { el.addEventListener('change', e => { const idx = parseInt(e.target.dataset.idx); const field = e.target.dataset.field; let value = e.target.value; if (field === 'rate') value = parseFloat(value) || 0; extractedItems[idx][field] = value; }); }); tbl.querySelectorAll('.rev-del').forEach(btn => { btn.addEventListener('click', () => { const idx = parseInt(btn.dataset.idx); extractedItems.splice(idx, 1); renderReviewTable(); }); }); } function addBlankRow() { extractedItems.push({ type: 'material', name: 'New rate', rate: 0, unit: 'each', confidence: 'high', }); renderReviewTable(); // Focus the new row's name field setTimeout(() => { const inputs = document.querySelectorAll('.rev-input[data-field="name"]'); const last = inputs[inputs.length - 1]; if (last) { last.focus(); last.select(); } }, 50); } async function savePricing() { if (!profile || extractedItems.length === 0) return; const validItems = extractedItems.filter(i => i.name && i.rate > 0); if (validItems.length === 0) { showToast('No valid rates to save.'); return; } const btn = document.getElementById('pricing-save-btn'); btn.disabled = true; btn.innerHTML = 'Saving...'; try { const res = await fetch(API_BASE + '/api/pricing/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ profile_id: profile.id, items: validItems, replace_existing: true, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Save failed'); } const data = await res.json(); profile._has_pricing = true; showToast(`Saved ${data.saved} rates. They'll be used in every quote.`); closePricingModal(); renderSetupChecklist(); ensureRatesSettingsEntry(); } catch (err) { console.error('Save pricing failed:', err); showToast(err.message || 'Could not save pricing.'); btn.disabled = false; btn.textContent = 'Save my pricing'; } } // Wire up pricing modal document.querySelectorAll('.upload-option').forEach(btn => { btn.addEventListener('click', () => triggerUpload(btn.dataset.upload)); }); document.getElementById('file-xlsx').addEventListener('change', e => { handleFileUpload(e.target.files[0], 'xlsx'); e.target.value = ''; }); document.getElementById('file-csv').addEventListener('change', e => { handleFileUpload(e.target.files[0], 'csv'); e.target.value = ''; }); document.getElementById('file-photo').addEventListener('change', e => { handleFileUpload(e.target.files[0], 'photo'); e.target.value = ''; }); document.getElementById('pricing-modal-close').addEventListener('click', closePricingModal); document.getElementById('pricing-cancel-btn').addEventListener('click', closePricingModal); document.getElementById('pricing-save-btn').addEventListener('click', savePricing); document.getElementById('review-add-btn').addEventListener('click', addBlankRow); // Click outside modal to close document.getElementById('pricing-modal').addEventListener('click', e => { if (e.target.id === 'pricing-modal') closePricingModal(); }); // ── HELPERS ────────────────────────────────────────────── // Build the customer-facing PDF filename: "{biz}-Quote-{number}.pdf". // Sanitises the business name so it's safe in filesystems (strips // non-alphanumerics, collapses runs of dashes, caps length at 30 chars). // Mirrors the pattern used in the bot for Telegram delivery. function quoteFilename(quoteNumber) { const raw = (profile?.business_name || '').trim(); const safe = raw .replace(/[^a-zA-Z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .slice(0, 30); return safe ? `${safe}-Quote-${quoteNumber}.pdf` : `Quote-${quoteNumber}.pdf`; } // Build a copy-pasteable text block for sending to a supplier. Filters out // non-material items (labour, compliance certificates, disposal, equipment // hire) so the supplier only sees what they can actually fulfil. No // customer-facing pricing; just descriptions, quantities, and units. // Format derived from real supplier-message conventions (see Shaw feedback). function buildSupplierListText(quote, items, profile) { const businessName = (profile?.business_name || '').trim() || 'Quote'; const ownerName = (profile?.full_name || profile?.business_owner_name || '').trim(); const phone = (profile?.phone || '').trim(); const abn = (profile?.abn || '').trim(); const clientName = (quote.client_name || '').trim(); const siteAddress = (quote.site_address || '').trim(); const ref = clientName && siteAddress ? `${clientName}, ${siteAddress}` : (clientName || siteAddress || ''); // Materials only — labour, compliance, disposal etc. are not orderable const materialItems = (items || []).filter(it => it.category === 'materials'); const lines = []; lines.push(`${businessName} — Materials Order`); lines.push(`For Quote #${quote.quote_number}${ref ? ' — ' + ref : ''}`); lines.push(`Required by: __________`); lines.push(''); if (materialItems.length === 0) { lines.push('(No materials items found on this quote)'); } else { for (const it of materialItems) { const qty = Number(it.quantity || 0); const qtyStr = qty === Math.floor(qty) ? String(qty) : qty.toFixed(2); const unit = (it.unit || '').trim(); // Format: "— 256 lm of blackbutt 90×19 decking" // Use "of" for units that read as quantities of a thing (lm, m2, m3, kg); // use "x" for countable items (each, item, post, sheet) const isMeasured = ['lm', 'm', 'm2', 'm3', 'kg', 'l', 'litre', 'litres'].includes(unit.toLowerCase()); const qtyPhrase = isMeasured ? `${qtyStr} ${unit} of ${it.description}` : (unit ? `${qtyStr}x ${it.description}` : `${qtyStr} ${it.description}`); lines.push(`— ${qtyPhrase}`); } } lines.push(''); const sigParts = [ownerName, phone, abn ? `ABN ${abn}` : ''].filter(Boolean); if (sigParts.length > 0) lines.push(sigParts.join(' — ')); return lines.join('\n'); } function fmtDate(d) { return d.toLocaleDateString('en-AU', { day: 'numeric', month: 'short', year: 'numeric' }); } function fmtNum(n) { return Number(n || 0).toLocaleString('en-AU', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); } // Inc-GST display for tracking screens. Quote totals are stored ex-GST per // Convention A — but tradies mentally track in inc-GST because that's what // the customer sees on the PDF and pays into their bank. So all // dashboard tracking and history displays should call this rather than use // q.total directly. Storage logic must continue to use q.total (ex-GST). function displayTotal(q) { const ex = parseFloat(q?.total) || 0; return Math.round(ex * 1.1); } function fmtQty(n) { const v = Number(n || 0); return v === Math.floor(v) ? String(v) : v.toFixed(2); } function esc(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>'); } function emptyEl(msg) { return `

${msg}

`; } function showToast(msg) { const t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.style.zIndex = '9999'; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 2800); } // ── WIRE UP ────────────────────────────────────────────── document.querySelectorAll('.bnav-item').forEach(btn => { btn.addEventListener('click', () => showPage(btn.dataset.page)); }); document.querySelectorAll('.f-pill').forEach(btn => { btn.addEventListener('click', () => { quotesFilter = btn.dataset.filter; document.querySelectorAll('.f-pill').forEach(b => b.classList.remove('on')); btn.classList.add('on'); renderQuotesList(allQuotes); }); }); initStatsPeriodToggle(); wireBrandColourInputs(); // All-quotes page: search input (live filter) const searchInput = document.getElementById('quotes-search'); if (searchInput) { searchInput.addEventListener('input', (e) => { quotesSearch = e.target.value; renderQuotesList(allQuotes); }); } // All-quotes page: back link returns to Quotes home const quotesBack = document.getElementById('quotes-back'); if (quotesBack) quotesBack.addEventListener('click', () => showPage('home')); // All-quotes page: bulk mark-all-drafts-as-sent button const bulkMarkBtn = document.getElementById('bulk-mark-sent-btn'); if (bulkMarkBtn) bulkMarkBtn.addEventListener('click', markAllDraftsAsSent); document.getElementById('logout-btn').addEventListener('click', logout); document.getElementById('action-new-quote').addEventListener('click', () => showPage('newquote')); // Bundles: New Quote entry + picker close document.getElementById('nq-bundle-bar')?.addEventListener('click', openBundlePicker); document.getElementById('bundle-modal-close')?.addEventListener('click', closeBundlePicker); document.getElementById('action-setup').addEventListener('click', () => { document.getElementById('setup-card').scrollIntoView({ behavior: 'smooth', block: 'center' }); }); // See all → all-quotes sub-page (reset filter to All, clear search) const seeAllBtn = document.getElementById('action-see-all'); if (seeAllBtn) seeAllBtn.addEventListener('click', () => { quotesFilter = 'all'; quotesSearch = ''; const si = document.getElementById('quotes-search'); if (si) si.value = ''; document.querySelectorAll('.f-pill').forEach(b => b.classList.remove('on')); const allPill = document.querySelector('.f-pill[data-filter="all"]'); if (allPill) allPill.classList.add('on'); renderQuotesList(allQuotes); showPage('quotes'); }); // Attention card → all-quotes filtered to sent (where stale highlights show) const attnCard = document.getElementById('attn-card'); if (attnCard) attnCard.addEventListener('click', () => { quotesFilter = 'sent'; quotesSearch = ''; const si = document.getElementById('quotes-search'); if (si) si.value = ''; document.querySelectorAll('.f-pill').forEach(b => b.classList.remove('on')); const sentPill = document.querySelector('.f-pill[data-filter="sent"]'); if (sentPill) sentPill.classList.add('on'); renderQuotesList(allQuotes); showPage('quotes'); }); document.getElementById('gen-btn').addEventListener('click', generateQuote); document.getElementById('confirm-btn').addEventListener('click', confirmQuote); document.getElementById('cancel-btn').addEventListener('click', cancelQuote); document.getElementById('save-btn').addEventListener('click', saveSettings); // Email connection (Settings) document.getElementById('email-connect-google').addEventListener('click', () => emailConnection.startGoogleConnect()); document.getElementById('email-disconnect-btn').addEventListener('click', () => emailConnection.disconnect()); // Microsoft handler will be wired here when Outlook OAuth ships: // document.getElementById('email-connect-microsoft').addEventListener('click', () => emailConnection.startMicrosoftConnect()); // Hide the "Email to client" buttons everywhere unless connected. // The `email-conn-known` class is added once we have a real status from the // server (so the UI doesn't briefly flash the setup card). The `email-conn-on` // class is added when connected. emailConnection.onChange((s, meta) => { if (meta && meta.statusKnown) { document.body.classList.add('email-conn-known'); } document.body.classList.toggle('email-conn-on', !!s.connected); // Re-render setup checklist so the email step ticks/un-ticks live if (typeof renderSetupChecklist === 'function') { try { renderSetupChecklist(); } catch (e) {} } }); // Chat-based editing document.getElementById('chat-edit-btn').addEventListener('click', applyChatEdit); document.getElementById('chat-edit-input').addEventListener('keydown', e => { if (e.key === 'Enter') applyChatEdit(); }); // Logo upload document.getElementById('logo-drop').addEventListener('click', () => document.getElementById('file-logo').click()); document.getElementById('logo-replace-btn').addEventListener('click', () => document.getElementById('file-logo').click()); document.getElementById('logo-remove-btn').addEventListener('click', removeLogo); document.getElementById('file-logo').addEventListener('change', e => { handleLogoSelected(e.target.files[0]); e.target.value = ''; }); // Quote detail modal close document.getElementById('qd-close-btn').addEventListener('click', closeQuoteDetail); document.getElementById('quote-detail-modal').addEventListener('click', e => { if (e.target.id === 'quote-detail-modal') closeQuoteDetail(); }); // New quote tabs and photo // Voice tab upgrade: HTML ships the Voice tab with disabled + "Soon" pill // as the safe default for environments that don't support MediaRecorder. // Now that voice is supported, upgrade the tab: stamp data-mode="voice", // strip disabled, remove the Soon pill. Safe no-op if the tab is already // properly wired up. let voiceTab = document.querySelector('.nq-tab[data-mode="voice"]'); if (!voiceTab) { // Fallback: find by disabled + .nq-tab-disabled class (HTML state) voiceTab = document.querySelector('.nq-tab[disabled], .nq-tab.nq-tab-disabled'); } if (voiceTab) { voiceTab.dataset.mode = 'voice'; voiceTab.disabled = false; voiceTab.removeAttribute('disabled'); voiceTab.classList.remove('nq-tab-disabled', 'disabled'); const soonPill = voiceTab.querySelector('.nq-soon, .soon, [class*="soon"]'); if (soonPill) soonPill.remove(); } document.querySelectorAll('.nq-tab').forEach(tab => { tab.addEventListener('click', () => switchInputMode(tab.dataset.mode)); }); // ── Budget field helper ─────────────────────────────────────────────── // Estimate-vs-budget is no longer a choice the user has to understand up // front — the budget field itself is the control. Blank → priced at market // rates; filled → priced and compared. We just make that behaviour obvious // with a one-line helper and relabel the field "(optional)". Routing is // unchanged: the backend already keys off whether #nq-budget has a value. (function initBudgetHelper() { const bi = document.getElementById('nq-budget'); const field = bi ? bi.closest('.field') : null; if (!field || field.querySelector('.bq-budget-help')) return; if (!document.getElementById('bq-budget-help-style')) { const st = document.createElement('style'); st.id = 'bq-budget-help-style'; st.textContent = '.bq-budget-help{font-size:12px;color:var(--grey-2);margin:6px 2px 0;line-height:1.4;}'; document.head.appendChild(st); } const label = field.querySelector('.f-label'); if (label) label.textContent = 'Budget'; const help = document.createElement('p'); help.className = 'bq-budget-help'; help.textContent = "Got a price in mind? We'll price the job and show how yours compares."; field.appendChild(help); })(); // ── Quote builder: "Build it for me" vs "I'll build it" ─────────────── // A two-way control at the top of the new-quote screen. "Build it for me" // is the describe-it flow (we build the quote). "I'll build it" opens a // manual line-item editor with its own client/address fields. Manual // submits go to /api/quote-manual, which returns the same quote object the // describe path does — so renderPreview and the confirm/PDF/share flow work // unchanged. collectManualItems lives at this scope (not nested) so // generateQuote can call it. function collectManualItems() { const items = []; document.querySelectorAll('#nqm-items .nqm-row').forEach(function (r) { const description = (r.querySelector('[data-f="description"]')?.value || '').trim(); const quantity = parseFloat(r.querySelector('[data-f="quantity"]')?.value) || 0; const unit_price = parseFloat(r.querySelector('[data-f="unit_price"]')?.value) || 0; if (!description && !(quantity && unit_price)) return; // skip blank rows items.push({ description, quantity, unit_price }); }); return items; } function initManualBuilder() { const inputArea = document.getElementById('nq-input-area'); if (!inputArea || document.querySelector('.nqm-mode')) return; if (!document.getElementById('nqm-style')) { const st = document.createElement('style'); st.id = 'nqm-style'; st.textContent = '.nqm-mode{display:flex;gap:4px;background:var(--off);border-radius:10px;padding:4px;margin-bottom:16px;}' + '.nqm-mode-btn{flex:1;background:transparent;border:none;padding:11px 10px;border-radius:8px;font-family:inherit;font-size:13px;font-weight:600;color:var(--grey-2);cursor:pointer;transition:all .15s;line-height:1.2;}' + '.nqm-mode-btn:hover{color:var(--navy);}' + '.nqm-mode-btn.active{background:#fff;color:var(--navy);box-shadow:0 1px 3px rgba(13,27,46,.06);}' + '.nqm-items{display:flex;flex-direction:column;gap:10px;margin-bottom:10px;}' + '.nqm-row{border:1.5px solid var(--border);border-radius:12px;padding:11px 11px 12px;position:relative;}' + '.nqm-desc{margin-bottom:9px;padding-right:30px;}' + '.nqm-row2{display:flex;align-items:flex-end;gap:9px;}' + '.nqm-cell{display:flex;flex-direction:column;}' + '.nqm-qcell{width:72px;flex:none;}' + '.nqm-pcell{flex:1;min-width:0;}' + '.nqm-mini{font-size:10px;font-weight:600;color:var(--grey-2);text-transform:uppercase;letter-spacing:.04em;margin:0 0 4px 2px;}' + '.nqm-pwrap{position:relative;}' + '.nqm-pwrap .nqm-dollar{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:#9aa6b4;font-size:14px;pointer-events:none;}' + '.nqm-pwrap .nqm-p{padding-left:22px;}' + '.nqm-amt{margin-left:auto;flex:none;min-width:72px;text-align:right;padding-bottom:11px;font-weight:700;font-size:15px;color:var(--navy);font-variant-numeric:tabular-nums;}' + '.nqm-rm{position:absolute;top:9px;right:9px;width:26px;height:26px;border-radius:8px;border:none;background:transparent;color:#aab4c2;cursor:pointer;font-size:18px;line-height:1;}' + '.nqm-rm:hover{background:#fdecec;color:#d6453f;}' + '.nqm-add{width:100%;border:1.5px dashed #c5d4e6;background:var(--off);color:var(--blue-mid,#3f7fc4);border-radius:11px;padding:11px;font-family:inherit;font-weight:600;font-size:13.5px;cursor:pointer;margin-bottom:16px;}' + '.nqm-add:hover{background:#e7f0fb;}' + '.nqm-grammar{display:flex;align-items:center;gap:11px;background:var(--off);border:1px solid var(--border);border-radius:12px;padding:12px 13px;margin-bottom:16px;cursor:pointer;}' + '.nqm-sw{width:40px;height:23px;border-radius:999px;background:#c7d2e0;flex:none;position:relative;transition:background .15s;}' + '.nqm-sw::after{content:"";position:absolute;top:2px;left:2px;width:19px;height:19px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:left .15s;}' + '.nqm-grammar.on .nqm-sw{background:var(--blue-mid,#3f7fc4);}.nqm-grammar.on .nqm-sw::after{left:19px;}' + '.nqm-grammar .nqm-pt{font-weight:600;font-size:13.5px;color:var(--navy);}.nqm-grammar .nqm-ps{font-size:12px;color:var(--grey-2);margin-top:1px;}' + '.nqm-tot{border-top:1px solid var(--border);padding-top:14px;margin-bottom:8px;}' + '.nqm-tline{display:flex;justify-content:space-between;font-size:13.5px;color:var(--grey-2);padding:3px 2px;font-variant-numeric:tabular-nums;}' + '.nqm-tbar{background:var(--navy);border-radius:12px;padding:13px 16px;display:flex;justify-content:space-between;align-items:center;margin-top:9px;}' + '.nqm-tbar .tl{color:#aebfd4;font-weight:600;letter-spacing:.05em;font-size:12px;text-transform:uppercase;}' + '.nqm-tbar .tv{color:var(--blue);font-weight:700;font-size:21px;font-variant-numeric:tabular-nums;}'; document.head.appendChild(st); } inputArea.insertAdjacentHTML('afterbegin', '
' + '' + '' + '
'); const editor = document.createElement('div'); editor.id = 'nqm-editor'; editor.style.display = 'none'; editor.innerHTML = '
' + '
' + '
' + '
' + '
' + '
' + '' + '
' + '' + '
' + '' + 'Check grammar' + '
' + '
' + '
Subtotal$0
' + '
GST (10%)$0
' + '
Total (inc GST)$0
' + '
'; const details = inputArea.querySelector('.nq-details'); if (details) details.parentNode.insertBefore(editor, details); else inputArea.appendChild(editor); function money(n) { return '$' + fmtNum(Math.round((n || 0) * 100) / 100); } function recompute() { let sub = 0; document.querySelectorAll('#nqm-items .nqm-row').forEach(function (r) { const q = parseFloat(r.querySelector('[data-f="quantity"]')?.value) || 0; const p = parseFloat(r.querySelector('[data-f="unit_price"]')?.value) || 0; const amt = q * p; const cell = r.querySelector('.nqm-amt'); if (cell) cell.textContent = money(amt); sub += amt; }); document.getElementById('nqm-sub').textContent = money(sub); document.getElementById('nqm-gst').textContent = money(sub * 0.1); document.getElementById('nqm-total').textContent = money(sub * 1.1); } function addRow(focus) { const row = document.createElement('div'); row.className = 'nqm-row'; row.innerHTML = '' + '' + '
' + '
Qty' + '
' + '
Unit price' + '
$' + '
' + '$0' + '
'; document.getElementById('nqm-items').appendChild(row); if (focus) { const d = row.querySelector('[data-f="description"]'); if (d) d.focus(); } } const itemsEl = document.getElementById('nqm-items'); itemsEl.addEventListener('input', recompute); itemsEl.addEventListener('click', function (e) { if (!e.target.closest('.nqm-rm')) return; e.target.closest('.nqm-row').remove(); if (!itemsEl.querySelector('.nqm-row')) addRow(false); recompute(); }); document.getElementById('nqm-add').addEventListener('click', function () { addRow(true); }); document.getElementById('nqm-polish').addEventListener('click', function () { this.classList.toggle('on'); }); function setQuoteMode(mode) { document.querySelectorAll('.nqm-mode-btn').forEach(function (b) { b.classList.toggle('active', b.dataset.qmode === mode); }); const tabs = document.querySelector('.nq-input-tabs'); if (mode === 'manual') { if (tabs) tabs.style.display = 'none'; // Hide describe-mode input areas via their own class (NOT inline // display) so switchInputMode can restore them when we switch back. document.querySelectorAll('.nq-mode').forEach(function (m) { m.classList.remove('active'); }); if (details) details.style.display = 'none'; // editor has its own client/address editor.style.display = 'block'; } else { if (tabs) tabs.style.display = ''; if (details) details.style.display = ''; editor.style.display = 'none'; if (typeof switchInputMode === 'function') switchInputMode(inputMode || 'text'); } } document.querySelectorAll('.nqm-mode-btn').forEach(function (b) { b.addEventListener('click', function () { setQuoteMode(b.dataset.qmode); }); }); addRow(false); recompute(); setQuoteMode('describe'); } initManualBuilder(); document.getElementById('nq-photo-drop').addEventListener('click', selectQuotePhoto); document.getElementById('nq-photo-remove').addEventListener('click', removeQuotePhoto); document.getElementById('file-quote-photo').addEventListener('change', e => { handleQuotePhotoSelected(e.target.files[0]); e.target.value = ''; }); init(); });