Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ worker-configuration.d.ts
*.tsbuildinfo
.superpowers/
docs/superpowers/plans/
.worktrees/
client_secret*
CALENDAR_LOGIC.md
CLAUDE.md
40 changes: 38 additions & 2 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 } 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 @@ -124,10 +126,19 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) {
}

type SectionKey =
'bookings' | 'business' | 'pets' | 'services' | 'timeoff' | 'clients' | 'apps' | 'embed';
| 'bookings'
| 'earnings'
| 'business'
| 'pets'
| 'services'
| 'timeoff'
| 'clients'
| 'apps'
| 'embed';

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: 'Pet types', icon: IconPaw },
{ key: 'services', label: 'Services & rates', icon: IconTag },
Expand Down Expand Up @@ -277,6 +288,21 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () =>
setPreviewKey((k) => k + 1);
});

const addService = (template: string, label: string) =>
run(async () => {
await adminFetch(token, `/api/${slug}/admin/services`, {
method: 'POST',
body: JSON.stringify({ template, label }),
});
await refresh();
});

const removeService = (type: string) =>
run(async () => {
await adminFetch(token, `/api/${slug}/admin/services/${type}`, { method: 'DELETE' });
await refresh();
});

const connectCalendar = () =>
run(async () => {
const { url } = await adminApi.calendar.start(slug, token);
Expand Down Expand Up @@ -330,9 +356,19 @@ 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: <ServicesSection settings={settings} setSettings={setSettings} />,
services: (
<ServicesSection
settings={settings}
setSettings={setSettings}
addService={addService}
removeService={removeService}
/>
),
timeoff: (
<TimeOffSection
blocked={settings.blocked}
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;
}
Loading
Loading