/* ============================================================================
MOWDEW OWNER — Mobile app (on-the-go god mode)
The owner's pocket control: see the money, nudge who owes, approve
reschedules (route auto-updates), message customers, manage the book.
Reuses owner data (BIZ, WORKERS, CUSTOMERS, RESCHEDULES, THREADS, PLANS).
Screens: o_home · o_money · o_inbox · o_customers · o_customer · o_thread
========================================================================= */
const { useState: omState } = React;
/* small vertical pill (garden green / pool aqua) */
function MVert({ vert }) {
const v = VERT(vert);
return (
{v.name}
);
}
/* rich photo-backed stat card (bespoke imagery behind the number) */
function RichStat({ scene, eyebrow, value, sub, badge, badgeTone = 'mint', onClick, tall }) {
const badgeBg = { mint: 'var(--green-500)', alert: 'var(--alert-500)', sky: 'var(--blue-500)', aqua: 'var(--aqua-500)' }[badgeTone] || 'var(--green-500)';
return (
);
}
/* the owner pulse — one dark statement band (money is the hero) + a calm
white ops pair. Same visual language as the homeowner's ledger band. */
function OwnerStatBand({ collected, collectedSub, owed, owedSub, onOwed, visitsValue, visitsSub, visitsLive = false, onVisits, homesValue, homesSub, onHomes }) {
const billed = collected + owed;
const pct = Math.round((collected / billed) * 100);
return (
COLLECTED · MAY
{fmtMoney(collected)}
{collectedSub}
0 ? onOwed : undefined} style={{ flex: 1, minWidth: 0, paddingLeft: 16, borderLeft: '1px solid rgba(255,255,255,.16)', cursor: owed > 0 ? 'pointer' : 'default' }}>
0 ? '#FF9E7D' : 'rgba(255,255,255,.6)' }}>OUTSTANDING {owed > 0 && }
0 ? '#FFB59A' : '#fff' }}>{fmtMoney(owed)}
{owedSub}
{pct}% COLLECTED BILLED {fmtMoney(billed)}
{[
{ icon: 'route', bg: 'var(--blue-500)', eyebrow: 'VISITS TODAY', value: visitsValue, sub: visitsSub, live: visitsLive, onClick: onVisits },
{ icon: 'home', bg: 'var(--aqua-500)', eyebrow: 'ACTIVE HOMES', value: homesValue, sub: homesSub, onClick: onHomes },
].map(c => (
{c.eyebrow}
{c.live && }
{c.value}
{c.sub}
))}
);
}
/* the "all-clear" celebratory hero — shows when the book is fully caught up */
function AllClearHero() {
return (
All clear
You're all caught up.
Everyone's paid, every route is set.
);
}
/* ============================== OWNER · HOME ========================== */
function OwnerHome({ go, openFab }) {
const owing = CUSTOMERS.filter(c => c.balance > 0);
const owed = owing.reduce((s, c) => s + c.balance, 0);
const collected = 184000;
const allClear = owed === 0 && RESCHEDULES.length === 0;
return (
}>
{/* header */}
{BIZ.name}
Good morning, Diego
go('o_notifs')} />
{/* money + ops — rich photo-backed stat cards (bespoke imagery) */}
{allClear ?
: (
0 ? `${owing.length} invoices open` : 'All collected'} onOwed={() => go('o_money')}
visitsValue="9" visitsSub="2 workers out · live" visitsLive onVisits={() => go('o_routes')}
homesValue={String(BIZ.homes)} homesSub="21 garden · 17 pool" onHomes={() => go('o_customers')}
/>
)}
{/* time to bill — owner-confirmed billing action item (hidden at zero) */}
{runPending().length > 0 && (
go('o_billing_run')} />
)}
{/* needs you */}
Needs you
go('o_inbox', { tab: 'approvals' })} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 12, padding: 14, border: 'none', borderBottom: '1px solid var(--ink-100)', background: '#fff', cursor: 'pointer', textAlign: 'left' }}>
Reschedule requests
Approve, suggest a time, or move the stop
{needsOwnerCount()}
go('o_money')} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 12, padding: 14, border: 'none', borderBottom: '1px solid var(--ink-100)', background: '#fff', cursor: 'pointer', textAlign: 'left' }}>
Unpaid invoices
{fmtMoney(owed)} outstanding · tap to nudge
{owing.length}
go('o_inbox', { tab: 'messages' })} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 12, padding: 14, border: 'none', background: '#fff', cursor: 'pointer', textAlign: 'left' }}>
2
{/* G6/G5 — field notes + photo'd requests (union: present in the app, now in the design too) */}
go('o_inbox', { tab: 'notes' })} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 12, padding: 14, border: 'none', borderTop: '1px solid var(--ink-100)', background: '#fff', cursor: 'pointer', textAlign: 'left' }}>
Field notes from the crew
Flags & requests from the field
3
go('o_requests')} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 12, padding: 14, border: 'none', borderTop: '1px solid var(--ink-100)', background: '#fff', cursor: 'pointer', textAlign: 'left' }}>
Requests to quote
Photos in — turn them into priced quotes
2
{/* today's routes */}
go('o_routes')} className="btn btn-ghost" style={{ minHeight: 30, padding: '5px 9px', fontSize: 12.5 }}>Dispatch & team }>Today's routes
{WORKERS.map(w => {
const doneN = w.id === 'w1' ? 2 : 0;
return (
go('o_routes', { worker: w.id })} className="card" style={{ padding: 13, cursor: 'pointer' }}>
{w.name}{w.you && · you }
{w.role}
{w.id === 'w1' ? 'On route' : 'Starts 11:00'}
{Array.from({ length: w.today }).map((_, i) => (
))}
{doneN}/{w.today}
);
})}
);
}
/* ============================== OWNER · MONEY ========================= */
function OwnerMoneyM({ go, openFab, toast }) {
const [nudged, setNudged] = omState({});
const owing = CUSTOMERS.filter(c => c.balance > 0);
const paid = CUSTOMERS.filter(c => c.balance === 0);
const owed = owing.reduce((s, c) => s + c.balance, 0);
const nudge = (c) => { setNudged(n => ({ ...n, [c.id]: true })); toast(`Nudge sent to ${c.name.split(' ')[0]}`, 'green'); };
return (
}>
{/* time to bill — appears whenever ≥1 customer is due (hidden at zero) */}
{runPending().length > 0 && (
go('o_billing_run')} />
)}
{/* summary */}
Collected · May
{fmtMoney(184000)}
{/* owes you */}
Owes you
{owing.map(c => (
{c.name}
{c.addr}, {c.city} · {c.dueLabel}
go('o_customer', { id: c.id })} className="btn btn-secondary" style={{ flex: 1, minHeight: 42 }}>View
nudge(c)} disabled={nudged[c.id]} className={nudged[c.id] ? 'btn btn-secondary' : 'btn btn-primary'} style={{ flex: 1.3, minHeight: 42 }}> {nudged[c.id] ? 'Nudged' : 'Send nudge'}
))}
{/* paid up */}
Paid up
{paid.slice(0, 5).map((c, i) => (
))}
);
}
/* ============================== OWNER · INBOX ========================= */
function OwnerInbox({ go, openFab, toast, params }) {
const [tab, setTab] = omState(params.tab || 'approvals');
const [resolved, setResolved] = omState({});
const act = (r, ok) => {
setResolved(s => ({ ...s, [r.id]: ok ? 'approved' : 'declined' }));
const w = WORKERS.find(x => x.id === r.worker);
if (ok) { toast(`Approved · ${w.name.split(' ')[0]}'s route updated`, 'green'); }
};
const declineMsg = (r) => {
const c = CUSTOMERS.find(x => x.id === r.cust);
const first = c.name.split(' ')[0];
const when = r.to === 'skip' ? 'skip this week' : `move to ${r.to}`;
const pre = `Hi ${first}, thanks for the heads up! I'm not able to ${when} this time — could we keep your regular ${r.from} visit? Happy to find another day that works for you. — Diego`;
setResolved(s => ({ ...s, [r.id]: 'declined' }));
go('o_thread', { id: r.cust, prefill: pre });
};
const threadIds = Object.keys(THREADS);
return (
}>
{/* segmented */}
{[['approvals', 'Approvals', needsOwnerCount()], ['messages', 'Messages', 2]].map(([id, lb, n]) => (
setTab(id)} style={{ flex: 1, padding: '10px 6px', borderRadius: 10, border: 'none', cursor: 'pointer', fontSize: 13.5, fontWeight: 700, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 7, background: tab === id ? '#fff' : 'transparent', color: tab === id ? 'var(--ink-900)' : 'var(--ink-500)', boxShadow: tab === id ? 'var(--shadow-sm)' : 'none' }}>
{lb}{n}
))}
{tab === 'approvals' ? (
go('o_requests')} className="card" style={{ padding: 13, display: 'flex', alignItems: 'center', gap: 11, cursor: 'pointer', borderColor: 'var(--aqua-100)', marginBottom: 12 }}>
Requests & quotes
1 photo to quote · 1 awaiting approval
) : (
{threadIds.concat(CUSTOMERS.filter(c => !threadIds.includes(c.id)).slice(0, 3).map(c => c.id)).map(id => {
const c = CUSTOMERS.find(x => x.id === id);
const last = (THREADS[id] || [])[(THREADS[id] || []).length - 1];
return (
go('o_thread', { id })} className="card" style={{ padding: 13, display: 'flex', alignItems: 'center', gap: 11, cursor: 'pointer' }}>
{c.name}
{last ? last.t : 'Start a conversation'}
);
})}
)}
);
}
/* ============================== OWNER · THREAD ======================= */
function OwnerThread({ go, params }) {
const c = CUSTOMERS.find(x => x.id === params.id) || CUSTOMERS[1];
const [extra, setExtra] = omState([]);
const [draft, setDraft] = omState('');
const msgs = [...(THREADS[c.id] || THREADS.c2), ...extra];
const send = () => { if (!draft.trim()) return; setExtra(e => [...e, { from: 'owner', t: draft, at: 'now' }]); setDraft(''); };
return (
setDraft(e.target.value)} onKeyDown={e => e.key === 'Enter' && send()} placeholder="Write a reply…" style={{ flex: 1, padding: '12px 14px', border: '1px solid var(--ink-200)', borderRadius: 12, fontFamily: 'var(--font-body)', fontSize: 14, outline: 'none' }} />
}>
go('o_inbox', { tab: 'messages' })} right={ go('o_customer', { id: c.id })} style={{ width: 40, height: 40, borderRadius: 12, border: '1px solid var(--ink-100)', background: '#fff', display: 'grid', placeItems: 'center', cursor: 'pointer', color: 'var(--ink-700)' }}> } />
{msgs.map((m, i) => {
if (m.from === 'sys') return
{m.t}
;
const me = m.from === 'owner';
return (
);
})}
);
}
/* ============================== OWNER · CUSTOMERS ===================== */
function OwnerCustomersM({ go, openFab }) {
const [q, setQ] = omState('');
const [vf, setVf] = omState('all');
const rows = CUSTOMERS.filter(c => (vf === 'all' || c.vert === vf) && (c.name.toLowerCase().includes(q.toLowerCase()) || c.addr.toLowerCase().includes(q.toLowerCase())));
return (
}>
go('o_new_customer')} style={{ width: 42, height: 42, borderRadius: 12, border: 'none', background: 'var(--green-700)', display: 'grid', placeItems: 'center', cursor: 'pointer', color: '#fff', boxShadow: 'var(--shadow-green)' }}> } />
{/* search */}
setQ(e.target.value)} className="input" placeholder="Search name or address" style={{ paddingLeft: 44 }} />
{/* filter */}
{[['all', 'All'], ['garden', 'Garden'], ['pool', 'Pool']].map(([id, lb]) => (
setVf(id)} style={{ padding: '8px 15px', borderRadius: 10, border: '1px solid', borderColor: vf === id ? 'transparent' : 'var(--ink-200)', cursor: 'pointer', fontSize: 13, fontWeight: 700, background: vf === id ? 'var(--ink-900)' : '#fff', color: vf === id ? '#fff' : 'var(--ink-700)' }}>{lb}
))}
{rows.map(c => {
const w = custWorker(c);
return (
go('o_customer', { id: c.id })} className="card" style={{ padding: 13, display: 'flex', alignItems: 'center', gap: 12, cursor: 'pointer' }}>
{c.name}
{c.addr} · {w.name.split(' ')[0]}
);
})}
{rows.length === 0 &&
No customers match.
}
);
}
/* ============================== OWNER · CUSTOMER DETAIL =============== */
function OwnerCustomerM({ go, params, toast }) {
const c = CUSTOMERS.find(x => x.id === params.id) || CUSTOMERS[1];
const [wid, setWid] = omState(c.worker);
const [reassign, setReassign] = omState(false);
const w = WORKERS.find(x => x.id === wid) || custWorker(c);
const v = VERT(c.vert);
const scene = c.vert === 'pool' ? 'pool' : 'lawn';
const visits = [
{ d: 'Mon, May 12', tin: '9:58 AM', tout: '10:22 AM', dur: '24 min' },
{ d: 'Mon, May 5', tin: '10:04 AM', tout: '10:31 AM', dur: '27 min' },
{ d: 'Mon, Apr 28', tin: '9:51 AM', tout: '10:13 AM', dur: '22 min' },
];
const doReassign = (nw) => { setWid(nw.id); setReassign(false); toast(`${c.name.split(' ')[0]} reassigned to ${nw.name.split(' ')[0]} · routes rebalanced`, 'green'); };
return (
go('o_thread', { id: c.id })} className="btn btn-secondary" style={{ flex: 1 }}> Message
{c.balance > 0
? toast(`Nudge sent to ${c.name.split(' ')[0]}`, 'green')} className="btn btn-primary" style={{ flex: 1.3 }}> Send nudge
: go('o_new_invoice', { id: c.id })} className="btn btn-primary" style={{ flex: 1.3 }}> New invoice }
}>
go('o_customers')} />
{/* balance */}
0 ? 'var(--alert-50)' : 'var(--green-50)', borderColor: c.balance > 0 ? '#F8D3C5' : 'var(--green-100)', display: 'flex', alignItems: 'center', gap: 13 }}>
{c.balance > 0 ? 'Outstanding' : 'All settled'}
{c.balance > 0 ?
:
$0.00
}
{/* billing history — why the balance is what it is */}
0 ? 'alert' : 'green'} icon="ledger" style={{ marginBottom: 12 }}>Billing history
{(() => {
const n = c.cadence === 'Weekly' ? 4 : c.cadence === 'Monthly' ? 1 : 2;
const moTotal = c.price * n;
const dueMonths = c.balance > 0 ? Math.max(1, Math.round(c.balance / moTotal)) : 0;
const rows = [
{ m: 'May 2026', v: n, cents: moTotal, status: dueMonths >= 1 ? 'unpaid' : 'paid' },
{ m: 'Apr 2026', v: n, cents: moTotal, status: dueMonths >= 2 ? 'unpaid' : 'paid', on: 'Apr 30' },
{ m: 'Mar 2026', v: c.cadence === 'Weekly' ? 5 : n, cents: c.price * (c.cadence === 'Weekly' ? 5 : n), status: 'paid', on: 'Mar 28' },
];
const runRow = BILLRUN.rows.find(rr => rr.cid === c.id);
return rows.map((r, i) => {
const unpaid = r.status === 'unpaid';
const linked = i === 0 && runRow;
return (
linked && go('o_invoice', { inv: runRow.inv })} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 14px', borderTop: i ? '1px solid var(--ink-100)' : 'none', cursor: linked ? 'pointer' : 'default' }}>
{r.m}
{linked ? `${runRow.inv} · ` : ''}{r.v} visits{unpaid ? ` · due` : r.on ? ` · paid ${r.on} via ${RAIL[c.rail].label}` : ''}
);
});
})()}
{c.balance > 0 && (
{fmtMoney(c.balance)} outstanding across the months above.
toast(`Nudge sent to ${c.name.split(' ')[0]}`, 'green')} className="btn btn-primary" style={{ minHeight: 36, padding: '8px 13px', fontSize: 12.5 }}>Nudge
)}
{c.svc}
{/* pricing & billing schedule — rate, frequency, bill day */}
{/* scheduled work & repairs — approved-in-advance loop */}
{/* pool water chemistry — history + trust signal */}
{c.vert === 'pool' && (
go('o_chem', { id: c.id })} className="card" style={{ marginTop: 10, padding: 13, cursor: 'pointer', borderColor: 'var(--aqua-100)' }}>
Water chemistry
8 visits · CYA trending high
)}
{/* equipment on the record */}
{c.vert === 'pool' && (
go('o_equipment', { id: c.id })} className="card" style={{ marginTop: 10, padding: 13, display: 'flex', alignItems: 'center', gap: 11, cursor: 'pointer', borderColor: 'var(--aqua-100)' }}>
Equipment
5 parts · pump, filter, heater, salt cell, robot
)}
Pros see "{c.label}" — never the name or price.
{/* assigned worker */}
Assigned to
setReassign(true)} className="btn btn-ghost" style={{ minHeight: 36, padding: '7px 11px', fontSize: 12.5 }}>Reassign
{/* reassign sheet */}
setReassign(false)} title="Reassign to a different Pro">
{c.name.split(' ')[0]}'s {v.name.toLowerCase()} service will move to the new Pro's route. Both Pros are notified and routes rebalance automatically.
{WORKERS.map(nw => {
const on = wid === nw.id;
return (
on ? null : doReassign(nw)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '13px 14px', borderRadius: 'var(--radius-lg)', border: on ? '2px solid var(--green-500)' : '1.5px solid var(--ink-100)', background: on ? 'var(--green-50)' : '#fff', cursor: on ? 'default' : 'pointer', textAlign: 'left' }}>
{nw.name}{nw.you ? ' · you' : ''}
{nw.role} · {nw.today} stops today
{on
? Current
: Assign }
);
})}
{/* recent visits */}
Recent verified visits
{visits.map((vi, i) => (
go('h_visit', { id: 'v2' })} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderTop: i ? '1px solid var(--ink-100)' : 'none', cursor: 'pointer' }}>
{vi.d}
{vi.tin}–{vi.tout} · {vi.dur} on-site
))}
);
}
/* ============================== OWNER · FAB SHEET ==================== */
function OwnerFabSheet({ open, onClose, go, toast }) {
const items = [
['plus', 'green', 'Add customer', () => { go('o_new_customer'); }],
['invoice', 'green', 'New invoice', () => { go('o_new_invoice'); }],
['route', 'blue', 'Dispatch routes', () => { go('o_routes'); }],
['nudge', 'alert', 'Nudge all unpaid', () => { go('o_money'); }],
];
return (
{items.map(([ic, tone, label, fn]) => (
{ onClose(); fn(); }} style={{ display: 'flex', alignItems: 'center', gap: 13, padding: '13px 14px', borderRadius: 'var(--radius-lg)', border: '1px solid var(--ink-100)', background: '#fff', cursor: 'pointer', textAlign: 'left' }}>
{label}
))}
);
}
Object.assign(window, { OwnerHome, OwnerMoneyM, OwnerInbox, OwnerThread, OwnerCustomersM, OwnerCustomerM, OwnerFabSheet, MVert });