/* ============================================================================
MOWDEW — Reschedule negotiation screens (shared by web + mobile)
NegotiationCard (viewer-aware, every state) · ReschedQueue (owner inbox,
desktop + mobile) · HomeReschedStatus (homeowner thread + respond) ·
NotifScreenM (mobile notifications) · OwnerBellMenu (desktop popover).
Load AFTER mowdew-kit, mowdew-owner-data, mowdew-resched.
========================================================================= */
const { useState: rsState } = React;
/* local vertical pill (MVert lives in the mobile bundle only) */
function RVert({ vert }) {
const v = VERT(vert);
return (
{v.name}
);
}
/* ─── status bars for resolved / waiting states ─────────────────────── */
function ReschedStatusBar({ state, text }) {
const m = {
approved: { bg: 'var(--green-50)', fg: 'var(--green-800)', icon: 'check-circle' },
declined: { bg: 'var(--alert-50)', fg: 'var(--alert-700)', icon: 'x' },
cancelled: { bg: 'var(--ink-50)', fg: 'var(--ink-600,#6E6B60)', icon: 'x' },
waiting: { bg: 'var(--yellow-50)', fg: 'var(--yellow-700)', icon: 'clock' },
}[state] || { bg: 'var(--ink-50)', fg: 'var(--ink-700)', icon: 'clock' };
return (
{text}
);
}
/* ============================================================================
NEGOTIATION CARD — the reusable thread card, all states, both viewers
========================================================================= */
function NegotiationCard({ req, viewer = 'owner', onAction, compact = false, hideHeader = false }) {
const c = CUSTOMERS.find(x => x.id === req.cust);
const w = WORKERS.find(x => x.id === req.worker);
const first = c.name.split(' ')[0];
const latest = reqLatestTo(req);
const names = viewer === 'owner' ? { owner: 'You', homeowner: first } : { owner: 'Diego', homeowner: 'You' };
const toMeta = req.state === 'approved' ? { label: 'CONFIRMED', tone: 'green' }
: req.state === 'declined' ? { label: 'ASKED', tone: 'alert' }
: req.state === 'proposed' ? { label: viewer === 'owner' ? 'YOU SUGGESTED' : 'DIEGO SUGGESTED', tone: 'green' }
: { label: 'REQUESTED', tone: 'green' };
const btn = (st = {}) => ({ minHeight: compact ? 42 : 44, fontSize: compact ? 13.5 : 14, padding: '10px 14px', ...st });
return (
{/* header */}
{!hideHeader && (viewer === 'owner' ? (
{c.name}
{req.rounds > 1 && ROUND {req.rounds} }
{c.addr} · {req.age}
) : (
Marta Lawn & Pool
Pool service · Diego decides, you'll hear right here
))}
{/* assigned pro — always visible, updates live after a reassign */}
{viewer === 'owner' && !hideHeader && (
{w.name} {w.you ? ' · you' : ''} · {w.role}
{req.state === 'pending' && (
onAction('reassign')} style={{ marginLeft: 'auto', flexShrink: 0, border: 'none', background: 'none', color: 'var(--green-700)', fontWeight: 700, fontSize: 12, cursor: 'pointer', padding: '4px 2px', display: 'inline-flex', alignItems: 'center', gap: 4 }}>
Change pro
)}
)}
{/* time change */}
t.to).to : latest} toLabel={toMeta.label} toTone={toMeta.tone} compact={compact} />
{/* thread */}
{/* actions / status */}
{viewer === 'owner' && req.state === 'pending' && (
onAction('decline')} className="btn btn-secondary" style={btn({ flex: 1, color: 'var(--alert-700)' })}>Decline
onAction('propose')} className="btn btn-secondary" style={btn({ flex: 1.2 })}> Propose time
onAction('approve')} className="btn btn-primary" style={btn({ flex: 1.4 })}> Approve
)}
{viewer === 'owner' && req.state === 'proposed' && (
)}
{viewer === 'homeowner' && req.state === 'proposed' && (
onAction('accept')} className="btn btn-primary btn-block" style={btn()}> Accept {latest}
onAction('counter')} className="btn btn-secondary" style={btn({ flex: 1.4 })}> Propose another
onAction('declineProposal')} className="btn btn-secondary" style={btn({ flex: 1, color: 'var(--alert-700)' })}>Decline
onAction('cancel')} className="btn btn-ghost" style={btn({ minHeight: 34, fontSize: 12.5, color: 'var(--ink-500)' })}>Cancel my request
)}
{viewer === 'homeowner' && req.state === 'pending' && (
onAction('cancel')} className="btn btn-ghost" style={btn({ minHeight: 36, fontSize: 12.5, color: 'var(--ink-500)' })}>Cancel my request
)}
{req.state === 'approved' && (
viewer === 'homeowner' ? (
onAction('calendar')} className="btn btn-secondary btn-block" style={btn()}> Add to my calendar
) : (
)
)}
{req.state === 'declined' && (
viewer === 'homeowner' ? (
t.kind === 'decline') || {}).note || ''}"`} />
onAction('message')} className="btn btn-secondary btn-block" style={btn()}> Message Diego
) : (
)
)}
{req.state === 'cancelled' && (
)}
);
}
/* ============================================================================
OWNER QUEUE — filterable inbox (desktop + mobile), dialogs included
========================================================================= */
function ReschedQueue({ go, toast, compact = false }) {
const [reqs, setReqs] = rsState(() => RESCHED_REQS.map(q => ({ ...q, thread: [...q.thread] })));
const [filter, setFilter] = rsState('needs');
const [dialog, setDialog] = rsState(null); // { type, id }
const counts = {
needs: reqs.filter(reqNeedsOwner).length,
waiting: reqs.filter(reqWaiting).length,
done: reqs.filter(reqResolved).length,
};
const list = reqs.filter(q => filter === 'needs' ? reqNeedsOwner(q) : filter === 'waiting' ? reqWaiting(q) : reqResolved(q));
const upd = (id, fn) => setReqs(rs => rs.map(q => q.id === id ? fn({ ...q, thread: [...q.thread] }) : q));
const custOf = (id) => CUSTOMERS.find(x => x.id === reqs.find(q => q.id === id).cust);
const act = (q, type) => {
const c = CUSTOMERS.find(x => x.id === q.cust);
const w = WORKERS.find(x => x.id === q.worker);
if (type === 'approve') {
upd(q.id, x => ({ ...x, state: 'approved', final: reqLatestTo(x), thread: [...x.thread, { by: 'owner', kind: 'approve', to: reqLatestTo(x), at: 'Just now' }] }));
toast(`Approved · ${w.name.split(' ')[0]}'s route updated — ${c.name.split(' ')[0]} notified`, 'green');
} else setDialog({ type, id: q.id });
};
const doDecline = (reason) => {
const q = reqs.find(x => x.id === dialog.id); const c = custOf(dialog.id);
upd(q.id, x => ({ ...x, state: 'declined', thread: [...x.thread, { by: 'owner', kind: 'decline', note: reason, at: 'Just now' }] }));
setDialog(null); toast(`Declined — ${c.name.split(' ')[0]} keeps the original time`, 'blue');
};
const doPropose = (time, note) => {
const q = reqs.find(x => x.id === dialog.id); const c = custOf(dialog.id);
upd(q.id, x => ({ ...x, state: 'proposed', thread: [...x.thread, { by: 'owner', kind: 'propose', to: time, note: note || undefined, at: 'Just now' }] }));
setDialog(null); toast(`Suggested ${time} — ${c.name.split(' ')[0]} can accept or counter`, 'green');
};
const doReassign = (wid, alsoApprove) => {
const q = reqs.find(x => x.id === dialog.id); const c = custOf(dialog.id);
const nw = WORKERS.find(x => x.id === wid);
upd(q.id, x => ({ ...x, worker: wid,
thread: [...x.thread,
{ by: 'owner', kind: 'reassign', toWho: nw.name.split(' ')[0], at: 'Just now' },
...(alsoApprove ? [{ by: 'owner', kind: 'approve', to: reqLatestTo(x), at: 'Just now' }] : [])],
...(alsoApprove ? { state: 'approved', final: reqLatestTo(x) } : {}) }));
setDialog(null);
toast(alsoApprove ? `Approved & moved to ${nw.name.split(' ')[0]} — routes rebalanced` : `${c.name.split(' ')[0]} reassigned to ${nw.name.split(' ')[0]}`, 'green');
};
const dialogReq = dialog && reqs.find(x => x.id === dialog.id);
const dialogCust = dialogReq && CUSTOMERS.find(x => x.id === dialogReq.cust);
const dialogTitle = dialog && {
decline: `Decline ${dialogCust && dialogCust.name.split(' ')[0]}'s request`,
propose: 'Propose a new time',
reassign: 'Reassign to another pro',
}[dialog.type];
const dialogBody = dialog && (
dialog.type === 'decline' ?
: dialog.type === 'propose' ?
:
);
return (
{/* filter chips */}
{[['needs', 'Needs you'], ['waiting', 'Waiting'], ['done', 'History']].map(([id, lb]) => {
const on = filter === id;
return (
setFilter(id)} style={{ flex: compact ? 1 : 'none', padding: compact ? '9px 6px' : '9px 15px', borderRadius: 11, border: '1px solid', borderColor: on ? 'transparent' : 'var(--ink-200)', cursor: 'pointer', fontSize: 13, fontWeight: 700, background: on ? 'var(--ink-900)' : '#fff', color: on ? '#fff' : 'var(--ink-700)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
{lb}{counts[id]}
);
})}
{list.map(q =>
act(q, t)} />)}
{list.length === 0 && (
{filter === 'needs' ? 'Nothing needs you.' : filter === 'waiting' ? 'Nothing in flight.' : 'No resolved requests yet.'}
{filter === 'needs' ? 'New reschedule requests land here first.' : filter === 'waiting' ? 'Suggestions you send wait here for an answer.' : 'Approved, declined and cancelled requests file here.'}
)}
{/* dialogs — Sheet on mobile, Modal on web */}
{compact ? (
setDialog(null)} title={dialogTitle}>{dialogBody}
) : (
setDialog(null)} title={dialogTitle}>{dialogBody}
)}
);
}
/* ============================================================================
HOMEOWNER — negotiation status / thread screen (h_resched)
========================================================================= */
function HomeReschedStatus({ go, toast, params }) {
const fresh = params && params.fresh;
const [req, setReq] = rsState(() => fresh
? { id: 'qx', cust: 'c2', worker: 'w1', state: 'pending', age: 'Just now', orig: 'Mon, May 18 · 10 AM',
thread: [{ by: 'homeowner', kind: 'request', to: params.to, note: params.note || undefined, at: 'Just now' }] }
: (() => { const q = RESCHED_REQS[0]; return { ...q, thread: [...q.thread] }; })());
const [sheet, setSheet] = rsState(null); // 'counter' | 'decline'
const latest = reqLatestTo(req);
const turn = (kind, extra = {}) => ({ by: 'homeowner', kind, at: 'Just now', ...extra });
const onAction = (type) => {
if (type === 'accept') {
setReq(r => ({ ...r, state: 'approved', final: latest, thread: [...r.thread, { by: 'owner', kind: 'approve', to: latest, at: 'Just now' }] }));
toast(`Confirmed — see you ${latest}`, 'green');
} else if (type === 'counter') setSheet('counter');
else if (type === 'declineProposal') setSheet('decline');
else if (type === 'cancel') {
setReq(r => ({ ...r, state: 'cancelled', thread: [...r.thread, turn('cancel', { note: 'Cancelled from the app.' })] }));
toast('Request cancelled — your original time stands', 'blue');
} else if (type === 'calendar') toast('Added to your calendar', 'green');
else if (type === 'message') go('h_request');
};
const doCounter = (time, note) => {
setReq(r => ({ ...r, state: 'pending', rounds: (r.rounds || 1) + 1, thread: [...r.thread, turn('counter', { to: time, note: note || undefined })] }));
setSheet(null); toast(`Sent — Diego will respond to ${time}`, 'green');
};
const doDeclineProposal = (reason) => {
setReq(r => ({ ...r, state: 'declined', thread: [...r.thread, turn('decline', { note: reason })] }));
setSheet(null); toast('Okay — your original time stands', 'blue');
};
return (
<>
go('h_feed')} />
{req.state === 'approved' && (
All set
Your pool visit moved to {req.final}.
Diego's route is updated — no need to do anything else.
)}
Every change lands on the route automatically — nothing to text about.
setSheet(null)} title="Propose another time">
setSheet(null)} title="Decline Diego's suggestion">
>
);
}
/* ============================================================================
NOTIFICATIONS — mobile screen (all personas) + desktop bell popover
========================================================================= */
function NotifScreenM({ persona, go, toast }) {
const back = persona === 'owner' ? 'o_home' : persona === 'pro' ? 'pro_today' : 'h_feed';
const openLink = (n) => {
const map = persona === 'owner'
? { resched: ['o_inbox', { tab: 'approvals' }], billing: ['o_billing_run', {}] }
: persona === 'homeowner'
? { resched: ['h_resched', {}], invoice: ['h_inbox', {}], feed: ['h_feed', {}] }
: { today: ['pro_today', {}] };
const t = map[n.link];
if (t) go(t[0], t[1]);
};
return (
go(back)} />
);
}
/* desktop owner — bell + anchored popover in the shell header */
function OwnerBellMenu({ go }) {
const [open, setOpen] = rsState(false);
const [, force] = rsState(0);
const count = notifUnreadCount('owner');
const openLink = (n) => {
setOpen(false); force(x => x + 1);
go(n.link === 'billing' ? 'billingrun' : 'reschedules');
};
return (
setOpen(o => !o)} />
{open && (
<>
setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 70 }} />
>
)}
);
}
Object.assign(window, { RVert, ReschedStatusBar, NegotiationCard, ReschedQueue, HomeReschedStatus, NotifScreenM, OwnerBellMenu });