153 lines
7.4 KiB
TypeScript
153 lines
7.4 KiB
TypeScript
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||
import { Form, useActionData, useLoaderData, useNavigation } from "react-router";
|
||
import prisma from "../db.server";
|
||
import { adminContext, formBool, formString } from "../lib/admin.server";
|
||
import { METHOD_LABEL, STATUS_TONE, money, niceDate, slotLabel } from "../lib/format";
|
||
import { rescheduleBooking, rescheduleOptions } from "../services/bookings.server";
|
||
import type { Method } from "../engine/types";
|
||
|
||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||
const { cfg } = await adminContext(request);
|
||
const b = await prisma.booking.findFirst({
|
||
where: { id: params.id, shopId: cfg.shop.id },
|
||
include: { events: { orderBy: { at: "desc" } }, reminderJobs: { orderBy: { sendAt: "asc" } } },
|
||
});
|
||
if (!b) throw new Response("Not found", { status: 404 });
|
||
const options = ["confirmed", "needs_review"].includes(b.status) ? await rescheduleOptions(cfg.shop.domain, b.id, { customer: false }) : [];
|
||
return {
|
||
b: {
|
||
...b,
|
||
startUtc: b.startUtc?.toISOString() ?? null,
|
||
createdAt: b.createdAt.toISOString(),
|
||
events: b.events.map((e) => ({ ...e, at: e.at.toISOString() })),
|
||
reminderJobs: b.reminderJobs.map((j) => ({ id: j.id, channel: j.channel, status: j.status, sendAt: j.sendAt.toISOString(), lastError: j.lastError })),
|
||
},
|
||
location: cfg.locations.find((l) => l.id === b.locationId)?.name ?? null,
|
||
options: options.slice(0, 400),
|
||
currency: cfg.shop.currency,
|
||
orderUrl: b.orderId ? `shopify://admin/orders/${b.orderId.split("/").pop()}` : null,
|
||
};
|
||
};
|
||
|
||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||
const { cfg, actor } = await adminContext(request);
|
||
const fd = await request.formData();
|
||
const intent = formString(fd, "intent");
|
||
if (intent === "dismiss") {
|
||
await prisma.booking.updateMany({ where: { id: params.id, shopId: cfg.shop.id, status: "confirmed" }, data: { reviewReason: null } });
|
||
return { ok: true, message: "Marked as checked" };
|
||
}
|
||
const [locationId, method, date, slot] = formString(fd, "target").split("|");
|
||
if (!slot) return { ok: false, message: "Choose a new time" };
|
||
const r = await rescheduleBooking({
|
||
domain: cfg.shop.domain,
|
||
bookingId: params.id!,
|
||
sel: { locationId, method: method as Method, date, slot },
|
||
actor,
|
||
force: formBool(fd, "force"),
|
||
});
|
||
return r.ok ? { ok: true, message: "Booking moved. Reminders were re-planned." } : { ok: false, message: r.message };
|
||
};
|
||
|
||
export default function BookingDetail() {
|
||
const { b, location, options, currency, orderUrl } = useLoaderData<typeof loader>();
|
||
const result = useActionData<typeof action>();
|
||
const busy = useNavigation().state !== "idle";
|
||
const addr = (b.address ?? {}) as { line1?: string; city?: string; postal?: string };
|
||
const items = (b.items ?? []) as { title: string; quantity: number }[];
|
||
return (
|
||
<s-page heading={b.orderName ?? "Booking"}>
|
||
{orderUrl && <s-button slot="secondary-actions" href={orderUrl}>Open order</s-button>}
|
||
{result && <s-banner tone={result.ok ? "success" : "critical"}>{result.message}</s-banner>}
|
||
{b.status === "needs_review" && (
|
||
<s-banner tone="critical" heading="This order needs a time">
|
||
<s-paragraph>{b.reviewReason}. Pick a time below to confirm it, or tick "allow over capacity" if you can fit it in.</s-paragraph>
|
||
</s-banner>
|
||
)}
|
||
{b.status === "confirmed" && b.reviewReason && (
|
||
<s-banner tone="warning" heading="Check this order">
|
||
<s-stack gap="base">
|
||
<s-paragraph>{b.reviewReason}</s-paragraph>
|
||
<Form method="post">
|
||
<input type="hidden" name="intent" value="dismiss" />
|
||
<s-button type="submit">Mark as checked</s-button>
|
||
</Form>
|
||
</s-stack>
|
||
</s-banner>
|
||
)}
|
||
<s-section heading="Booking">
|
||
<s-grid gridTemplateColumns="repeat(auto-fit, minmax(180px, 1fr))" gap="base">
|
||
<Field label="Status"><s-badge tone={STATUS_TONE[b.status] ?? "neutral"}>{b.status.replace("_", " ")}</s-badge></Field>
|
||
<Field label="Method">{METHOD_LABEL[b.method]}</Field>
|
||
<Field label="Location">{location ?? "—"}</Field>
|
||
<Field label="Date">{niceDate(b.date, true)}</Field>
|
||
<Field label="Time">{b.method === "shipping" ? "Dispatch day" : slotLabel(b.slotStart, b.slotEnd)}</Field>
|
||
<Field label="Fee paid">{money(b.fee, currency)}</Field>
|
||
<Field label="Customer">{[b.customerName, b.customerEmail, b.customerPhone].filter(Boolean).join(" · ") || "—"}</Field>
|
||
<Field label="Address">{[addr.line1, addr.city, addr.postal].filter(Boolean).join(", ") || "—"}</Field>
|
||
<Field label="Items">{items.map((i) => `${i.quantity}× ${i.title}`).join(", ") || "—"}</Field>
|
||
<Field label="Rule version">v{b.configVersion}</Field>
|
||
</s-grid>
|
||
</s-section>
|
||
{options.length > 0 && (
|
||
<s-section heading={b.status === "needs_review" ? "Choose a time" : "Move to another time"}>
|
||
<Form method="post">
|
||
<s-stack gap="base">
|
||
<s-select label="New time" name="target">
|
||
{options.map((o) => (
|
||
<s-option key={`${o.date}|${o.slot}`} value={`${o.locationId}|${o.method}|${o.date}|${o.slot}`}>
|
||
{`${niceDate(o.date)} · ${o.method === "shipping" ? "dispatch" : slotLabel(o.start, o.end)} · ${o.full ? "full" : o.remaining == null ? "open" : `${o.remaining} left`}`}
|
||
</s-option>
|
||
))}
|
||
</s-select>
|
||
<s-checkbox name="force" label="Allow over capacity" details="Only for times marked full. Recorded in the history." />
|
||
<s-paragraph>The current place is released only if the new one saves. The customer isn't messaged automatically.</s-paragraph>
|
||
<s-button type="submit" variant="primary" loading={busy}>Save new time</s-button>
|
||
</s-stack>
|
||
</Form>
|
||
</s-section>
|
||
)}
|
||
<s-section heading="Reminders">
|
||
{b.reminderJobs.length === 0 ? (
|
||
<s-paragraph>No reminders planned.</s-paragraph>
|
||
) : (
|
||
<s-unordered-list>
|
||
{b.reminderJobs.map((j) => (
|
||
<s-list-item key={j.id}>
|
||
{j.channel} · {new Date(j.sendAt).toLocaleString("en-CA")} · {j.status}
|
||
{j.lastError ? ` (${j.lastError})` : ""}
|
||
</s-list-item>
|
||
))}
|
||
</s-unordered-list>
|
||
)}
|
||
</s-section>
|
||
<s-section heading="History">
|
||
<s-unordered-list>
|
||
{b.events.map((e) => (
|
||
<s-list-item key={e.id}>
|
||
{new Date(e.at).toLocaleString("en-CA")} · {e.actor} · {e.action}
|
||
{e.before ? ` · from ${describe(e.before)}` : ""}
|
||
{e.after ? ` · to ${describe(e.after)}` : ""}
|
||
</s-list-item>
|
||
))}
|
||
</s-unordered-list>
|
||
</s-section>
|
||
</s-page>
|
||
);
|
||
}
|
||
|
||
function describe(v: unknown) {
|
||
const o = v as Record<string, unknown>;
|
||
if (o.date) return `${o.date} ${o.slot ?? ""}`.trim() + (o.forced ? " (over capacity)" : "");
|
||
return Object.entries(o).map(([k, x]) => `${k}: ${String(x)}`).join(", ");
|
||
}
|
||
|
||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||
return (
|
||
<s-stack gap="small-300">
|
||
<s-text color="subdued">{label}</s-text>
|
||
<s-text>{children}</s-text>
|
||
</s-stack>
|
||
);
|
||
}
|