datewise/app/routes/app.bookings._index.tsx
metatroncubeswdev 244de30d34 feat(listing): screenshots, framing script, listing copy; bookings list polish
- brand/make_screenshots.py frames raw shots at 1600x900 with captions + alt text
- brand/LISTING.md: introduction, details, features, search terms, testing steps
- bookings filters no longer overflow; footer reads '1 booking'; no empty pager

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:26:26 -04:00

130 lines
6.0 KiB
TypeScript

import type { LoaderFunctionArgs } from "react-router";
import { Form, useLoaderData, useSearchParams } from "react-router";
import prisma from "../db.server";
import { localParts } from "../engine/time";
import { adminContext } from "../lib/admin.server";
import { logAccess } from "../lib/audit.server";
import { bookingWhere } from "../lib/bookings-query.server";
import { METHOD_LABEL, STATUS_TONE, niceDate, slotLabel } from "../lib/format";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, actor } = await adminContext(request);
const sp = new URL(request.url).searchParams;
logAccess(cfg.shop.id, actor, "bookings.list", sp.toString() || null);
const today = localParts(Date.now(), cfg.shop.timezone).date;
const page = Math.max(1, Number(sp.get("page") ?? 1));
const where = bookingWhere(cfg.shop.id, sp, today);
const [rows, total] = await Promise.all([
prisma.booking.findMany({
where,
orderBy: [{ status: "desc" }, { date: "asc" }, { slotStart: "asc" }],
take: 50,
skip: (page - 1) * 50,
}),
prisma.booking.count({ where }),
]);
return {
rows: rows.map((b) => ({ ...b, startUtc: b.startUtc?.toISOString() ?? null, endUtc: b.endUtc?.toISOString() ?? null, createdAt: b.createdAt.toISOString(), updatedAt: b.updatedAt.toISOString() })),
total,
page,
locations: cfg.locations.map((l) => ({ id: l.id, name: l.name })),
shop: cfg.shop.domain,
};
};
export default function Bookings() {
const { rows, total, page, locations } = useLoaderData<typeof loader>();
const [sp] = useSearchParams();
const locName = (id: string | null) => locations.find((l) => l.id === id)?.name ?? "—";
const exportHref = `/app/bookings/export?${sp.toString()}`;
return (
<s-page heading="Bookings">
<s-button slot="secondary-actions" href={exportHref} target="_blank">Export CSV</s-button>
<s-section>
<Form method="get">
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(190px, 1fr))" gap="base" alignItems="end">
<s-select label="Show" name="status" value={sp.get("status") ?? "upcoming"}>
<s-option value="upcoming">Upcoming</s-option>
<s-option value="review">Needs review</s-option>
<s-option value="confirmed">Confirmed</s-option>
<s-option value="fulfilled">Fulfilled</s-option>
<s-option value="cancelled">Cancelled</s-option>
<s-option value="all">All</s-option>
</s-select>
<s-select label="Method" name="method" value={sp.get("method") ?? "all"}>
<s-option value="all">All methods</s-option>
<s-option value="delivery">Delivery</s-option>
<s-option value="pickup">Pickup</s-option>
<s-option value="shipping">Shipping</s-option>
</s-select>
<s-select label="Location" name="location" value={sp.get("location") ?? "all"}>
<s-option value="all">All locations</s-option>
{locations.map((l) => (
<s-option key={l.id} value={l.id}>{l.name}</s-option>
))}
</s-select>
<s-date-field label="From" name="from" value={sp.get("from") ?? ""} />
<s-date-field label="To" name="to" value={sp.get("to") ?? ""} />
<s-search-field label="Search" name="q" placeholder="Order number, or a full email or phone" value={sp.get("q") ?? ""} />
<s-button type="submit">Apply</s-button>
</s-grid>
</Form>
</s-section>
<s-section padding="none">
<s-table>
<s-table-header-row>
<s-table-header listSlot="primary">Order</s-table-header>
<s-table-header>Date</s-table-header>
<s-table-header>Time</s-table-header>
<s-table-header>Method</s-table-header>
<s-table-header>Location</s-table-header>
<s-table-header listSlot="secondary">Customer</s-table-header>
<s-table-header>Status</s-table-header>
</s-table-header-row>
<s-table-body>
{rows.map((b) => (
<s-table-row key={b.id} clickDelegate={`b-${b.id}`}>
<s-table-cell>
<s-link id={`b-${b.id}`} href={`/app/bookings/${b.id}`}>{b.orderName ?? "No order"}</s-link>
</s-table-cell>
<s-table-cell>{niceDate(b.date)}</s-table-cell>
<s-table-cell>{b.method === "shipping" ? "Dispatch" : slotLabel(b.slotStart, b.slotEnd)}</s-table-cell>
<s-table-cell>
{METHOD_LABEL[b.method]} {b.peakDayId && <s-badge tone="warning">Peak</s-badge>}
</s-table-cell>
<s-table-cell>{locName(b.locationId)}</s-table-cell>
<s-table-cell>{b.customerName ?? b.customerEmail ?? "—"}</s-table-cell>
<s-table-cell>
<s-stack gap="small-300">
<s-badge tone={STATUS_TONE[b.status] ?? "neutral"}>{b.status.replace("_", " ")}</s-badge>
{b.reviewReason && <s-text color="subdued">{b.reviewReason}</s-text>}
</s-stack>
</s-table-cell>
</s-table-row>
))}
</s-table-body>
</s-table>
{rows.length === 0 && (
<s-box padding="base">
<s-paragraph>No bookings match these filters.</s-paragraph>
</s-box>
)}
<s-box padding="base">
<s-stack direction="inline" gap="base" alignItems="center">
{page > 1 && <s-link href={`?${withPage(sp, page - 1)}`}>Previous</s-link>}
<s-text color="subdued">{total} booking{total === 1 ? "" : "s"}{total > 50 ? ` · page ${page}` : ""}</s-text>
{page * 50 < total && <s-link href={`?${withPage(sp, page + 1)}`}>Next</s-link>}
</s-stack>
</s-box>
</s-section>
</s-page>
);
}
function withPage(sp: URLSearchParams, p: number) {
const n = new URLSearchParams(sp);
n.set("page", String(p));
return n.toString();
}