metatrondelivery/app/routes/app.dashboard.tsx
metatroncubeswdev c5ec8f368c feat: Phase 6 — ops/dispatch dashboard
- Prisma: Booking.totalPriceCents (parsed from the order webhook's
  total_price string), populated in booking.server.ts so "revenue by
  method" is real data, not a placeholder.
- app/services/dashboard.server.ts: pure aggregation over injected booking
  data (CLAUDE.md — no DB calls in the math) — bucketByLocalDate (each
  booking grouped under its own location's local calendar day, not a
  shared UTC day), revenueByMethod (confirmed/fulfilled only),
  utilizationByDateLocation (booked vs. summed SlotTemplate capacity for
  that weekday, capped at 100%), upcomingFulfillments, and a CSV
  writer/formatter with proper quote-escaping.
- /app/dashboard: filterable (date range, location, method, status) view
  with revenue-by-method cards, a capacity-utilization list (color-coded
  by load), an upcoming-fulfillments table with one-click
  confirmed->fulfilled/no_show status transitions, and a full by-day
  booking list.
- /app/dashboard/export: a resource route (loader only, no component)
  streaming the same filtered bookings as a downloadable CSV — kept
  separate from the dashboard route specifically so it can import
  dashboard.server.ts freely without the client-bundling constraint the
  main route has to respect (see below).

Fixed the same class of server/client bundling bug from the Phase 5 commit
before it could ship, this time by construction: dashboard.server.ts's
aggregation functions are called only inside app.dashboard.tsx's `loader`,
never referenced by the default-exported component (which only reads
useLoaderData() output) — verified this holds by actually running
`npm run build`, not just tsc/vitest, which both stay silent about this
class of error. Also hit (and fixed) the same "loader Dates arrive as
strings on the client" issue from Phase 1: swapped DateTime.fromJSDate for
DateTime.fromISO in the two places the component formats a booking's
slotStart.

Verified: lint, typecheck, 102 unit tests (+16 new for dashboard.server.ts,
+2 new for totalPriceCents parsing), 18 integration tests, both builds, and
a live script exercising the full aggregation pipeline (revenue exclusion
of cancelled bookings, utilization math, CSV output) against the Postgres
container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:33:21 -04:00

342 lines
12 KiB
TypeScript

import { data, type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData, useNavigation, useSearchParams } from "@remix-run/react";
import {
Page,
Card,
BlockStack,
InlineStack,
Text,
Button,
Select,
TextField,
IndexTable,
Badge,
Box,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import { DateTime } from "luxon";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
import {
bucketByLocalDate,
revenueByMethod,
upcomingFulfillments,
utilizationByDateLocation,
type BookingSummary,
} from "../services/dashboard.server";
import { formatPriceLabel } from "../lib/currency";
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
const STATUSES = ["confirmed", "fulfilled", "no_show", "cancelled"];
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationIdFilter = url.searchParams.get("locationId") || undefined;
const methodFilter = (url.searchParams.get("method") as Method | null) || undefined;
const statusFilter = url.searchParams.get("status") || undefined;
const startDate = url.searchParams.get("startDate") || DateTime.now().toISODate()!;
const endDate = url.searchParams.get("endDate") || DateTime.now().plus({ days: 7 }).toISODate()!;
const rangeStart = DateTime.fromISO(startDate, { zone: "utc" }).toJSDate();
const rangeEnd = DateTime.fromISO(endDate, { zone: "utc" }).plus({ days: 1 }).toJSDate();
const [locations, bookings, slotTemplates] = await Promise.all([
db.location.findMany({ where: { shopDomain: session.shop }, orderBy: { createdAt: "asc" } }),
db.booking.findMany({
where: {
shopDomain: session.shop,
locationId: locationIdFilter,
method: methodFilter,
status: statusFilter,
slotStart: { gte: rangeStart, lt: rangeEnd },
},
include: { location: true },
orderBy: { slotStart: "asc" },
}),
db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: locationIdFilter, method: methodFilter },
}),
]);
const summaries: BookingSummary[] = bookings.map((b) => ({
id: b.id,
orderName: b.orderName,
locationId: b.locationId,
locationName: b.location.name,
method: b.method,
slotStart: b.slotStart,
slotEnd: b.slotEnd,
timezone: b.location.timezone,
status: b.status,
totalPriceCents: b.totalPriceCents,
customerEmail: b.customerEmail,
}));
const byDate = bucketByLocalDate(summaries);
const revenue = revenueByMethod(summaries);
const utilization = utilizationByDateLocation(
summaries,
slotTemplates.map((t) => ({ locationId: t.locationId, method: t.method, weekday: t.weekday, capacity: t.capacity })),
);
const upcoming = upcomingFulfillments(summaries);
return {
locations,
filters: { locationId: locationIdFilter ?? "", method: methodFilter ?? "", status: statusFilter ?? "", startDate, endDate },
byDate,
revenue,
utilization,
upcoming,
totalBookings: summaries.length,
};
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const bookingId = String(formData.get("bookingId") || "");
const status = String(formData.get("status") || "");
if (!bookingId || !STATUSES.includes(status)) {
return data({ error: "Invalid request" }, { status: 400 });
}
await db.booking.updateMany({ where: { id: bookingId, shopDomain: session.shop }, data: { status } });
return data({ ok: true });
};
export default function Dashboard() {
const { locations, filters, byDate, revenue, utilization, upcoming, totalBookings } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const setFilter = (key: string, value: string) => {
const next = new URLSearchParams(searchParams);
if (value) next.set(key, value);
else next.delete(key);
setSearchParams(next);
};
const dates = Object.keys(byDate).sort();
const exportQuery = new URLSearchParams(searchParams).toString();
return (
<Page>
<TitleBar title="Dispatch dashboard" />
<BlockStack gap="400">
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Filters
</Text>
<InlineStack gap="300" wrap>
<TextField
label="Start date"
labelHidden={false}
type="date"
autoComplete="off"
value={filters.startDate}
onChange={(v) => setFilter("startDate", v)}
/>
<TextField
label="End date"
type="date"
autoComplete="off"
value={filters.endDate}
onChange={(v) => setFilter("endDate", v)}
/>
<Select
label="Location"
options={[{ label: "All locations", value: "" }, ...locations.map((l) => ({ label: l.name, value: l.id }))]}
value={filters.locationId}
onChange={(v) => setFilter("locationId", v)}
/>
<Select
label="Method"
options={[{ label: "All methods", value: "" }, ...METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))]}
value={filters.method}
onChange={(v) => setFilter("method", v)}
/>
<Select
label="Status"
options={[{ label: "All statuses", value: "" }, ...STATUSES.map((s) => ({ label: s, value: s }))]}
value={filters.status}
onChange={(v) => setFilter("status", v)}
/>
</InlineStack>
<InlineStack gap="200">
<Button url={`/app/dashboard/export?${exportQuery}`} target="_blank">
Export CSV
</Button>
<Text as="span" tone="subdued">
{totalBookings} booking{totalBookings === 1 ? "" : "s"} in range
</Text>
</InlineStack>
</BlockStack>
</Card>
<InlineStack gap="400" wrap>
{METHODS.map((m) => (
<Card key={m}>
<BlockStack gap="100">
<Text as="span" tone="subdued">
{m.replace("_", " ")} revenue
</Text>
<Text as="span" variant="headingLg">
{formatPriceLabel(revenue[m])}
</Text>
</BlockStack>
</Card>
))}
</InlineStack>
<Card>
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Capacity utilization
</Text>
{utilization.length === 0 ? (
<Text as="p" tone="subdued">
No bookings in this range yet.
</Text>
) : (
<BlockStack gap="150">
{utilization.map((row) => (
<InlineStack key={`${row.date}-${row.locationId}-${row.method}`} align="space-between" blockAlign="center">
<Text as="span">
{row.date} · {row.locationName} · {row.method.replace("_", " ")}
</Text>
<InlineStack gap="200" blockAlign="center">
<Box
minWidth="120px"
background={row.utilizationPct >= 90 ? "bg-fill-critical" : row.utilizationPct >= 60 ? "bg-fill-caution" : "bg-fill-success"}
padding="100"
borderRadius="100"
>
<Text as="span" alignment="center">
{row.booked}/{row.capacity} ({row.utilizationPct}%)
</Text>
</Box>
</InlineStack>
</InlineStack>
))}
</BlockStack>
)}
</BlockStack>
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
Upcoming fulfillments
</Text>
</Box>
{upcoming.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
Nothing confirmed and upcoming in this range.
</Text>
</Box>
) : (
<IndexTable
itemCount={upcoming.length}
headings={[
{ title: "Order" },
{ title: "When" },
{ title: "Method" },
{ title: "Location" },
{ title: "Customer" },
{ title: "" },
]}
selectable={false}
>
{upcoming.map((b, index) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<IndexTable.Row id={b.id} key={b.id} position={index}>
<IndexTable.Cell>{b.orderName ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>{local.toFormat("ccc LLL d, h:mm a")}</IndexTable.Cell>
<IndexTable.Cell>{b.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>{b.locationName}</IndexTable.Cell>
<IndexTable.Cell>{b.customerEmail ?? "—"}</IndexTable.Cell>
<IndexTable.Cell>
<InlineStack gap="150">
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="fulfilled" />
<Button submit size="slim" loading={isSubmitting}>
Fulfilled
</Button>
</Form>
<Form method="post">
<input type="hidden" name="bookingId" value={b.id} />
<input type="hidden" name="status" value="no_show" />
<Button submit size="slim" tone="critical" loading={isSubmitting}>
No-show
</Button>
</Form>
</InlineStack>
</IndexTable.Cell>
</IndexTable.Row>
);
})}
</IndexTable>
)}
</Card>
<Card padding="0">
<Box padding="400">
<Text as="h3" variant="headingSm">
All bookings by day
</Text>
</Box>
{dates.length === 0 ? (
<Box padding="400">
<Text as="p" tone="subdued">
No bookings in this range.
</Text>
</Box>
) : (
dates.map((date) => (
<Box key={date} padding="400" borderBlockStartWidth="025" borderColor="border">
<BlockStack gap="200">
<Text as="h4" variant="headingXs">
{date}
</Text>
{byDate[date].map((b) => {
const local = DateTime.fromISO(b.slotStart, { zone: "utc" }).setZone(b.timezone);
return (
<InlineStack key={b.id} align="space-between">
<Text as="span">
{local.toFormat("h:mm a")} · {b.orderName ?? "—"} · {b.method.replace("_", " ")} · {b.locationName}
</Text>
<Badge
tone={
b.status === "confirmed"
? "info"
: b.status === "fulfilled"
? "success"
: b.status === "no_show"
? "critical"
: "read-only"
}
>
{b.status}
</Badge>
</InlineStack>
);
})}
</BlockStack>
</Box>
))
)}
</Card>
</BlockStack>
</Page>
);
}