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 { logAccess } from "../lib/audit.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, actor } = await adminContext(request); logAccess(cfg.shop.id, actor, "booking.view", params.id ?? null); 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(); const result = useActionData(); 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 ( {orderUrl && Open order} {result && {result.message}} {b.status === "needs_review" && ( {b.reviewReason}. Pick a time below to confirm it, or tick "allow over capacity" if you can fit it in. )} {b.status === "confirmed" && b.reviewReason && ( {b.reviewReason}
Mark as checked
)} {b.status.replace("_", " ")} {METHOD_LABEL[b.method]} {location ?? "—"} {niceDate(b.date, true)} {b.method === "shipping" ? "Dispatch day" : slotLabel(b.slotStart, b.slotEnd)} {money(b.fee, currency)} {[b.customerName, b.customerEmail, b.customerPhone].filter(Boolean).join(" · ") || "—"} {[addr.line1, addr.city, addr.postal].filter(Boolean).join(", ") || "—"} {items.map((i) => `${i.quantity}× ${i.title}`).join(", ") || "—"} v{b.configVersion} {options.length > 0 && (
{options.map((o) => ( {`${niceDate(o.date)} · ${o.method === "shipping" ? "dispatch" : slotLabel(o.start, o.end)} · ${o.full ? "full" : o.remaining == null ? "open" : `${o.remaining} left`}`} ))} The current place is released only if the new one saves. The customer isn't messaged automatically. Save new time
)} {b.reminderJobs.length === 0 ? ( No reminders planned. ) : ( {b.reminderJobs.map((j) => ( {j.channel} · {new Date(j.sendAt).toLocaleString("en-CA")} · {j.status} {j.lastError ? ` (${j.lastError})` : ""} ))} )} {b.events.map((e) => ( {new Date(e.at).toLocaleString("en-CA")} · {e.actor} · {e.action} {e.before ? ` · from ${describe(e.before)}` : ""} {e.after ? ` · to ${describe(e.after)}` : ""} ))}
); } function describe(v: unknown) { const o = v as Record; 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 ( {label} {children} ); }