diff --git a/.gitignore b/.gitignore index 2245dfc..3b1b0f7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ worker-configuration.d.ts *.tsbuildinfo .superpowers/ docs/superpowers/plans/ +.worktrees/ client_secret* CALENDAR_LOGIC.md CLAUDE.md diff --git a/app/admin/App.tsx b/app/admin/App.tsx index 7498c7c..5aa69fe 100644 --- a/app/admin/App.tsx +++ b/app/admin/App.tsx @@ -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, @@ -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'; @@ -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 }, @@ -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); @@ -330,9 +356,19 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () => bookings: ( setError('')} /> ), + earnings: ( + setError('')} /> + ), business: , pets: , - services: , + services: ( + + ), timeoff: ( void | Promise; + 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(null); + const [amount, setAmount] = useState(''); + const [method, setMethod] = useState('cash'); + const [paidDate, setPaidDate] = useState(todayStr); + const [note, setNote] = useState(''); + const [busyId, setBusyId] = useState(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 ( +
+ {payments === null ? ( +

Loading…

+ ) : payments.length === 0 ? ( + allowRecord &&

No payments recorded yet.

+ ) : ( +
    + {payments.map((p) => ( +
  • + + ${p.amount} · {p.method} · {p.paidDate} + {p.note ? ` — ${p.note}` : ''} + + +
  • + ))} +
+ )} + {allowRecord && ( +
+ + + + + +
+ )} +
+ ); +} diff --git a/app/admin/admin.css b/app/admin/admin.css index 10d1612..e474b9c 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -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; @@ -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; +} diff --git a/app/admin/sections/BookingsSection.tsx b/app/admin/sections/BookingsSection.tsx index 2a85805..23b94dd 100644 --- a/app/admin/sections/BookingsSection.tsx +++ b/app/admin/sections/BookingsSection.tsx @@ -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. */ @@ -11,6 +12,10 @@ 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'; @@ -18,6 +23,15 @@ function chipClass(status: string): string { 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, @@ -29,6 +43,7 @@ export function BookingsSection({ }) { const [bookings, setBookings] = useState(null); const [busyId, setBusyId] = useState(null); + const [openId, setOpenId] = useState(null); const [message, setMessage] = useState(''); const load = () => @@ -98,21 +113,39 @@ export function BookingsSection({ Cancel )} + {(isActive(b) || b.paidTotal > 0) && ( + + )} ); - const row = (b: AdminBooking) => ( -
  • - - {b.customerName || b.customerEmail || 'Unknown customer'} — {b.type} -
    - {formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'} - {b.estCost != null ? ` · $${b.estCost}` : ''}{' '} - {b.status} -
    - {actionsFor(b)} -
  • - ); + const row = (b: AdminBooking) => { + const paid = paidText(b); + return ( +
  • + + {b.customerName || b.customerEmail || 'Unknown customer'} — {b.type} +
    + {formatWhen(b)} · {b.petCount} pet{b.petCount === 1 ? '' : 's'} + {b.estCost != null ? ` · $${b.estCost}` : ''}{' '} + {b.status} + {paid && <> · {paid}} +
    + {actionsFor(b)} + {(isActive(b) || b.paidTotal > 0) && openId === b.id && ( + setBookings(await load())} + handleError={handleError} + allowRecord={isActive(b)} + /> + )} +
  • + ); + }; const pending = (bookings ?? []).filter((b) => b.status === 'pending').sort(byStartDate); const rest = (bookings ?? []).filter((b) => b.status !== 'pending').sort(byStartDate); diff --git a/app/admin/sections/ClientsSection.tsx b/app/admin/sections/ClientsSection.tsx index b16c32a..b5eee5f 100644 --- a/app/admin/sections/ClientsSection.tsx +++ b/app/admin/sections/ClientsSection.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { Customer } from '../../shared-ui/api.js'; +import type { Customer, ImportResult } from '../../shared-ui/api.js'; import { adminApi } from '../../shared-ui/api.js'; import { IconUsers } from '../../shared-ui/icons'; @@ -84,6 +84,11 @@ export function ClientsSection({ const [custName, setCustName] = useState(''); const [custPhone, setCustPhone] = useState(''); const [busy, setBusy] = useState(false); + const [csvFile, setCsvFile] = useState(null); + const [sendInvites, setSendInvites] = useState(false); + const [importResult, setImportResult] = useState(null); + const [importing, setImporting] = useState(false); + const [fileInputKey, setFileInputKey] = useState(0); /** Matches the old Dashboard run() semantics: clear the error banner at the START of each * action (so a stale error from an earlier failure doesn't outlive a later action), run the @@ -121,6 +126,24 @@ export function ClientsSection({ const removePet = (endUserId: string, petId: string) => mutate(() => adminApi.customers.removePet(slug, token, endUserId, petId)); + const runImport = async () => { + if (!csvFile || importing) return; + clearError(); + setImporting(true); + try { + const csv = await csvFile.text(); + const result = await adminApi.customers.import(slug, token, csv, sendInvites); + setImportResult(result); + setCsvFile(null); + setFileInputKey((k) => k + 1); + onCustomersChanged(); + } catch (e) { + handleError(e); + } finally { + setImporting(false); + } + }; + return ( <>

    @@ -152,6 +175,53 @@ export function ClientsSection({ {busy ? 'Adding…' : 'Add customer'} +
    + { + setCsvFile(e.target.files?.[0] ?? null); + setImportResult(null); + }} + /> + + + + Download example CSV + +
    + {importResult && ( +
    +

    + Imported {importResult.importedCustomers} client + {importResult.importedCustomers === 1 ? '' : 's'} and {importResult.importedPets} pet + {importResult.importedPets === 1 ? '' : 's'}. + {importResult.invitesSent > 0 ? ` Sent ${importResult.invitesSent} invite(s).` : ''} + {importResult.invitesFailed > 0 + ? ` ${importResult.invitesFailed} invite(s) failed to send.` + : ''} +

    + {importResult.skippedRows.length > 0 && ( +
      + {importResult.skippedRows.map((r) => ( +
    • + Row {r.row}: {r.reason} +
    • + ))} +
    + )} +
    + )}
      {customers.map((cust) => (
    • diff --git a/app/admin/sections/EarningsSection.tsx b/app/admin/sections/EarningsSection.tsx new file mode 100644 index 0000000..75ca858 --- /dev/null +++ b/app/admin/sections/EarningsSection.tsx @@ -0,0 +1,225 @@ +import { useEffect, useRef, useState } from 'react'; +import { adminApi, type AnalyticsPayload } from '../../shared-ui/api.js'; +import { IconChartBar } from '../../shared-ui/icons'; +import { PaymentsPanel } from '../PaymentsPanel'; +import type { Session } from '../shared.js'; + +const NO_PAYMENTS = 'No payments recorded yet.'; + +const MONTH_NAMES = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/** '2026-07' → 'Jul 26'. */ +function monthLabel(month: string): string { + const [y, m] = month.split('-'); + return `${MONTH_NAMES[Number(m) - 1]} ${y.slice(2)}`; +} + +/** Hand-rolled 12-bar SVG chart — no chart library (see the design's non-goals). */ +function MonthlyChart({ monthly }: { monthly: AnalyticsPayload['monthly'] }) { + const max = Math.max(1, ...monthly.map((m) => m.total)); + const barW = 22; + const gap = 8; + const chartH = 110; + const width = monthly.length * (barW + gap) - gap; + return ( + + {monthly.map((m, i) => { + const h = m.total === 0 ? 0 : Math.max(2, Math.round((m.total / max) * (chartH - 16))); + const x = i * (barW + gap); + return ( + + {m.total > 0 && m.total < 10000 && ( + + ${m.total} + + )} + + + {monthLabel(m.month)} + + + ); + })} + + ); +} + +export function EarningsSection({ + session, + handleError, + clearError, +}: { + session: Session; + handleError: (e: unknown) => void; + clearError: () => void; +}) { + const [data, setData] = useState(null); + const [openId, setOpenId] = useState(null); + const alive = useRef(true); + + const load = () => adminApi.analytics.get(session.slug, session.token); + + useEffect(() => { + let active = true; + load() + .then((d) => active && setData(d)) + .catch((e) => active && handleError(e)); + return () => { + active = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session]); + + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); + + const reload = async () => { + clearError(); + try { + const result = await load(); + if (alive.current) { + setData(result); + } + } catch (e) { + if (alive.current) { + handleError(e); + } + } + }; + + if (data === null) + return ( + <> +

      + Earnings +

      +

      Loading…

      + + ); + + const hasPayments = data.byService.length > 0; + const maxService = Math.max(1, ...data.byService.map((s) => s.total)); + + return ( + <> +

      + Earnings +

      + +
      +
      + ${data.tiles.thisMonth} + This month +
      +
      + ${data.tiles.lastMonth} + Last month +
      +
      + ${data.tiles.outstandingTotal} + Outstanding +
      +
      + {data.tiles.outstandingCount} + Unpaid bookings +
      +
      + +

      Revenue over time

      + {hasPayments ? ( + + ) : ( +

      {NO_PAYMENTS}

      + )} + +

      By service (all-time)

      + {data.byService.length === 0 ? ( +

      {NO_PAYMENTS}

      + ) : ( +
        + {data.byService.map((s) => ( +
      • + {s.label} +
        +
        +
        + ${s.total} +
      • + ))} +
      + )} + +

      Top clients (all-time)

      + {data.topClients.length === 0 ? ( +

      {NO_PAYMENTS}

      + ) : ( +
        + {data.topClients.map((t) => ( +
      • + + {t.name || t.email || 'Unknown client'} + + + ${t.total} · {t.bookings} booking{t.bookings === 1 ? '' : 's'} + +
      • + ))} +
      + )} + +

      Outstanding balances

      + {data.outstanding.length === 0 ? ( +

      No outstanding balances.

      + ) : ( +
        + {data.outstanding.map((o) => ( +
      • + + {o.name || o.email || 'Unknown client'} —{' '} + {o.serviceType} ({o.startDate}) +
        + owes ${o.balance} (paid ${o.paidTotal} of ${o.estCost}) +
        + + {openId === o.bookingId && ( + + )} +
      • + ))} +
      + )} + + ); +} diff --git a/app/admin/sections/ServicesSection.tsx b/app/admin/sections/ServicesSection.tsx index 0ef7721..685aa9b 100644 --- a/app/admin/sections/ServicesSection.tsx +++ b/app/admin/sections/ServicesSection.tsx @@ -1,12 +1,19 @@ +import { useState } from 'react'; import { NullableNumberField } from './fields.js'; -import { IconTag } from '../../shared-ui/icons'; +import { IconPaw, IconTag, SERVICE_ICONS } from '../../shared-ui/icons'; import type { QuestionForm, ServiceForm, ServiceOptionForm, + Settings, SettingsSectionProps, } from '../shared.js'; +function ServiceIcon({ icon }: { icon: string }) { + const Icon = SERVICE_ICONS[icon] ?? IconPaw; + return ; +} + const QUESTION_TYPES: QuestionForm['type'][] = ['text', 'yesno', 'number', 'select']; const QUESTION_TYPE_LABELS: Record = { text: 'Text', @@ -138,7 +145,59 @@ function QuestionRow({ ); } -export function ServicesSection({ settings, setSettings }: SettingsSectionProps) { +function AddServiceForm({ + templates, + addService, +}: { + templates: Settings['templates']; + addService: (template: string, label: string) => Promise; +}) { + const [template, setTemplate] = useState(templates[0]?.id ?? ''); + const [label, setLabel] = useState(''); + const [busy, setBusy] = useState(false); + + const submit = async () => { + if (busy || !label.trim() || !template) return; + setBusy(true); + try { + await addService(template, label.trim()); + setLabel(''); + } finally { + setBusy(false); + } + }; + + return ( +
      + + setLabel(e.target.value)} + /> + +
      + ); +} + +export function ServicesSection({ + settings, + setSettings, + addService, + removeService, +}: SettingsSectionProps & { + addService: (template: string, label: string) => Promise; + removeService: (type: string) => Promise; +}) { return ( <>

      @@ -163,8 +222,13 @@ export function ServicesSection({ settings, setSettings }: SettingsSectionProps) checked={s.enabled} onChange={(e) => setService({ ...s, enabled: e.target.checked })} /> - {s.label} + {s.label} + {s.custom && ( + + )} {!s.hasDuration ? (
      ); })} +
      +

      Add service

      + +
      ); } diff --git a/app/admin/shared.ts b/app/admin/shared.ts index 52d274b..a0fd074 100644 --- a/app/admin/shared.ts +++ b/app/admin/shared.ts @@ -10,13 +10,16 @@ export type QuestionForm = Omit & { id?: string }; export type ServiceForm = ServiceConstraints & { type: string; label: string; + icon: string; hasDuration: boolean; rateUnit: string; shape: 'range' | 'single'; + custom: boolean; enabled: boolean; options: ServiceOptionForm[]; questions: QuestionForm[]; }; +export type ServiceTemplate = { id: string; label: string }; export type Settings = { displayName: string; accentColor: string; @@ -28,6 +31,7 @@ export type Settings = { contactPhone: string | null; petTypes: { petType: string; enabled: boolean }[]; services: ServiceForm[]; + templates: ServiceTemplate[]; blocked: { id: string; startDate: string; endDate: string | null }[]; calendar: { status: string; diff --git a/app/embed/BookTab.tsx b/app/embed/BookTab.tsx index 2f30cc7..7d94f4f 100644 --- a/app/embed/BookTab.tsx +++ b/app/embed/BookTab.tsx @@ -14,8 +14,7 @@ import { type Pet, type TenantConfig, } from '../shared-ui/api'; -import { SERVICE_ICONS } from './services'; -import { IconCheck, IconPaw } from '../shared-ui/icons'; +import { IconCheck, IconPaw, SERVICE_ICONS } from '../shared-ui/icons'; import { QuestionField } from './QuestionField'; import { errorMsg, slug, parentOrigin } from './shared'; @@ -144,7 +143,7 @@ export function BookTab({
      {config.services.map((s) => { - const Icon = SERVICE_ICONS[s.type] ?? IconPaw; + const Icon = SERVICE_ICONS[s.icon] ?? IconPaw; return (