- 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>
342 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|