Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
33fb27a
Add design spec for earnings analytics + payment tracking
bradburch Jul 12, 2026
7415ee2
Revise earnings analytics spec per subagent review
bradburch Jul 12, 2026
eacf101
Add Payments table and payment repo layer
bradburch Jul 12, 2026
ccf1c83
Add getAnalytics earnings aggregates to repo
bradburch Jul 12, 2026
3f587cb
Add admin payment routes and paidTotal on the bookings list
bradburch Jul 12, 2026
7ffee54
Add admin analytics route with earnings tiles
bradburch Jul 12, 2026
3bb7aa0
Pin seeded customer name literal in analytics tests
bradburch Jul 12, 2026
9beef21
Add payments API client and shared PaymentsPanel
bradburch Jul 12, 2026
b08caca
Guard payment form submit against invalid amount/date
bradburch Jul 12, 2026
dd408a9
Show payment state and payments panel in Bookings section
bradburch Jul 12, 2026
28052ab
Hide zero paid text and gate payments panel on row status
bradburch Jul 12, 2026
f41c848
Add Earnings dashboard section
bradburch Jul 12, 2026
fc8be92
Fix Prettier formatting in pre-existing files
bradburch Jul 12, 2026
85179fd
Guard earnings reload against post-unmount state updates
bradburch Jul 12, 2026
079b7ec
Reset alive ref on mount so StrictMode remount keeps earnings reload …
bradburch Jul 12, 2026
dbf8b5f
Address review follow-ups: payments GET 404, analytics reconcile, que…
bradburch Jul 12, 2026
867b001
Show read-only payments ledger on cancelled bookings; per-action busy…
bradburch Jul 12, 2026
4bb317c
Polish earnings section: empty copy, truncation, chart label overflow
bradburch Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/admin/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ReactNode, useCallback, useEffect, useLayoutEffect, useState } fro
import { adminApi, isAuthExpired, type Customer, type ImportResult } from '../shared-ui/api.js';
import {
IconCalendar,
IconChartBar,
IconClipboardCheck,
IconCode,
IconPaw,
Expand All @@ -14,6 +15,7 @@ import { AppsSection } from './sections/AppsSection';
import { BookingsSection } from './sections/BookingsSection';
import { BusinessSection } from './sections/BusinessSection';
import { ClientsSection } from './sections/ClientsSection';
import { EarningsSection } from './sections/EarningsSection';
import { EmbedSection } from './sections/EmbedSection';
import { PetsSection } from './sections/PetsSection';
import { ServicesSection } from './sections/ServicesSection';
Expand Down Expand Up @@ -120,6 +122,7 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) {

type SectionKey =
| 'bookings'
| 'earnings'
| 'business'
| 'pets'
| 'services'
Expand All @@ -130,6 +133,7 @@ type SectionKey =

const SECTIONS: { key: SectionKey; label: string; icon: typeof IconStore }[] = [
{ key: 'bookings', label: 'Bookings', icon: IconClipboardCheck },
{ key: 'earnings', label: 'Earnings', icon: IconChartBar },
{ key: 'business', label: 'Business', icon: IconStore },
{ key: 'pets', label: 'Pets', icon: IconPaw },
{ key: 'services', label: 'Services & rates', icon: IconTag },
Expand Down Expand Up @@ -402,6 +406,9 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () =>
bookings: (
<BookingsSection session={session} handleError={handle} clearError={() => setError('')} />
),
earnings: (
<EarningsSection session={session} handleError={handle} clearError={() => setError('')} />
),
business: <BusinessSection settings={settings} setSettings={setSettings} />,
pets: <PetsSection settings={settings} setSettings={setSettings} />,
services: (
Expand Down
154 changes: 154 additions & 0 deletions app/admin/PaymentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { adminApi, PAYMENT_METHODS, type Payment } from '../shared-ui/api.js';
import type { Session } from './shared.js';

/** Local 'YYYY-MM-DD' default for the paid-date field (the sitter can change it). */
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}

/**
* One booking's payment ledger: existing payments (each deletable — deleting the record is the
* only correction mechanism) plus the record-payment form. Shared by BookingsSection (rows) and
* EarningsSection (outstanding table); `onChanged` lets each parent re-fetch its own payload.
*/
export function PaymentsPanel({
session,
bookingId,
onChanged,
handleError,
allowRecord = true,
}: {
session: Session;
bookingId: string;
onChanged: () => void | Promise<void>;
handleError: (e: unknown) => void;
/** False for cancelled/declined bookings: the ledger is read-only (delete is the refund-
* correction mechanism), so the record-payment form is hidden. */
allowRecord?: boolean;
}) {
const [payments, setPayments] = useState<Payment[] | null>(null);
const [amount, setAmount] = useState('');
const [method, setMethod] = useState<string>('cash');
const [paidDate, setPaidDate] = useState(todayStr);
const [note, setNote] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const RECORDING = '__record__';

const amountNum = Number(amount);
const canSubmit = Number.isInteger(amountNum) && amountNum >= 1 && paidDate.trim() !== '';

const load = () =>
adminApi.payments
.list(session.slug, session.token, bookingId)
.then(({ payments: list }) => list);

useEffect(() => {
let active = true;
load()
.then((list) => active && setPayments(list))
.catch((e) => active && handleError(e));
return () => {
active = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId, session]);

const record = async () => {
if (busyId) return;
setBusyId(RECORDING);
try {
await adminApi.payments.record(session.slug, session.token, bookingId, {
amount: amountNum,
method,
paidDate,
...(note.trim() ? { note: note.trim() } : {}),
});
setAmount('');
setNote('');
setPaidDate(todayStr());
setPayments(await load());
await onChanged();
} catch (e) {
handleError(e);
} finally {
setBusyId(null);
}
};

const remove = async (paymentId: string) => {
if (busyId) return;
setBusyId(paymentId);
try {
await adminApi.payments.remove(session.slug, session.token, bookingId, paymentId);
setPayments(await load());
await onChanged();
} catch (e) {
handleError(e);
} finally {
setBusyId(null);
}
};

if (!allowRecord && payments !== null && payments.length === 0) return null;

return (
<div className="pb-payments">
{payments === null ? (
<p>Loading…</p>
) : payments.length === 0 ? (
allowRecord && <p className="pb-hint">No payments recorded yet.</p>
) : (
<ul>
{payments.map((p) => (
<li key={p.id}>
<span>
${p.amount} · {p.method} · {p.paidDate}
{p.note ? ` — ${p.note}` : ''}
</span>
<button disabled={busyId === p.id} onClick={() => void remove(p.id)}>
Delete
</button>
</li>
))}
</ul>
)}
{allowRecord && (
<div className="pb-row">
<label className="pb-inline">
Amount ($)
<input
type="number"
min={1}
step={1}
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</label>
<label className="pb-inline">
Method
<select value={method} onChange={(e) => setMethod(e.target.value)}>
{PAYMENT_METHODS.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
</label>
<label className="pb-inline">
Date
<input type="date" value={paidDate} onChange={(e) => setPaidDate(e.target.value)} />
</label>
<label className="pb-inline">
Note
<input value={note} onChange={(e) => setNote(e.target.value)} />
</label>
<button disabled={busyId === RECORDING || !canSubmit} onClick={() => void record()}>
Record payment
</button>
</div>
)}
</div>
);
}
93 changes: 93 additions & 0 deletions app/admin/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,18 @@ body {
margin-top: 4px;
}

/* ── Payments panel (shared by Bookings rows + Earnings outstanding table) ── */
.pb-payments {
width: 100%;
border-top: 1px dashed var(--line);
margin-top: 8px;
padding-top: 8px;
}

.pb-payments .pb-row {
align-items: flex-end;
}

@media (prefers-reduced-motion: reduce) {
.pb-wrap button {
transition: none;
Expand All @@ -551,3 +563,84 @@ body {
font-size: 16px; /* prevents iOS zoom-on-focus */
}
}

/* ── Earnings section ──────────────────────────────────────────── */
.pb-tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 12px;
margin: 16px 0;
}

.pb-tile {
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
}

.pb-tile strong {
font-size: 1.4rem;
color: var(--ink);
}

.pb-tile span {
color: var(--soft);
font-size: 0.85rem;
}

.pb-earnings-chart {
width: 100%;
max-width: 480px;
display: block;
}

.pb-earnings-chart rect {
fill: var(--sage);
}

.pb-earnings-chart text {
fill: var(--soft);
}

.pb-hbars li {
display: grid;
grid-template-columns: 140px 1fr 60px;
align-items: center;
gap: 10px;
min-width: 0;
}

.pb-hbars li > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}

.pb-hbar {
background: var(--sage-wash);
border-radius: 4px;
height: 12px;
}

.pb-hbar-fill {
background: var(--sage);
border-radius: 4px;
height: 100%;
min-width: 2px;
}

.pb-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
min-width: 0;
}

.pb-truncate-block {
display: block;
min-width: 0;
}
57 changes: 45 additions & 12 deletions app/admin/sections/BookingsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { adminApi, type AdminBooking } from '../../shared-ui/api.js';
import { IconClipboardCheck } from '../../shared-ui/icons';
import { PaymentsPanel } from '../PaymentsPanel';
import type { Session } from '../shared.js';

/** Renders the dates for one row: single date (+ time, for timed services) or a range. */
Expand All @@ -11,13 +12,26 @@ function formatWhen(b: AdminBooking): string {

const byStartDate = (a: AdminBooking, b: AdminBooking) => a.startDate.localeCompare(b.startDate);

/** True for bookings that aren't cancelled/declined — the payments ledger is fully editable for
* these; cancelled/declined rows show a read-only ledger only when they have payments to show. */
const isActive = (b: AdminBooking) => b.status !== 'cancelled' && b.status !== 'declined';

function chipClass(status: string): string {
if (status === 'confirmed') return ' pb-chip-ok';
if (status === 'cancelled') return ' pb-chip-bad';
if (status === 'declined') return ' pb-chip-warn';
return '';
}

/** Payment state for a row; null for unpaid rows. 'paid in full' covers overpayment/tips
* (paidTotal > estCost). Shown for cancelled/declined rows too, so a sitter reviewing a refund
* case can still see the amount. */
function paidText(b: AdminBooking): string | null {
if (b.paidTotal === 0) return null;
if (b.estCost == null) return `paid $${b.paidTotal}`;
return b.paidTotal >= b.estCost ? 'paid in full' : `paid $${b.paidTotal} of $${b.estCost}`;
}

export function BookingsSection({
session,
handleError,
Expand All @@ -29,6 +43,7 @@ export function BookingsSection({
}) {
const [bookings, setBookings] = useState<AdminBooking[] | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [message, setMessage] = useState('');

const load = () =>
Expand Down Expand Up @@ -98,21 +113,39 @@ export function BookingsSection({
Cancel
</button>
)}
{(isActive(b) || b.paidTotal > 0) && (
<button onClick={() => setOpenId(openId === b.id ? null : b.id)}>
{openId === b.id ? 'Close' : 'Payments'}
</button>
)}
</span>
);

const row = (b: AdminBooking) => (
<li key={b.id}>
<span>
{b.customerName || b.customerEmail || 'Unknown customer'} — {b.type}
<br />
{formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'}
{b.estCost != null ? ` · $${b.estCost}` : ''}{' '}
<span className={`pb-chip${chipClass(b.status)}`}>{b.status}</span>
</span>
{actionsFor(b)}
</li>
);
const row = (b: AdminBooking) => {
const paid = paidText(b);
return (
<li key={b.id}>
<span>
{b.customerName || b.customerEmail || 'Unknown customer'} — {b.type}
<br />
{formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'}
{b.estCost != null ? ` · $${b.estCost}` : ''}{' '}
<span className={`pb-chip${chipClass(b.status)}`}>{b.status}</span>
{paid && <> · {paid}</>}
</span>
{actionsFor(b)}
{(isActive(b) || b.paidTotal > 0) && openId === b.id && (
<PaymentsPanel
session={session}
bookingId={b.id}
onChanged={async () => setBookings(await load())}
handleError={handleError}
allowRecord={isActive(b)}
/>
)}
</li>
);
};

const pending = (bookings ?? []).filter((b) => b.status === 'pending').sort(byStartDate);
const rest = (bookings ?? []).filter((b) => b.status !== 'pending').sort(byStartDate);
Expand Down
Loading
Loading