DataAccessLog records bookings list/view/export and run-sheet views (actor, action, target, time); pruned after 12 months by the worker; migration 0003. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
130 lines
6.0 KiB
TypeScript
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(150px, 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 paginate hasNextPage={page * 50 < total} hasPreviousPage={page > 1}>
|
|
<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} bookings · 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();
|
|
}
|