NanniMoon
Sign in with your phone number
EN
HE
FR
DE
Admin Login
Change number
Verify
Enter the code sent to +66 81 234 5678
Verify
Resend code in 30 s
Resend Code
Didn't receive the code? Check your SMS inbox or try resending.
© 2026 NanniMoon · All rights reserved
Admin Panel
Replacement Request Management
Logout
Admin Login
Sign in with your authorized Google account
Zoho CRM Sync
Sync Nannies
Sync Bookings
Families can use this PIN + their Order ID to access their booking if they can't log in by phone.
Sync & Audit Trail
C-Level Finance Dashboard
Back to dashboard
Cash Flow
Payouts
Cancel
Replace
Flows
Sync
Travel
Manual Inputs
Save & Recalculate
Weekly
Monthly
Stripe Pay
Dashboard
Hotels
Agreements
© 2026 NanniMoon · Admin Portal
`;
}
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 += `
${t('replace.back_to_my_bookings')}
`;
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')}
` : ''}
${t('replace.back_to_my_bookings')}
`;
}
// ============================================================
// 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}
` : ''}
${t('replace.back_to_my_bookings')}
`;
}
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')}
${t('replace.back_to_my_bookings')}
`;
}
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 => `${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')}
${t('war_cancel.location')}
${t('war_cancel.select_location')}
${destOptions}
${t('war_cancel.notes')}
${t('war_cancel.submit_new_dates')}
`;
}
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) ? `
${t('war_cancel.change_selection')}
` : ''}
${t('replace.back_to_my_bookings')}
`;
}
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}
${t('common.try_again')}
`;
}
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 `
${renderNannyDetails(m.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 `
${renderNannyDetails(w.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
`;
// 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 = `
← Back
${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
${nanny.items.map(item => {
const dailyThb = parseFloat(item.daily_rate_thb) || 0;
const totalThb = parseFloat(item.amount_thb) || 0;
const dailyUsd = dailyThb / b.thb_rate;
const calcUsd = parseFloat(item.calculated_amount_usd) || 0;
const finalUsd = parseFloat(item.final_amount_usd) || 0;
return `
${item.booking_name || item.booking_id}
${item.transfer_status === 'success' ? 'PAID ' : ''}
${item.transfer_status === 'failed' ? 'FAILED ' : ''}
Daily rate
${dailyThb.toLocaleString()} THB (${fmtUSD(dailyUsd)})
Days this week
${item.days} days
Rate
1 USD = ${b.thb_rate} THB
${dailyThb.toLocaleString()} x ${item.days} = ${totalThb.toLocaleString()} THB
${fmtUSD(calcUsd)}
${isDraft ? `
Final amount:
USD
` : `Final: ${fmtUSD(finalUsd)} `}
${item.transfer_status === 'failed' ? `
${item.transfer_error || ''}
Retry
` : ''}
${item.admin_note ? `
Note: ${item.admin_note}
` : ''}
`;
}).join('')}
`;
}
html += `
`;
// Confirm button (only for draft)
if (isDraft) {
html += `
Confirm & Pay ${fmtUSD(b.total_usd)}
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 += `
Booking Family Nanny Total Stage
${data.zoho_cancellations.map(z => `
${z.booking_name || z.record_id}
${z.family_name || '-'}
${z.nanny_name || '-'}
${fmtUSD(z.booking_total)}
${z.stage}
`).join('')}
`;
}
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) => `
${step.step}${step.note ? ' — ' + step.note : ''}${step.date ? ' (' + fmtD(step.date) + ')' : ''}
${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.
Refresh Report
${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
Save
`;
for (const [key, cfg] of Object.entries(statusLabels)) {
const cnt = sc[key] || 0;
if (key === 'completed') continue; // show completed separately
html += `
`;
}
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
Create Status
`;
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'})}
Close
`;
}
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('')}
` : ''}
${Object.entries(statusLabels).map(([k, v]) => `${v.label} `).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
+ Add Hotel
Refresh
`;
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} ` : ''}
Delete
${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' ? `
Resend
` : `
Send Contract
Manual set...
Self-Managed
Platform
`}
+ Tags
`;
}
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);
}