NanniMoon

NanniMoon

Sign in with your phone number

We'll send a 6-digit code to your WhatsApp number

or

Verify

Enter the code sent to +66 81 234 5678

Resend code in 30s

Didn't receive the code? Check your SMS inbox or try resending.

My Bookings

Request Replacement

Cancel / Change Dates

Admin Panel

Replacement Request Management

Admin Login

Sign in with your authorized Google account

`; } function updateDateChangeSummary() { const startVal = document.getElementById('dc-new-start')?.value; const endVal = document.getElementById('dc-new-end')?.value; const summary = document.getElementById('dc-summary'); if (!summary || !startVal || !endVal || !dateChangeData) return; const origDays = dateChangeData.booking.original_days; const start = new Date(startVal); const end = new Date(endVal); const newDays = Math.max(1, Math.ceil((end - start) / (1000*60*60*24))); const diff = newDays - origDays; const dailyPrice = parseFloat(dateChangeData.booking.nanny_daily_price) || 0; let html = `

${t('date_change.original_days')}: ${origDays} ${t('date_change.days_label')}

`; html += `

${t('date_change.new_days')}: ${newDays} ${t('date_change.days_label')}

`; if (diff === 0) { html += `

${t('date_change.same_days')}

`; } else if (diff < 0) { html += `

${Math.abs(diff)} ${t('date_change.fewer_days')}

`; if (dailyPrice > 0) { const refundPct = dateChangeSource === 'war' ? 0.95 : 0.95; // approximate — server determines exact const est = Math.abs(diff) * dailyPrice * refundPct; html += `

${t('date_change.refund_estimate')}: ~$${est.toLocaleString('en-US', {minimumFractionDigits:0, maximumFractionDigits:0})}

`; } } else { html += `

+${diff} ${t('date_change.more_days')}

`; if (dailyPrice > 0) { const extra = diff * dailyPrice * 1.10; html += `

${t('date_change.more_days_charge')}: $${extra.toLocaleString('en-US', {minimumFractionDigits:0, maximumFractionDigits:0})}

`; html += `

${t('date_change.more_days_formula')}

`; } } summary.innerHTML = html; summary.classList.remove('hidden'); // Update button text const btn = document.getElementById('btn-dc-submit'); if (btn) { btn.textContent = diff > 0 ? t('date_change.submit_and_pay') : t('date_change.submit'); } } async function submitDateChange() { const newStart = document.getElementById('dc-new-start')?.value; const newEnd = document.getElementById('dc-new-end')?.value; const newLoc = document.getElementById('dc-new-location')?.value; const note = document.getElementById('dc-note')?.value || ''; if (!newStart || !newEnd || !newLoc) { alert(t('war_cancel.fill_all_fields')); return; } const btn = document.getElementById('btn-dc-submit'); btn.disabled = true; btn.innerHTML = ` ${t('common.processing')}`; try { const result = await api('/api/family/date-change/submit', { method: 'POST', body: { booking_id: dateChangeBookingId, new_start_date: newStart, new_end_date: newEnd, new_location: newLoc, source: dateChangeSource, family_note: note, }, }); // Handle special responses if (result.blocked) { btn.disabled = false; btn.textContent = t('date_change.submit'); alert(result.message); return; } if (result.confirm_required) { // War flow fewer days confirmation if (confirm(result.message)) { btn.innerHTML = ` ${t('common.processing')}`; const confirmed = await api('/api/family/date-change/submit', { method: 'POST', body: { booking_id: dateChangeBookingId, new_start_date: newStart, new_end_date: newEnd, new_location: newLoc, source: dateChangeSource, family_note: note, confirmed_fewer_days: true, }, }); renderDateChangeSuccess(confirmed); } else { btn.disabled = false; btn.textContent = t('date_change.submit'); } return; } renderDateChangeSuccess(result); } catch (err) { btn.disabled = false; btn.textContent = t('date_change.submit'); alert(err.message || 'Something went wrong'); } } function renderDateChangeSuccess(result) { const container = document.getElementById('cancel-content'); if (!container) return; let html = ''; if (result.day_change_type === 'same') { const msg = result.is_same_area ? t('date_change.success_same_nanny') : t('date_change.success_same_replacement'); html = `

${t('date_change.success_same_title')}

${msg}

`; } else if (result.day_change_type === 'fewer') { html = `

${t('date_change.success_fewer_title')}

${t('date_change.success_fewer_desc')}

${result.refund_amount ? `

${t('date_change.refund_estimate')}: $${Number(result.refund_amount).toLocaleString('en-US', {minimumFractionDigits:0, maximumFractionDigits:0})}

` : ''}
`; } else if (result.day_change_type === 'more') { html = `

${t('date_change.success_more_title')}

${t('date_change.success_more_desc')}

$${Number(result.extra_days_charge).toLocaleString('en-US', {minimumFractionDigits:0, maximumFractionDigits:0})}

${result.stripe_checkout_url ? ` ${t('date_change.pay_now')} ` : ''}
`; } html += ` `; container.innerHTML = html; window.scrollTo(0, 0); } function renderDateChangeStatus(req) { const statusMap = { 'awaiting_nanny': { color: 'amber', title: t('date_change.status_awaiting_nanny') }, 'nanny_approved': { color: 'green', title: t('date_change.status_nanny_approved') }, 'nanny_declined': { color: 'blue', title: t('date_change.status_nanny_declined') }, 'replacement_triggered': { color: 'blue', title: t('date_change.status_replacement') }, 'pricing_review': { color: 'amber', title: t('date_change.status_pricing_review') }, 'awaiting_payment': { color: 'blue', title: t('date_change.status_awaiting_payment') }, 'payment_received': { color: 'green', title: t('date_change.status_payment_received') }, 'completed': { color: 'green', title: t('date_change.status_completed') }, }; const info = statusMap[req.status] || { color: 'gray', title: req.status }; const colors = { green: 'bg-green-50 border-green-200 text-green-800', amber: 'bg-amber-50 border-amber-200 text-amber-800', blue: 'bg-blue-50 border-blue-200 text-blue-800', gray: 'bg-gray-50 border-gray-200 text-gray-800', }; return `

${t('date_change.status_title')}

${req.booking_name || ''}

${info.title}

${formatDate(req.new_start_date)} — ${formatDate(req.new_end_date)} (${req.new_days} ${t('date_change.days_label')}) · ${req.new_location}

${req.day_change_type === 'more' && req.stripe_checkout_url && req.status === 'awaiting_payment' ? ` ${t('date_change.pay_now')} ` : ''}
`; } // ============================================================ // WAR CANCEL FUNCTIONS (remaining) // ============================================================ async function warShowConfirmCredit() { if (!warCancelData) return; const fmtMoney = n => '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); if (!confirm(t('war_cancel.confirm_credit', { amount: fmtMoney(warCancelData.financials.credit_amount) }))) return; const container = document.getElementById('cancel-content'); container.innerHTML = `
${t('common.processing')}
`; try { const result = await api('/api/family/war-cancel/credit', { method: 'POST', body: { booking_id: currentCancelBookingId, reason_note: warCancelNote }, }); container.innerHTML = renderWarSuccess('credit', result); window.scrollTo(0, 0); } catch (err) { container.innerHTML = renderWarError(err.message); } } async function warFullCancel() { if (!confirm(t('war_cancel.confirm_full_cancel'))) return; const container = document.getElementById('cancel-content'); container.innerHTML = `
${t('common.processing')}
`; try { await api('/api/family/war-cancel/full-cancel', { method: 'POST', body: { booking_id: currentCancelBookingId, reason_note: warCancelNote }, }); // Proceed to standard cancellation flow with SECURITY_WAR reason cancelWizardData = await api('/api/family/cancellation/calculate', { method: 'POST', body: { booking_id: currentCancelBookingId, reason_code: 'SECURITY_WAR' }, }); cancelWizardData._reason = 'SECURITY_WAR'; cancelWizardData._note = warCancelNote; container.innerHTML = renderCancelStep2(cancelWizardData); document.getElementById('cancel-subtitle').textContent = ''; window.scrollTo(0, 0); } catch (err) { container.innerHTML = renderWarError(err.message); } } function renderWarSuccess(flow, result) { const fmtMoney = n => '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); if (flow === 'credit' || flow === 'credit_no_dates') { const expiry = result.credit_expires_at ? new Date(result.credit_expires_at).toLocaleDateString(i18n.locale, { month: 'long', year: 'numeric' }) : ''; return `

${t('war_cancel.success_credit_title')}

${flow === 'credit_no_dates' ? t('war_cancel.success_no_dates') : t('war_cancel.success_credit')}

${t('war_cancel.your_credit')}

${fmtMoney(result.credit_amount)}

${expiry ? `

${t('war_cancel.valid_until')} ${expiry}

` : ''}
`; } if (flow === 'reschedule') { const isSame = result.is_same_area; return `

${t('war_cancel.success_reschedule_title')}

${isSame ? t('war_cancel.success_reschedule_same') : t('war_cancel.success_reschedule_diff')}

`; } return ''; } let warStatusPollTimer = null; let warStatusLastStatus = null; function stopWarStatusPolling() { if (warStatusPollTimer) { clearInterval(warStatusPollTimer); warStatusPollTimer = null; } } function startWarStatusPolling(bookingId) { stopWarStatusPolling(); warStatusLastStatus = null; warStatusPollTimer = setInterval(async () => { // Stop if user navigated away from cancel page const cancelSection = document.getElementById('cancel-section'); if (!cancelSection || cancelSection.classList.contains('hidden')) { stopWarStatusPolling(); return; } try { const { request } = await api(`/api/family/war-cancel/${bookingId}`); if (!request) { stopWarStatusPolling(); return; } // Only re-render if status actually changed if (warStatusLastStatus && warStatusLastStatus === request.status) return; warStatusLastStatus = request.status; const container = document.getElementById('cancel-content'); if (container) { container.innerHTML = renderWarStatus(request); } // Stop polling once we reach a terminal or non-waiting state if (!['awaiting_nanny', 'credit_pending'].includes(request.status)) { stopWarStatusPolling(); } } catch (err) { // Silently ignore poll errors } }, 10000); // Poll every 10 seconds } function renderWarStatus(req) { const statusConfig = { credit_pending: { color: 'green', icon: '', title: 'war_cancel.status_credit_pending' }, awaiting_nanny: { color: 'amber', icon: '', title: 'war_cancel.status_awaiting_nanny' }, nanny_approved: { color: 'green', icon: '', title: 'war_cancel.status_nanny_approved' }, nanny_declined: { color: 'amber', icon: '', title: 'war_cancel.status_nanny_declined' }, replacement_triggered: { color: 'blue', icon: '', title: 'war_cancel.status_replacement' }, reschedule_requested: { color: 'blue', icon: '', title: 'war_cancel.status_reschedule' }, }; const cfg = statusConfig[req.status] || statusConfig.credit_pending; const colors = { green: { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-800', subtext: 'text-green-700', iconBg: 'bg-green-100', iconColor: 'text-green-600' }, amber: { bg: 'bg-amber-50', border: 'border-amber-200', text: 'text-amber-800', subtext: 'text-amber-700', iconBg: 'bg-amber-100', iconColor: 'text-amber-600' }, blue: { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-800', subtext: 'text-blue-700', iconBg: 'bg-blue-100', iconColor: 'text-blue-600' }, }; const c = colors[cfg.color]; const fmtDate = d => d ? new Date(d).toLocaleDateString(i18n.locale, { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; const fmtMoney = n => n ? ('$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })) : ''; let details = ''; if (req.flow === 'credit' || req.flow === 'credit_no_dates') { details = `
${t('war_cancel.your_credit')}${fmtMoney(req.credit_amount)}
${req.credit_expires_at ? `
${t('war_cancel.valid_until')}${fmtDate(req.credit_expires_at)}
` : ''}
`; } if (req.flow === 'reschedule') { details = `
${t('war_cancel.new_dates')}${fmtDate(req.new_start_date)} – ${fmtDate(req.new_end_date)}
${t('war_cancel.location')}${req.new_location || '—'}
`; } // Show "Submit dates" form for credit requests still pending let submitDatesForm = ''; if ((req.flow === 'credit' || req.flow === 'credit_no_dates') && req.status === 'credit_pending') { const destinations = ['Phuket','Krabi','Khao Lak','Ko Samui','Ko Phangan','Ko Lanta','Ko Yao Noi','Ko Yao Yai','Koh Phi Phi','Bangkok','Chiang Mai','Pattaya','Hua Hin','Israel']; const destOptions = destinations.map(d => ``).join(''); const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); const minDate = tomorrow.toISOString().split('T')[0]; submitDatesForm = `

${t('war_cancel.have_dates_now')}

${t('war_cancel.submit_dates_desc')}

`; } return `
${cfg.icon}

${t(cfg.title)}

${t('war_cancel.status_subtitle_' + req.status) || t('war_cancel.status_under_review')}

${details}
${submitDatesForm} ${['pending', 'credit_pending', 'reschedule_requested'].includes(req.status) ? ` ` : ''} `; } async function warSubmitFollowUpDates(bookingId) { const startDate = document.getElementById('war-followup-start')?.value; const endDate = document.getElementById('war-followup-end')?.value; const location = document.getElementById('war-followup-location')?.value; const note = document.getElementById('war-followup-note')?.value; const btn = document.getElementById('war-followup-btn'); if (!startDate || !endDate || !location) { showToast(t('war_cancel.fill_all_fields'), 'error'); return; } btn.disabled = true; btn.textContent = t('common.loading'); try { const result = await api('/api/family/war-cancel/submit-dates', { method: 'POST', body: JSON.stringify({ booking_id: bookingId, new_start_date: startDate, new_end_date: endDate, new_location: location, reschedule_note: note }), }); if (result.error) { showToast(result.error, 'error'); btn.disabled = false; btn.textContent = t('war_cancel.submit_new_dates'); return; } const container = document.getElementById('cancel-container'); container.innerHTML = renderWarSuccess(result, 'reschedule'); } catch (err) { showToast(t('war_cancel.error'), 'error'); btn.disabled = false; btn.textContent = t('war_cancel.submit_new_dates'); } } async function warWithdrawRequest(bookingId) { if (!confirm(t('war_cancel.confirm_withdraw'))) return; const btn = document.getElementById('btn-war-withdraw'); if (btn) { btn.disabled = true; btn.innerHTML = ` ${t('common.processing')}`; } try { await api('/api/family/war-cancel/withdraw', { method: 'POST', body: { booking_id: bookingId }, }); // Reload the cancel page — will show the war options again since request is now cancelled await loadWarCancelPage(warCancelNote || ''); } catch (err) { if (btn) { btn.disabled = false; btn.textContent = t('war_cancel.change_selection'); } alert(err.message); } } function renderWarError(msg) { return `

${t('war_cancel.error')}

${msg}

`; } function formatMoney(n) { return '$' + Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 }); } // ========== FINANCE DASHBOARD ========== let financeToken = sessionStorage.getItem('financeToken') || null; let financeCurrentTab = 'cashflow'; let financePayoutMode = 'weekly'; function financeApi(path, opts = {}) { const headers = { ...opts.headers }; if (financeToken) headers['x-finance-token'] = financeToken; return api(path, { ...opts, headers }); } function financeOpenDashboard() { document.getElementById('admin-dashboard-section').classList.add('hidden'); document.getElementById('admin-job-detail-section').classList.add('hidden'); document.getElementById('admin-audit-section').classList.add('hidden'); document.getElementById('admin-finance-section').classList.remove('hidden'); document.getElementById('admin-subtitle').textContent = 'Finance Dashboard'; // Check if we already have a valid finance token if (financeToken) { financeShowContent(); return; } // Check if PIN exists financeCheckPin(); } async function financeCheckPin() { try { const data = await api('/api/admin/finance/has-pin'); if (data.hasPin) { document.getElementById('finance-pin-gate').classList.remove('hidden'); document.getElementById('finance-set-pin-section').classList.add('hidden'); document.getElementById('finance-pin-subtitle').textContent = 'Enter your finance PIN to access'; document.getElementById('finance-pin-btn').classList.remove('hidden'); } else { document.getElementById('finance-pin-gate').classList.remove('hidden'); document.getElementById('finance-set-pin-section').classList.remove('hidden'); document.getElementById('finance-pin-subtitle').textContent = 'Set up your finance PIN first'; document.getElementById('finance-pin-btn').classList.add('hidden'); } document.getElementById('finance-content').classList.add('hidden'); financeSetupPinInputs('finance-pin-inputs'); financeSetupPinInputs('finance-new-pin-inputs'); financeSetupPinInputs('finance-change-pin-inputs'); } catch (err) { console.error('[Finance] Check PIN failed:', err.message); } } function financeSetupPinInputs(containerId) { const inputs = document.getElementById(containerId).querySelectorAll('.otp-input'); inputs.forEach((inp, i) => { inp.value = ''; inp.oninput = () => { if (inp.value.length === 1 && i < inputs.length - 1) inputs[i + 1].focus(); }; inp.onkeydown = (e) => { if (e.key === 'Backspace' && !inp.value && i > 0) inputs[i - 1].focus(); if (e.key === 'Enter') { // Trigger the action based on container if (containerId === 'finance-pin-inputs') financeVerifyPin(); else if (containerId === 'finance-new-pin-inputs') financeSetPin(); else if (containerId === 'finance-change-pin-inputs') financeChangePin(); } }; }); if (inputs[0]) inputs[0].focus(); } function financeGetPinValue(containerId) { const inputs = document.getElementById(containerId).querySelectorAll('.otp-input'); return Array.from(inputs).map(i => i.value).join(''); } async function financeVerifyPin() { const pin = financeGetPinValue('finance-pin-inputs'); const errEl = document.getElementById('finance-pin-error'); errEl.classList.add('hidden'); if (pin.length < 4) { errEl.textContent = 'Enter at least 4 digits'; errEl.classList.remove('hidden'); return; } try { const data = await api('/api/admin/finance/verify-pin', { method: 'POST', body: { pin } }); financeToken = data.token; sessionStorage.setItem('financeToken', data.token); financeShowContent(); } catch (err) { errEl.textContent = err.message || 'Incorrect PIN'; errEl.classList.remove('hidden'); } } async function financeSetPin() { const pin = financeGetPinValue('finance-new-pin-inputs'); const errEl = document.getElementById('finance-pin-error'); errEl.classList.add('hidden'); if (pin.length < 4) { errEl.textContent = 'Enter at least 4 digits'; errEl.classList.remove('hidden'); return; } try { await api('/api/admin/finance/set-pin', { method: 'POST', body: { pin } }); // Now verify to get token const data = await api('/api/admin/finance/verify-pin', { method: 'POST', body: { pin } }); financeToken = data.token; sessionStorage.setItem('financeToken', data.token); financeShowContent(); } catch (err) { errEl.textContent = err.message || 'Failed to set PIN'; errEl.classList.remove('hidden'); } } async function financeChangePin() { const pin = financeGetPinValue('finance-change-pin-inputs'); const statusEl = document.getElementById('finance-change-pin-status'); statusEl.classList.add('hidden'); if (pin.length < 4) { statusEl.textContent = 'Enter at least 4 digits'; statusEl.className = 'text-xs text-center text-red-600'; statusEl.classList.remove('hidden'); return; } try { await api('/api/admin/finance/set-pin', { method: 'POST', body: { pin } }); statusEl.textContent = 'PIN updated successfully'; statusEl.className = 'text-xs text-center text-emerald-600'; statusEl.classList.remove('hidden'); // Clear inputs document.getElementById('finance-change-pin-inputs').querySelectorAll('.otp-input').forEach(i => i.value = ''); } catch (err) { statusEl.textContent = err.message || 'Failed'; statusEl.className = 'text-xs text-center text-red-600'; statusEl.classList.remove('hidden'); } } function financeShowContent() { document.getElementById('finance-pin-gate').classList.add('hidden'); document.getElementById('finance-content').classList.remove('hidden'); financeTab('cashflow'); } function financeBackToAdmin() { adminBackToList(); } function fmtUSD(n) { const num = Number(n) || 0; const sign = num < 0 ? '-' : ''; return sign + '$' + Math.abs(num).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function fmtTHB(n) { const num = Number(n) || 0; const sign = num < 0 ? '-' : ''; return sign + '฿' + Math.abs(num).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function fmtInt(n) { return Number(n || 0).toLocaleString('en-US'); } // aliases for backward compat const financeFmt = fmtUSD; const financeFmtUSD = fmtUSD; const financeFmtTHB = fmtTHB; // ---- Tab switching ---- function financeTab(tab) { financeCurrentTab = tab; ['cashflow', 'payouts', 'cancellations', 'replacements', 'flows', 'sync', 'travel'].forEach(t => { const el = document.getElementById('finance-tab-' + t); if (el) el.classList.toggle('hidden', t !== tab); const btn = document.getElementById('ftab-' + t); if (btn) btn.className = t === tab ? 'shrink-0 px-3 text-[11px] font-semibold py-2 rounded-lg transition-all bg-white text-gray-800 shadow-sm' : 'shrink-0 px-3 text-[11px] font-semibold py-2 rounded-lg transition-all text-gray-500'; }); if (tab === 'cashflow') financeLoadCashflow(); if (tab === 'payouts') financeLoadPayouts(); if (tab === 'cancellations') financeLoadCancellations(); if (tab === 'replacements') financeLoadReplacements(); if (tab === 'flows') financeLoadFlows(); if (tab === 'sync') financeLoadSyncReport(); if (tab === 'travel') financeLoadTravel(); } // ---- CASH FLOW TAB ---- async function financeLoadCashflow() { const c = document.getElementById('finance-cashflow-content'); c.innerHTML = '

Loading...

'; try { const d = await financeApi('/api/admin/finance/summary'); const trueCashColor = d.true_cash >= 0 ? 'from-emerald-600 to-emerald-700' : 'from-red-600 to-red-700'; const trueCashLabel = d.true_cash >= 0 ? 'Company is cash-positive' : 'Liabilities exceed assets — action needed'; const updatedText = d.updated_at ? new Date(d.updated_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'Not set'; c.innerHTML = `

Last Updated: ${updatedText}  |  Rate: 1 USD = ${d.thb_to_usd} THB (live)

True Cash

${fmtUSD(d.true_cash)}

Assets - Liabilities - Expenses

${trueCashLabel}

Assets

Mercury Bank Balance${fmtUSD(d.assets.mercury)}
Stripe Balance${fmtUSD(d.assets.stripe)}
TOTAL ASSETS${fmtUSD(d.assets.total)}

Liabilities

Nanny Payouts Owed (${fmtInt(d.liabilities.future_bookings_count)} future bookings)
THB ${fmtInt(d.liabilities.nanny_payouts_thb)}
${fmtUSD(d.liabilities.nanny_payouts_owed)}
Cancellation Refunds Owed${fmtUSD(d.liabilities.cancellation_refunds_owed)}
${d.cancellations.refunds_paid_count > 0 ? `
Refunds Already Paid (${fmtInt(d.cancellations.refunds_paid_count)} — stage: Cancelled by customer)excluded
` : ''}
Credits Outstanding (non-cash)${fmtUSD(d.liabilities.credits_outstanding)}
TOTAL LIABILITIES${fmtUSD(d.liabilities.total)}
${d.cancellations.active_count > 0 ? `

App Cancellations

Confirmed${fmtInt(d.cancellations.confirmed_count)}
Pending Review${fmtInt(d.cancellations.pending_count)}
Rejected${fmtInt(d.cancellations.rejected_count)}
Cash Refunds Owed${fmtUSD(d.cancellations.refunds_owed)}
Credits Issued${fmtUSD(d.cancellations.credits_outstanding)}
` : ''}

Operational / Fixed Expenses

Monthly Operational Cost${fmtUSD(d.expenses.monthly_operational)}
TOTAL EXPENSES${fmtUSD(d.expenses.total)}

Booking Summary

Active Bookings${fmtInt(d.booking_summary.active_count)}
Future Bookings (start >= today)${fmtInt(d.booking_summary.future_count)}
Completed Bookings${fmtInt(d.booking_summary.completed_count)}
Canceled Bookings${fmtInt(d.booking_summary.cancelled_count)}
Gross Revenue (active)${fmtUSD(d.booking_summary.gross_revenue)}
Completed Revenue${fmtUSD(d.booking_summary.completed_revenue)}
Company Profit${fmtUSD(d.booking_summary.gross_profit)}
Future Revenue${fmtUSD(d.booking_summary.future_revenue)}
Nanny Payouts Owed${fmtUSD(d.booking_summary.nanny_payouts_owed)}
`; // Pre-fill inputs document.getElementById('finance-input-mercury').value = d.assets.mercury || ''; document.getElementById('finance-input-stripe').value = d.assets.stripe || ''; document.getElementById('finance-input-expenses').value = d.expenses.monthly_operational || 3500; document.getElementById('finance-thb-rate-display').textContent = `${d.thb_to_usd} THB`; } catch (err) { if (err.message?.includes('expired') || err.message?.includes('PIN required')) { financeToken = null; sessionStorage.removeItem('financeToken'); financeCheckPin(); return; } c.innerHTML = `

Failed: ${err.message}

`; } } async function financeSaveBalances(btn) { const origText = btn.textContent; btn.innerHTML = ''; btn.disabled = true; const statusEl = document.getElementById('finance-balance-status'); statusEl.classList.add('hidden'); try { const stripe = parseFloat(document.getElementById('finance-input-stripe').value) || 0; const mercury = parseFloat(document.getElementById('finance-input-mercury').value) || 0; const monthly_expenses = parseFloat(document.getElementById('finance-input-expenses').value) || 3500; await financeApi('/api/admin/finance/balances', { method: 'PUT', body: { stripe, mercury, monthly_expenses } }); statusEl.textContent = 'Saved — recalculating...'; statusEl.className = 'text-xs text-center text-emerald-600'; statusEl.classList.remove('hidden'); financeLoadCashflow(); } catch (err) { statusEl.textContent = err.message || 'Failed'; statusEl.className = 'text-xs text-center text-red-600'; statusEl.classList.remove('hidden'); } finally { btn.textContent = origText; btn.disabled = false; } } // ---- PAYOUTS TAB ---- function financePayoutView(mode) { financePayoutMode = mode; ['weekly', 'monthly', 'stripe'].forEach(m => { const btn = document.getElementById('fpayout-' + m); if (btn) btn.className = m === mode ? 'flex-1 text-[11px] font-semibold py-1.5 rounded-lg bg-white text-gray-800 shadow-sm transition-all' : 'flex-1 text-[11px] font-semibold py-1.5 rounded-lg text-gray-500 transition-all'; }); const payoutsEl = document.getElementById('finance-payouts-content'); const stripeEl = document.getElementById('finance-stripe-content'); if (mode === 'stripe') { payoutsEl.classList.add('hidden'); stripeEl.classList.remove('hidden'); financeLoadStripePay(); } else { payoutsEl.classList.remove('hidden'); stripeEl.classList.add('hidden'); financeLoadPayouts(); } } async function financeLoadPayouts() { const c = document.getElementById('finance-payouts-content'); c.innerHTML = '

Loading...

'; try { const data = await financeApi(`/api/admin/finance/payouts?view=${financePayoutMode}&weeks=8`); const fmtD = d => new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const mn = { '01':'Jan','02':'Feb','03':'Mar','04':'Apr','05':'May','06':'Jun','07':'Jul','08':'Aug','09':'Sep','10':'Oct','11':'Nov','12':'Dec' }; function renderNannyDetails(nannies) { if (!nannies?.length) return '

No payouts

'; // Group by nanny: one salary line + optional accommodation sub-line const grouped = {}; for (const n of nannies) { const key = n.nanny_name || 'Unknown'; if (!grouped[key]) grouped[key] = { name: key, salary: null, accom: null }; if (n.is_accommodation) { if (!grouped[key].accom) grouped[key].accom = { days: 0, daily_rate: n.daily_rate, amount: 0, daily_rate_thb: n.daily_rate_thb, amount_thb: 0 }; grouped[key].accom.days += n.days; grouped[key].accom.amount = Math.round((grouped[key].accom.amount + n.amount) * 100) / 100; grouped[key].accom.amount_thb += (n.amount_thb || 0); } else { if (!grouped[key].salary) grouped[key].salary = { days: 0, daily_rate: n.daily_rate, amount: 0, daily_rate_thb: n.daily_rate_thb, amount_thb: 0 }; grouped[key].salary.days += n.days; grouped[key].salary.amount = Math.round((grouped[key].salary.amount + n.amount) * 100) / 100; grouped[key].salary.amount_thb += (n.amount_thb || 0); } } const fmtTHB = v => Math.round(v).toLocaleString() + ' THB'; return `
${Object.values(grouped).map(g => { const totalUsd = (g.salary?.amount || 0) + (g.accom?.amount || 0); const totalThb = (g.salary?.amount_thb || 0) + (g.accom?.amount_thb || 0); return `
${g.name}${g.salary ? g.salary.days + 'd x ' + fmtTHB(g.salary.daily_rate_thb) : ''}
${fmtUSD(totalUsd)}(${fmtTHB(totalThb)})
${g.accom ? `
Accommodation${g.accom.days}n x ${fmtTHB(g.accom.daily_rate_thb)}
${fmtUSD(g.accom.amount)}(${fmtTHB(g.accom.amount_thb)})
` : ''}
`; }).join('')}
`; } function togglePayoutRow(el) { const details = el.nextElementSibling; const arrow = el.querySelector('.payout-arrow'); if (details.classList.contains('hidden')) { details.classList.remove('hidden'); arrow.style.transform = 'rotate(180deg)'; } else { details.classList.add('hidden'); arrow.style.transform = 'rotate(0deg)'; } } window.togglePayoutRow = togglePayoutRow; const todayStr = new Date().toISOString().slice(0, 10); const curMonth = todayStr.slice(0, 7); if (data.view === 'monthly') { if (!data.months?.length) { c.innerHTML = '

No data

'; return; } c.innerHTML = `

Nanny payout = daily rate (THB) × days ÷ THB rate  |  Tap a row to see details

` + data.months.map(m => { const [y, mm] = m.month.split('-'); const isPast = m.month < curMonth; const isCurrent = m.month === curMonth; const borderCls = isCurrent ? 'border-blue-300' : 'border-gray-200'; const headerCls = isPast ? 'bg-gray-100 hover:bg-gray-200' : isCurrent ? 'bg-blue-100 hover:bg-blue-200' : 'bg-blue-50 hover:bg-blue-100'; const titleCls = isPast ? 'text-gray-400' : 'text-blue-800'; const amountCls = isPast ? 'text-gray-400' : 'text-blue-700'; const badge = isPast ? 'PAID' : isCurrent ? 'NOW' : ''; return `
${mn[mm] || mm} ${y}${badge}
${fmtUSD(m.total)} ${Math.round(m.nannies.reduce((s,n) => s + (n.amount_thb||0), 0)).toLocaleString()} THB ${m.bookings} bookings · ${m.nannies.length} nannies
`; }).join(''); } else { if (!data.weeks?.length) { c.innerHTML = '

No data

'; return; } c.innerHTML = `

Nanny payout = daily rate (THB) × days ÷ THB rate  |  Tap a row to see details

` + data.weeks.map(w => { const isPast = w.week_end < todayStr && !w.is_current; const borderCls = w.is_current ? 'border-blue-300 bg-blue-50/30' : 'border-gray-200'; const headerCls = isPast ? 'bg-gray-100 hover:bg-gray-200' : w.is_current ? 'bg-blue-100 hover:bg-blue-200' : 'bg-gray-50 hover:bg-gray-100'; const dateCls = isPast ? 'text-gray-400' : w.is_current ? 'text-blue-700 font-bold' : 'text-gray-500'; const amountCls = isPast ? 'text-gray-400' : 'text-blue-700'; const badge = w.is_current ? 'NOW' : isPast ? 'PAID' : ''; return `
${fmtD(w.week_start)} - ${fmtD(w.week_end)} ${badge} Pay: ${fmtD(w.pay_day)}
${fmtUSD(w.total)} ${Math.round(w.nannies.reduce((s,n) => s + (n.amount_thb||0), 0)).toLocaleString()} THB · ${w.nannies.length} nannies
`; }).join(''); } } catch (err) { c.innerHTML = `

Failed: ${err.message}

`; } } // ---- STRIPE PAY ---- let currentBatchId = null; function getLastMonday() { const d = new Date(); const day = d.getDay(); // Go back to last Sunday (week_start) const diff = day === 0 ? 0 : day; const sun = new Date(d); sun.setDate(d.getDate() - diff - 7); // Last week's Sunday return sun.toISOString().split('T')[0]; } async function financeLoadStripePay() { const c = document.getElementById('finance-stripe-content'); c.innerHTML = '

Loading...

'; try { const data = await financeApi('/api/admin/finance/payout-batches'); const batches = data.batches || []; const fmtD = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-'; const lastSun = getLastMonday(); let html = `

Stripe Weekly Payouts

Generate batch → Review & Edit → Confirm & Pay

Generate Payout Batch

`; // Existing batches if (batches.length > 0) { html += `

Recent Batches

`; html += batches.map(b => { const statusColors = { draft: 'bg-amber-100 text-amber-700', processing: 'bg-blue-100 text-blue-700', completed: 'bg-emerald-100 text-emerald-700', }; const statusCls = statusColors[b.status] || 'bg-gray-100 text-gray-600'; return `
${fmtD(b.week_start)} - ${fmtD(b.week_end)} ${b.nanny_count || 0} nannies
${fmtUSD(b.total_usd)} ${b.status.toUpperCase()}
`; }).join(''); } c.innerHTML = html; } catch (err) { c.innerHTML = `

Failed: ${err.message}

`; } } async function stripeGenerateBatch(btn) { const origText = btn.textContent; btn.innerHTML = ''; btn.disabled = true; const statusEl = document.getElementById('stripe-generate-status'); statusEl.classList.add('hidden'); try { const weekStart = document.getElementById('stripe-week-start').value; if (!weekStart) throw new Error('Select a week start date'); const data = await financeApi('/api/admin/finance/payout-batches/generate', { method: 'POST', body: { week_start: weekStart } }); statusEl.textContent = `Batch generated: ${data.nanny_count} nannies, ${fmtUSD(data.total_usd)}`; statusEl.className = 'text-xs text-emerald-600'; statusEl.classList.remove('hidden'); // Load the batch detail stripeLoadBatch(data.batch_id); } catch (err) { statusEl.textContent = err.message || 'Failed'; statusEl.className = 'text-xs text-red-600'; statusEl.classList.remove('hidden'); } finally { btn.textContent = origText; btn.disabled = false; } } async function stripeLoadBatch(batchId) { currentBatchId = batchId; const c = document.getElementById('finance-stripe-content'); c.innerHTML = '

Loading batch...

'; try { const data = await financeApi(`/api/admin/finance/payout-batches/${batchId}`); const b = data.batch; const fmtD = d => d ? new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-'; const isDraft = b.status === 'draft'; const isCompleted = b.status === 'completed'; let html = `
${fmtD(b.week_start)} - ${fmtD(b.week_end)} ${b.status.toUpperCase()}

Total Payout

${fmtUSD(b.total_usd)}

${b.nanny_count} nannies · Rate: 1 USD = ${b.thb_rate} THB

`; // Nanny breakdown html += `
`; for (const nanny of data.nannies) { const hasEmail = !!nanny.nanny_email; const statusIcons = nanny.items.map(i => { if (i.transfer_status === 'success') return ''; if (i.transfer_status === 'failed') return ''; return ''; }).join(''); html += `
${nanny.nanny_name} ${statusIcons} ${!hasEmail ? 'No email' : ''} ${nanny.nanny_email || 'No email'} · ${nanny.total_days}d
${fmtUSD(nanny.total_usd)} ${Math.round(nanny.total_thb).toLocaleString()} THB
`; } html += `
`; // Confirm button (only for draft) if (isDraft) { html += `

This will transfer funds via Stripe to all nannies. This action cannot be undone.

`; } // Results summary (for completed) if (isCompleted) { const successCount = data.items.filter(i => i.transfer_status === 'success').length; const failedCount = data.items.filter(i => i.transfer_status === 'failed').length; html += `

Batch Completed

${successCount} successful transfers${failedCount > 0 ? ` · ${failedCount} failed` : ''}

`; } c.innerHTML = html; } catch (err) { c.innerHTML = `

Failed: ${err.message}

`; } } async function stripeEditItem(itemId, value) { try { const amount = parseFloat(value); if (isNaN(amount) || amount < 0) return; await financeApi(`/api/admin/finance/payout-items/${itemId}`, { method: 'PUT', body: { final_amount_usd: amount } }); // Refresh batch to update total if (currentBatchId) stripeLoadBatch(currentBatchId); } catch (err) { alert('Failed to update: ' + err.message); } } async function stripeConfirmBatch(batchId, btn) { if (!confirm('Are you sure you want to transfer funds to all nannies? This cannot be undone.')) return; const origText = btn.textContent; btn.innerHTML = ' Processing...'; btn.disabled = true; try { const result = await financeApi(`/api/admin/finance/payout-batches/${batchId}/confirm`, { method: 'POST' }); alert(`Done! ${result.success_count} successful, ${result.failed_count} failed. Total paid: ${fmtUSD(result.total_paid)}`); stripeLoadBatch(batchId); } catch (err) { alert('Error: ' + err.message); btn.textContent = origText; btn.disabled = false; } } async function stripeRetryItem(itemId, btn) { btn.textContent = '...'; btn.disabled = true; try { await financeApi(`/api/admin/finance/payout-items/${itemId}/retry`, { method: 'POST' }); if (currentBatchId) stripeLoadBatch(currentBatchId); } catch (err) { alert('Retry failed: ' + err.message); btn.textContent = 'Retry'; btn.disabled = false; } } // ---- CANCELLATIONS TAB ---- async function financeLoadCancellations() { const c = document.getElementById('finance-cancellations-content'); c.innerHTML = '

Loading...

'; try { const data = await financeApi('/api/admin/finance/cancellations'); const s = data.stats; const fmtD = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) : '-'; let html = `

Total Cancelled

${fmtInt(s.total_cancelled)}

Value: ${fmtUSD(s.total_value)}

Financial Impact

Refunds: ${fmtUSD(s.total_refunds_owed)}

Retained (5%): ${fmtUSD(s.total_retained)}

`; // App cancellations if (data.app_cancellations.length > 0) { html += `

App Cancellations (${data.app_cancellations.length})

`; html += data.app_cancellations.map(c => `
${c.booking_name || c.booking_id} ${c.status}
${c.family_name || '-'} | ${c.nanny_name || '-'}
${fmtD(c.start_date)} - ${fmtD(c.end_date)}
Reason: ${c.reason_code} Tier: ${c.tier} Method: ${c.method}
Booking: ${fmtUSD(c.booking_total)} Refund: ${fmtUSD(c.refund_amount)} Credit: ${fmtUSD(c.credit_amount)}
${c.reason_note ? `

"${c.reason_note}"

` : ''}
`).join(''); } // Zoho cancellations if (data.zoho_cancellations.length > 0) { html += `

Zoho/CRM Cancellations (${data.zoho_cancellations.length})

`; html += `
${data.zoho_cancellations.map(z => ` `).join('')}
BookingFamilyNannyTotalStage
${z.booking_name || z.record_id} ${z.family_name || '-'} ${z.nanny_name || '-'} ${fmtUSD(z.booking_total)} ${z.stage}
`; } c.innerHTML = html; } catch (err) { c.innerHTML = `

Failed: ${err.message}

`; } } // ---- REPLACEMENTS TAB ---- async function financeLoadReplacements() { const c = document.getElementById('finance-replacements-content'); c.innerHTML = '

Loading...

'; try { const data = await financeApi('/api/admin/finance/replacements'); const fmtD = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '-'; const STATUS_COLORS = { submitted: 'bg-amber-100 text-amber-700', broadcasting: 'bg-blue-100 text-blue-700', offered: 'bg-pink-100 text-pink-700', selection: 'bg-purple-100 text-purple-700', completed: 'bg-green-100 text-green-700', }; let html = `
${Object.entries(data.stats).map(([status, cnt]) => ` ${status}: ${cnt} `).join('')}
`; if (data.replacements.length === 0) { html += '

No replacement requests

'; } else { html += data.replacements.map(r => `
${r.booking_name || r.booking_id} ${r.status}
${r.family_name || '-'} | Original: ${r.nanny_name || '-'}
${r.requested_location || r.hotel_name || '-'} | ${fmtD(r.start_date)} - ${fmtD(r.end_date)}
${r.reason_category ? `
Reason: ${r.reason_category}
` : ''} ${r.ai_requirements ? `
"${r.ai_requirements}"
` : ''}
${fmtD(r.created_at)} | #${r.job_id.slice(0, 8)}
`).join(''); } c.innerHTML = html; } catch (err) { c.innerHTML = `

Failed: ${err.message}

`; } } // ---- FLOWS TAB (CRM Cancellation Pipeline) ---- async function financeLoadFlows() { const c = document.getElementById('finance-flows-content'); c.innerHTML = '

Loading...

'; try { const data = await financeApi('/api/admin/finance/cancellation-flows'); const s = data.stats; const fmtD = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) : '-'; const STEP_COLORS = { done: 'bg-emerald-500', current: 'bg-amber-500 animate-pulse', pending: 'bg-gray-300', warning: 'bg-orange-500', rejected: 'bg-red-500', }; const STATUS_BADGE = { pending_review: 'bg-amber-100 text-amber-700', confirmed: 'bg-red-100 text-red-700', rejected: 'bg-gray-100 text-gray-500', }; const TIER_LABELS = { OVER_30: '30+ days', '14_TO_29': '14-29 days', UNDER_14: 'Under 14 days', TRIAL_72H: '72h Trial', }; let html = `

Pending

${s.byStatus.pending_review || 0}

Confirmed

${s.byStatus.confirmed || 0}

Rejected

${s.byStatus.rejected || 0}

${Object.entries(s.byReason).map(([r, cnt]) => `${r}: ${cnt}`).join('')}
${Object.entries(s.byTier).map(([t, cnt]) => `${TIER_LABELS[t] || t}: ${cnt}`).join('')} Refund: ${s.byMethod.REFUND || 0} Credit: ${s.byMethod.CREDIT || 0}
`; // CRM Stage Flow Legend html += `

CRM Stage Flow

All cancellations: Submit → Cancellation Pending Review + Zoho: Cancelled by customer → Admin review required
Before start date: Admin approve → Booking Cancelled (local + Zoho) → Nanny notified
After start date: Admin approve → Partial cancelled by customer (local + Zoho) → Nanny notified
Rejected: Admin reject → Confirmed (rollback) — booking restored
Done Awaiting Pending Warning Rejected
`; if (data.flows.length === 0) { html += '

No cancellation flows yet

'; } else { html += data.flows.map(f => `
${f.booking_name || f.booking_id} ${fmtD(f.created_at)}
${f.status}
${f.family_name || '-'} | ${f.nanny_name || '-'} | ${fmtD(f.start_date)} - ${fmtD(f.end_date)}
${TIER_LABELS[f.tier] || f.tier} ${f.method} ${f.reason_code} ${f.refund_amount ? `$${parseFloat(f.refund_amount).toFixed(2)}` : ''} ${f.credit_amount ? `Credit $${parseFloat(f.credit_amount).toFixed(2)}` : ''}
${f.steps.map((step, i) => `
${i < f.steps.length - 1 ? `
` : ''}
`).join('')}
${f.steps.map(step => `${step.step.length > 18 ? step.step.slice(0, 16) + '..' : step.step}`).join('')}
${f.reason_note ? `

"${f.reason_note}"

` : ''}
CRM Stage: ${f.crm_stage || 'Not synced'}
`).join(''); } c.innerHTML = html; } catch (err) { if (err.message?.includes('expired') || err.message?.includes('PIN required')) { financeToken = null; sessionStorage.removeItem('financeToken'); financeCheckPin(); return; } c.innerHTML = `

Failed: ${err.message}

`; } } // ---- SYNC REPORT TAB ---- async function financeLoadSyncReport() { const c = document.getElementById('finance-sync-content'); c.innerHTML = '

Fetching CRM data & comparing... This may take a moment.

'; try { const data = await financeApi('/api/admin/finance/sync-report'); const s = data.stats; const changes = data.changes || []; const typeColors = { 'Stage Change': 'bg-red-50 border-red-200 text-red-700', 'Replacement': 'bg-amber-50 border-amber-200 text-amber-700', 'New in CRM': 'bg-blue-50 border-blue-200 text-blue-700', 'Date Change': 'bg-purple-50 border-purple-200 text-purple-700', 'Price Change': 'bg-yellow-50 border-yellow-200 text-yellow-700', }; let html = `

Website ↔ CRM Discrepancy Report

${s.discrepancies} Changes to Review

${s.total_crm} CRM records · ${s.total_local} website records · ${s.matching} matching

Sync is one-way: Website → CRM. Items below were changed in CRM and need manual update on the website.

How it works: Your website pushes booking data → CRM. If someone edits a booking directly in CRM, it creates a mismatch. Review the changes below, then update your website to match.
${Object.entries(s.by_type).map(([type, count]) => ` ${type}: ${count} `).join('')} High: ${s.by_priority.High} Medium: ${s.by_priority.Medium}
`; if (changes.length === 0) { html += '

All bookings are in sync!

'; } else { html += `
`; for (const ch of changes) { const prCls = ch.priority === 'High' ? 'bg-red-600' : 'bg-gray-400'; const typeCls = typeColors[ch.type] || 'bg-gray-50 border-gray-200 text-gray-600'; html += `
${ch.booking_name || ch.booking_id} ${ch.priority}
${ch.type}
${ch.family_name ? `

${ch.family_name}

` : ''}
${ch.field}
Website ${ch.local_value || '-'}
CRM ${ch.crm_value || '-'}

⚠ ${ch.type === 'New in CRM' ? 'New in CRM — add to website if needed' : 'Update website to match CRM, or it will be overwritten on next sync'}

`; } html += `
`; } c.innerHTML = html; } catch (err) { if (err.message?.includes('expired') || err.message?.includes('PIN required')) { financeToken = null; sessionStorage.removeItem('financeToken'); financeCheckPin(); return; } c.innerHTML = `

Failed: ${err.message}

`; } } async function financeSyncNow(btn) { const origText = btn.textContent; btn.innerHTML = ' Syncing...'; btn.disabled = true; const statusEl = document.getElementById('sync-status'); statusEl.classList.add('hidden'); try { const result = await financeApi('/api/admin/finance/sync-now', { method: 'POST' }); statusEl.textContent = `Synced: ${result.inserted} new, ${result.updated} updated, ${result.skipped} skipped (${result.total} total)`; statusEl.className = 'text-xs text-center text-emerald-600 mb-2'; statusEl.classList.remove('hidden'); // Refresh report after sync setTimeout(() => financeLoadSyncReport(), 1000); } catch (err) { statusEl.textContent = err.message || 'Sync failed'; statusEl.className = 'text-xs text-center text-red-600 mb-2'; statusEl.classList.remove('hidden'); } finally { btn.textContent = origText; btn.disabled = false; } } // ---- TRAVEL TAB ---- let currentTravelSubTab = 'dashboard'; function travelSubTab(tab) { ['dashboard', 'hotels', 'agreements'].forEach(t => { const content = document.getElementById('travel-sub-' + t + '-content'); const btn = document.getElementById('travel-sub-' + t); if (t === tab) { content.classList.remove('hidden'); btn.classList.add('bg-white', 'text-gray-800', 'shadow-sm'); btn.classList.remove('text-gray-500'); } else { content.classList.add('hidden'); btn.classList.remove('bg-white', 'text-gray-800', 'shadow-sm'); btn.classList.add('text-gray-500'); } }); currentTravelSubTab = tab; if (tab === 'dashboard') loadTravelDashboard(); else if (tab === 'hotels') loadTravelHotels(); else if (tab === 'agreements') loadTravelAgreements(); } async function financeLoadTravel() { travelSubTab(currentTravelSubTab); } // ========== SUB-TAB 1: DASHBOARD ========== async function loadTravelDashboard() { const c = document.getElementById('travel-sub-dashboard-content'); c.innerHTML = '

Loading dashboard...

'; try { const [dashData, issuesData] = await Promise.all([ financeApi('/api/admin/finance/travel-dashboard'), financeApi('/api/admin/finance/booking-issues?status=open'), ]); const bookings = dashData.bookings || []; const sc = dashData.status_counts || {}; const deadlines = dashData.deadlines || []; const issues = issuesData.issues || []; const total = dashData.total || 0; const statusLabels = { hotel_not_booked: { label: 'Hotel Not Booked', color: 'red' }, processing: { label: 'Processing', color: 'amber' }, checking_with_nanny: { label: 'Checking with Nanny', color: 'blue' }, waiting_for_payment: { label: 'Waiting for Payment', color: 'purple' }, completed: { label: 'Completed', color: 'emerald' }, }; // Load accommodation rate let currentAccomRate = dashData.thb_rate ? 1100 : 1100; try { const rateData = await financeApi('/api/admin/finance/accommodation-rate'); currentAccomRate = rateData.rate || 1100; } catch {} let html = `

Bookings Dashboard

Manage family bookings and nanny travel logistics

Accommodation Rate: THB/night

${total}

Total Bookings

`; for (const [key, cfg] of Object.entries(statusLabels)) { const cnt = sc[key] || 0; if (key === 'completed') continue; // show completed separately html += `

${cnt}

${cfg.label}

`; } html += `

${sc.completed || 0}

Completed

`; // Payment Deadlines if (deadlines.length > 0) { html += `

Payment Deadlines

`; for (const dl of deadlines) { const dateStr = new Date(dl.date).toLocaleDateString('en-GB', {day:'numeric',month:'short',year:'numeric'}); html += `
${dateStr}
${fmtTHB(dl.total_thb)}
${dl.bookings.length} nann${dl.bookings.length === 1 ? 'y' : 'ies'}
${dl.bookings.slice(0, 3).map(b => `${b}`).join('')}${dl.bookings.length > 3 ? `+${dl.bookings.length - 3}` : ''}
`; } html += '
'; } // Open Issues html += `

Open Issues / Current Status ${issues.length} open

`; if (issues.length === 0) { html += '

No open issues

'; } else { html += '
'; for (const issue of issues) { html += `
${issue.booking_name || '-'} ${issue.status} ${issue.description || '-'} ${new Date(issue.created_at).toLocaleDateString('en-GB', {day:'numeric',month:'short'})}
`; } html += '
'; } html += '
'; // Bookings List (grouped by month) const byMonth = {}; for (const b of bookings) { const month = b.start_date ? new Date(b.start_date).toLocaleDateString('en-US', {month:'long', year:'numeric'}) : 'No Date'; if (!byMonth[month]) byMonth[month] = []; byMonth[month].push(b); } html += '
'; for (const [month, bks] of Object.entries(byMonth)) { html += `
${month} ${bks.length} bookings
`; for (const b of bks) { const locations = typeof b.locations === 'string' ? JSON.parse(b.locations) : (b.locations || []); const travelLocs = locations.filter(l => l.is_travel); const accomSt = b.accom_status || 'hotel_not_booked'; const stCfg = statusLabels[accomSt] || statusLabels.hotel_not_booked; const optLabel = (b.signed_option || b.option_chosen) === 'self_managed' ? 'Self' : (b.signed_option || b.option_chosen) === 'platform_managed' ? 'Platform' : '-'; html += `
${b.booking_name || b.record_id} ${stCfg.label}
Nanny: ${b.nanny_name || '-'} Family: ${b.family_name || '-'} Option: ${optLabel}
${travelLocs.length > 0 ? `
${locations.map(loc => { const isTr = loc.is_travel; const n = (loc.start_date && loc.end_date) ? Math.ceil((new Date(loc.end_date) - new Date(loc.start_date)) / (1000*60*60*24)) : '?'; return `${loc.destination || loc.hotel_name || '-'} (${n}n)`; }).join('')}
` : ''}
`; } html += '
'; } html += '
'; c.innerHTML = html; } catch (err) { if (err.message?.includes('expired') || err.message?.includes('PIN required')) { financeToken = null; sessionStorage.removeItem('financeToken'); financeCheckPin(); return; } c.innerHTML = `

Failed: ${err.message}

`; } } // ========== SUB-TAB 2: HOTELS ========== async function loadTravelHotels() { const c = document.getElementById('travel-sub-hotels-content'); c.innerHTML = '

Loading hotels...

'; try { const data = await financeApi('/api/admin/finance/hotels'); const hotels = data.hotels || []; // Group by location const byLoc = {}; for (const h of hotels) { const loc = h.location || 'Other'; if (!byLoc[loc]) byLoc[loc] = []; byLoc[loc].push(h); } let html = `

Hotel Directory

${hotels.length} hotels across ${Object.keys(byLoc).length} locations

`; html += '
'; for (const [loc, locHotels] of Object.entries(byLoc).sort()) { html += `
${loc} ${locHotels.length} hotel${locHotels.length > 1 ? 's' : ''}
`; for (const h of locHotels) { const pairings = typeof h.pairings === 'string' ? JSON.parse(h.pairings) : (h.pairings || []); html += `
${h.hotel_name} ${h.hotel_area ? `${h.hotel_area}` : ''}
${h.contact_phone ? `Tel: ${h.contact_phone}` : ''} ${h.price_per_night ? `Price: ${fmtTHB(parseFloat(h.price_per_night))}/night` : ''} ${h.motorbike_price ? `Bike: ${fmtTHB(parseFloat(h.motorbike_price))}` : ''}
${h.cancellation_policy ? `

${h.cancellation_policy}

` : ''} ${h.notes ? `

${h.notes}

` : ''} ${pairings.length > 0 ? `
${pairings.map(p => `
Family Hotel: ${p.family_hotel || '-'} ${p.family_area ? `(${p.family_area})` : ''} ${p.distance ? ` - ${p.distance}` : ''}
`).join('')}
` : ''}
${h.stripe_link ? `Stripe` : ''} ${h.map_link ? `Map` : ''}
`; } html += '
'; } html += '
'; c.innerHTML = html; } catch (err) { if (err.message?.includes('expired') || err.message?.includes('PIN required')) { financeToken = null; sessionStorage.removeItem('financeToken'); financeCheckPin(); return; } c.innerHTML = `

Failed: ${err.message}

`; } } // ========== SUB-TAB 3: AGREEMENTS ========== async function loadTravelAgreements() { const c = document.getElementById('travel-sub-agreements-content'); c.innerHTML = '

Loading agreements...

'; try { const data = await financeApi('/api/admin/finance/travel-bookings'); const bookings = data.bookings || []; let html = `

Nanny Agreements

Create and manage travel agreements

`; html += '
'; for (const b of bookings) { const agStatus = b.agreement_status || null; const optLabel = (b.signed_option || b.option_chosen) === 'self_managed' ? 'Self-Managed' : (b.signed_option || b.option_chosen) === 'platform_managed' ? 'Platform-Managed' : null; const calc = b.calculated || {}; let badge = ''; if (agStatus === 'signed') { badge = `Signed`; } else if (agStatus === 'sent') { badge = `Sent`; } else { badge = `Draft`; } html += `
${b.booking_name || b.record_id} ${badge}
${agStatus === 'signed' && b.contract_signed_at ? `Signed ${new Date(b.contract_signed_at).toLocaleDateString('en-GB', {day:'numeric',month:'short',year:'numeric'})}` : ''}
Nanny: ${b.nanny_name || '-'}
Family: ${b.family_name || '-'}
${optLabel ? `
Accommodation Choice: ${optLabel}
` : ''} ${calc.total_thb ? `
Travel Expense: ${fmtTHB(calc.total_thb)}
` : ''} ${calc.travel_nights ? `
Travel Nights: ${calc.travel_nights}
` : ''}
${agStatus === 'signed' ? '' : agStatus === 'sent' ? ` ` : ` `}
`; } html += '
'; c.innerHTML = html; } catch (err) { if (err.message?.includes('expired') || err.message?.includes('PIN required')) { financeToken = null; sessionStorage.removeItem('financeToken'); financeCheckPin(); return; } c.innerHTML = `

Failed: ${err.message}

`; } } // ========== TRAVEL HELPER FUNCTIONS ========== function showAddTagForm(bookingId) { const el = document.getElementById('tag-form-' + bookingId); el.classList.toggle('hidden'); if (!el.classList.contains('hidden')) { document.getElementById('tag-input-' + bookingId).focus(); } } async function addBookingTag(bookingId) { const input = document.getElementById('tag-input-' + bookingId); const tag = input.value.trim(); if (!tag) return; try { await financeApi('/api/admin/finance/booking-tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ booking_record_id: bookingId, tag }), }); travelSubTab(currentTravelSubTab); } catch (err) { alert('Failed to add tag: ' + err.message); } } async function removeBookingTag(tagId, bookingId) { try { await financeApi('/api/admin/finance/booking-tags/' + tagId, { method: 'DELETE' }); travelSubTab(currentTravelSubTab); } catch (err) { alert('Failed to remove tag: ' + err.message); } } async function addQuickTags(bookingId) { try { await financeApi('/api/admin/finance/booking-tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ booking_record_id: bookingId, tag: 'Transpo & Accom' }) }); await financeApi('/api/admin/finance/booking-tags', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ booking_record_id: bookingId, tag: 'Added to BASE44' }) }); travelSubTab(currentTravelSubTab); } catch (err) { alert('Failed: ' + err.message); } } async function sendAgreement(bookingId) { if (!confirm('Send travel accommodation agreement via WhatsApp to the nanny?')) return; try { const result = await financeApi('/api/admin/finance/send-agreement', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ booking_record_id: bookingId }) }); alert('Agreement sent! WhatsApp SID: ' + (result.whatsapp_sid || 'sent')); travelSubTab(currentTravelSubTab); } catch (err) { alert('Failed to send agreement: ' + err.message); } } async function setTravelOption(bookingId, option) { if (!option) return; try { await financeApi('/api/admin/finance/travel-agreements', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ booking_record_id: bookingId, option_chosen: option }) }); travelSubTab(currentTravelSubTab); } catch (err) { alert('Failed: ' + err.message); } } async function updateAccomRate() { const input = document.getElementById('accom-rate-input'); const rate = parseFloat(input.value); if (!rate || rate <= 0) { alert('Please enter a valid rate'); return; } try { await financeApi('/api/admin/finance/accommodation-rate', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rate }) }); alert(`Accommodation rate updated to ${rate.toLocaleString()} THB/night`); } catch (err) { alert('Failed: ' + err.message); } } async function updateAccomStatus(bookingId, status) { try { await financeApi('/api/admin/finance/accom-status', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ booking_record_id: bookingId, status }) }); } catch (err) { alert('Failed: ' + err.message); } } function showAddHotelForm() { document.getElementById('add-hotel-form').classList.toggle('hidden'); } async function saveHotel() { const name = document.getElementById('h-name').value.trim(); const location = document.getElementById('h-location').value.trim(); if (!name || !location) { alert('Hotel name and location are required'); return; } const pairings = []; const famHotel = document.getElementById('h-fam-hotel').value.trim(); if (famHotel) { pairings.push({ family_hotel: famHotel, family_area: document.getElementById('h-fam-area').value.trim(), distance: document.getElementById('h-fam-dist').value.trim() }); } try { await financeApi('/api/admin/finance/hotels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hotel_name: name, location, hotel_area: document.getElementById('h-area').value.trim(), contact_phone: document.getElementById('h-phone').value.trim(), price_per_night: document.getElementById('h-price').value || null, motorbike_price: document.getElementById('h-motorbike').value || null, stripe_link: document.getElementById('h-stripe').value.trim(), map_link: document.getElementById('h-map').value.trim(), cancellation_policy: document.getElementById('h-cancel').value.trim(), notes: document.getElementById('h-notes').value.trim(), pairings, }), }); loadTravelHotels(); } catch (err) { alert('Failed: ' + err.message); } } async function deleteHotel(id) { if (!confirm('Delete this hotel?')) return; try { await financeApi('/api/admin/finance/hotels/' + id, { method: 'DELETE' }); loadTravelHotels(); } catch (err) { alert('Failed: ' + err.message); } } function filterHotels() { const q = document.getElementById('hotel-search').value.toLowerCase(); document.querySelectorAll('.hotel-group').forEach(g => { const locMatch = g.dataset.location.includes(q); let anyCard = false; g.querySelectorAll('.hotel-card').forEach(c => { const match = locMatch || c.dataset.name.includes(q); c.style.display = match ? '' : 'none'; if (match) anyCard = true; }); g.style.display = (locMatch || anyCard) ? '' : 'none'; if (q && anyCard) g.open = true; }); } function filterAgreements() { const q = document.getElementById('agreement-search').value.toLowerCase(); document.querySelectorAll('.agreement-card').forEach(c => { c.style.display = c.dataset.search.includes(q) ? '' : 'none'; }); } function showCreateIssueForm() { document.getElementById('create-issue-form').classList.toggle('hidden'); } async function createIssue() { const bookingInput = document.getElementById('issue-booking-id').value.trim().replace('#', ''); const desc = document.getElementById('issue-description').value.trim(); if (!bookingInput || !desc) { alert('Booking # and description required'); return; } // Find booking record_id by name try { const data = await financeApi('/api/admin/finance/travel-dashboard'); const match = (data.bookings || []).find(b => (b.booking_name || '').includes(bookingInput) || b.record_id === bookingInput); if (!match) { alert('Booking not found: ' + bookingInput); return; } await financeApi('/api/admin/finance/booking-issues', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ booking_record_id: match.record_id, description: desc }) }); document.getElementById('issue-booking-id').value = ''; document.getElementById('issue-description').value = ''; document.getElementById('create-issue-form').classList.add('hidden'); loadTravelDashboard(); } catch (err) { alert('Failed: ' + err.message); } } async function closeIssue(id) { try { await financeApi('/api/admin/finance/booking-issues/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'closed' }) }); loadTravelDashboard(); } catch (err) { alert('Failed: ' + err.message); } } // --- Init (after all definitions) --- if (location.hash) { const page = location.hash.slice(1); if (document.getElementById('page-' + page)) navigate(page); }