Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
16 changes: 15 additions & 1 deletion 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,
IconClipboardCheck,
IconCode,
IconPaw,
IconPlug,
Expand All @@ -10,6 +11,7 @@ import {
IconUsers,
} from '../shared-ui/icons';
import { AppsSection } from './sections/AppsSection';
import { BookingsSection } from './sections/BookingsSection';
import { BusinessSection } from './sections/BusinessSection';
import { ClientsSection } from './sections/ClientsSection';
import { EmbedSection } from './sections/EmbedSection';
Expand Down Expand Up @@ -116,9 +118,18 @@ function Login({ onLogin }: { onLogin: (s: Session) => void }) {
);
}

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

const SECTIONS: { key: SectionKey; label: string; icon: typeof IconStore }[] = [
{ key: 'bookings', label: 'Bookings', icon: IconClipboardCheck },
{ key: 'business', label: 'Business', icon: IconStore },
{ key: 'pets', label: 'Pets', icon: IconPaw },
{ key: 'services', label: 'Services & rates', icon: IconTag },
Expand Down Expand Up @@ -376,6 +387,9 @@ function Dashboard({ session, onSignOut }: { session: Session; onSignOut: () =>
const enabledPetTypes = settings.petTypes.filter((p) => p.enabled).map((p) => p.petType);

const panels: Record<SectionKey, ReactNode> = {
bookings: (
<BookingsSection session={session} handleError={handle} clearError={() => setError('')} />
),
business: <BusinessSection settings={settings} setSettings={setSettings} />,
pets: <PetsSection settings={settings} setSettings={setSettings} />,
services: (
Expand Down
7 changes: 7 additions & 0 deletions app/admin/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,13 @@ body {
border-color: color-mix(in srgb, var(--warn) 25%, transparent);
}

/* Cancelled (a real booking, killed) reads stronger than declined (never accepted). */
.pb-chip-bad {
color: var(--bad);
background: color-mix(in srgb, var(--bad) 10%, transparent);
border-color: color-mix(in srgb, var(--bad) 25%, transparent);
}

/* Add-a-customer / block-a-range input rows. */
.pb-row {
display: flex;
Expand Down
154 changes: 154 additions & 0 deletions app/admin/sections/BookingsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react';
import { adminApi, type AdminBooking } from '../../shared-ui/api.js';
import { IconClipboardCheck } from '../../shared-ui/icons';
import type { Session } from '../shared.js';

/** Renders the dates for one row: single date (+ time, for timed services) or a range. */
function formatWhen(b: AdminBooking): string {
const range = b.endDate ? `${b.startDate} – ${b.endDate}` : b.startDate;
return b.startTime ? `${range} at ${b.startTime}` : range;
}

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

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 '';
}

export function BookingsSection({
session,
handleError,
clearError,
}: {
session: Session;
handleError: (e: unknown) => void;
clearError: () => void;
}) {
const [bookings, setBookings] = useState<AdminBooking[] | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [message, setMessage] = useState('');

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

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

const setStatus = async (b: AdminBooking, status: 'confirmed' | 'declined' | 'cancelled') => {
if (busyId) return;
if (
status === 'cancelled' &&
!window.confirm(
`Cancel ${b.customerName || b.customerEmail || 'this client'}'s ${b.type} booking (${formatWhen(b)})? This can't be undone.`,
)
)
return;
clearError();
setMessage('');
setBusyId(b.id);
try {
const { notified } = await adminApi.bookings.setStatus(
session.slug,
session.token,
b.id,
status,
);
const who = b.customerName || b.customerEmail || 'the client';
const verb =
status === 'confirmed' ? 'Confirmed' : status === 'declined' ? 'Declined' : 'Cancelled';
setMessage(
`${verb} ${who}'s ${b.type} ${status === 'cancelled' ? 'booking' : 'request'}. ` +
(notified
? `We emailed ${who} the update.`
: `${who} couldn't be emailed automatically (email sending isn't set up), so let them know directly.`),
);
setBookings(await load());
} catch (e) {
handleError(e);
} finally {
setBusyId(null);
}
};

const actionsFor = (b: AdminBooking) => (
<span>
{b.status === 'pending' && (
<>
<button disabled={busyId === b.id} onClick={() => void setStatus(b, 'confirmed')}>
Confirm
</button>
<button disabled={busyId === b.id} onClick={() => void setStatus(b, 'declined')}>
Decline
</button>
</>
)}
{b.status === 'confirmed' && (
<button disabled={busyId === b.id} onClick={() => void setStatus(b, 'cancelled')}>
Cancel
</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 pending = (bookings ?? []).filter((b) => b.status === 'pending').sort(byStartDate);
const rest = (bookings ?? []).filter((b) => b.status !== 'pending').sort(byStartDate);

return (
<>
<h2>
<IconClipboardCheck size={18} /> Bookings
</h2>
{/* In-flow, not the fixed .pb-savebar — this section is the only one with a per-action
message, and a second fixed bar would overlap the app-level one when both are active. */}
{message && (
<p className="pb-hint" role="status">
{message} <button onClick={() => setMessage('')}>OK</button>
</p>
)}
{bookings === null ? (
<p>Loading…</p>
) : bookings.length === 0 ? (
<p className="pb-hint">No bookings yet.</p>
) : (
<>
<h3>
{pending.length === 0
? 'No requests waiting for a reply'
: `Needs your reply (${pending.length})`}
</h3>
{pending.length > 0 && <ul>{pending.map(row)}</ul>}
{rest.length > 0 && (
<>
<h3>Everything else</h3>
<ul>{rest.map(row)}</ul>
</>
)}
</>
)}
</>
);
}
32 changes: 32 additions & 0 deletions app/shared-ui/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ export type Customer = {
pets: Pet[];
};

export type AdminBooking = {
id: string;
customerEmail: string | null;
customerName: string | null;
type: string;
startDate: string;
endDate: string | null;
startTime: string | null;
optionKey: string | null;
petCount: number;
estCost: number | null;
status: string;
createdAt: string;
};

export class ApiError extends Error {
constructor(
public status: number,
Expand Down Expand Up @@ -188,6 +203,23 @@ export const adminApi = {
headers: authHeaders(token),
}),
},
bookings: {
list: (slug: string, token: string) =>
request<{ bookings: AdminBooking[] }>(`/api/${slug}/admin/bookings`, {
headers: authHeaders(token),
}),
setStatus: (
slug: string,
token: string,
id: string,
status: 'confirmed' | 'declined' | 'cancelled',
) =>
request<{ status: string; notified: boolean }>(`/api/${slug}/admin/bookings/${id}/status`, {
method: 'POST',
headers: { ...jsonHeaders, ...authHeaders(token) },
body: JSON.stringify({ status }),
}),
},
calendar: {
start: (slug: string, token: string) =>
request<{ url: string }>(`/api/${slug}/admin/providers/calendar/oauth/start`, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ plain duration option, would collide under that scheme. Fix: **windowed
options derive `OptionKey` from a slug of their label instead**
(e.g. "Morning Walk" → `morning-walk`); non-windowed options keep the existing
`d${durationMinutes}` scheme unchanged. The existing per-service duplicate
check (`admin.ts:280-284`) is generalized to check the *final derived
`OptionKey` set* for the service rather than just `DurationMinutes` — this
check (`admin.ts:280-284`) is generalized to check the _final derived
`OptionKey` set_ for the service rather than just `DurationMinutes` — this
covers collisions from either derivation scheme in one check, and matches the
real invariant that actually matters: the DB's `UNIQUE (TenantId, ServiceType,
OptionKey)` constraint (`schema.sql:53`).
Expand All @@ -121,7 +121,7 @@ OptionKey)` constraint (`schema.sql:53`).
- `listServiceOptions` SELECT expands to include the three new columns.
- New function, sibling to `listCapacityRows` (`repo.ts:147-166`), which today
explicitly excludes `walk`/`checkin` (`ServiceType IN ('boarding',
'housesitting', 'blocked')`):
'housesitting', 'blocked')`):

```ts
countSlotBookings(
Expand All @@ -139,9 +139,10 @@ OptionKey)` constraint (`schema.sql:53`).

Same status filter already used by `listCapacityRows` (pending+confirmed
count toward capacity; cancelled doesn't).

- `insertBookingRequest`'s INSERT (`repo.ts:186-190`) currently writes `Id,
TenantId, EndUserId, ServiceType, StartDate, EndDate, OptionKey, PetType,
PetCount, EstCost, Answers, Status` — `StartTime` is **not** in that column
TenantId, EndUserId, ServiceType, StartDate, EndDate, OptionKey, PetType,
PetCount, EstCost, Answers, Status` — `StartTime` is **not** in that column
list today even though the column exists and is already read back via
`BOOKING_COLS`. The function signature and INSERT both need a `startTime`
param/column added. The call site in `bookings.ts:146-157` passes
Expand Down Expand Up @@ -186,7 +187,7 @@ itself — it already branches into a timed `dateTime` event when `startTime`
is present, computing the event's end via `durationMinutes`
(`addMinutesToLocal`). This path has simply never been exercised because
nothing upstream set `startTime` until now. Because Section 2 makes
`DurationMinutes` *derived* from `EndTime - StartTime` for windowed options
`DurationMinutes` _derived_ from `EndTime - StartTime` for windowed options
(not independently settable), passing `option.StartTime` and
`option.DurationMinutes` through as today's sync payload already does
(`bookings.ts:183-184`) is sufficient — no separate `EndTime` plumbing into
Expand All @@ -199,7 +200,7 @@ answers.
### 7. Admin route (`server/routes/admin.ts`)

- `OptionBody` type gains `startTime?: string | null`, `endTime?: string |
null`, `capacity?: number | null`.
null`, `capacity?: number | null`.
- Validation, added to the existing per-option loop (`admin.ts:275-284`):
- `startTime`/`endTime` must both be present or both absent — reject one
without the other.
Expand Down
Loading
Loading