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>
This commit is contained in:
parent
6598691372
commit
c5ec8f368c
@ -53,9 +53,9 @@ public launch or Built-for-Shopify submission** — don't ship without it.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phase 0 (scaffold & CI) through Phase 5 (zones, rates, auto-assignment) are
|
Phase 0 (scaffold & CI) through Phase 6 (ops/dispatch dashboard) are
|
||||||
complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and
|
complete. See §6 of `IMPLEMENTATION_PLAN.md` for the phased build order and
|
||||||
acceptance criteria — next up is Phase 6 (ops/dispatch dashboard).
|
acceptance criteria — next up is Phase 7 (POS + Checkout UI extensions).
|
||||||
|
|
||||||
Phase 5's Google Maps / geocoding features (pickup-location map in the
|
Phase 5's Google Maps / geocoding features (pickup-location map in the
|
||||||
widget, radius-zone eligibility, address auto-geocoding on Save Location)
|
widget, radius-zone eligibility, address auto-geocoding on Save Location)
|
||||||
|
|||||||
58
app/routes/app.dashboard.export.tsx
Normal file
58
app/routes/app.dashboard.export.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
import { authenticate } from "../shopify.server";
|
||||||
|
import db from "../db.server";
|
||||||
|
import { bookingsToCsv, type BookingSummary } from "../services/dashboard.server";
|
||||||
|
|
||||||
|
// Resource route (no default export/component) — safe to hit directly for
|
||||||
|
// a file download, and safe to import dashboard.server.ts freely since
|
||||||
|
// there's no client component here for Remix to worry about bundling it into.
|
||||||
|
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 bookings = await db.booking.findMany({
|
||||||
|
where: {
|
||||||
|
shopDomain: session.shop,
|
||||||
|
locationId: locationIdFilter,
|
||||||
|
method: methodFilter,
|
||||||
|
status: statusFilter,
|
||||||
|
slotStart: { gte: rangeStart, lt: rangeEnd },
|
||||||
|
},
|
||||||
|
include: { location: true },
|
||||||
|
orderBy: { slotStart: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
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 csv = bookingsToCsv(summaries);
|
||||||
|
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="dispatch-${startDate}-to-${endDate}.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
341
app/routes/app.dashboard.tsx
Normal file
341
app/routes/app.dashboard.tsx
Normal file
@ -0,0 +1,341 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -29,6 +29,7 @@ export default function App() {
|
|||||||
<Link to="/app/blackouts">Blackout dates</Link>
|
<Link to="/app/blackouts">Blackout dates</Link>
|
||||||
<Link to="/app/zones">Delivery zones</Link>
|
<Link to="/app/zones">Delivery zones</Link>
|
||||||
<Link to="/app/rates">Delivery rates</Link>
|
<Link to="/app/rates">Delivery rates</Link>
|
||||||
|
<Link to="/app/dashboard">Dispatch dashboard</Link>
|
||||||
</NavMenu>
|
</NavMenu>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppProvider>
|
</AppProvider>
|
||||||
|
|||||||
@ -16,9 +16,16 @@ export interface OrderWebhookPayload {
|
|||||||
cart_token?: string | null;
|
cart_token?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
total_price?: string | null; // e.g. "49.99" — Shopify sends this as a decimal string, not cents
|
||||||
note_attributes?: OrderNoteAttribute[];
|
note_attributes?: OrderNoteAttribute[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parsePriceCents(totalPrice: string | null | undefined): number | undefined {
|
||||||
|
if (!totalPrice) return undefined;
|
||||||
|
const value = Number(totalPrice);
|
||||||
|
return Number.isFinite(value) ? Math.round(value * 100) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined {
|
function readAttr(attrs: OrderNoteAttribute[], key: string): string | undefined {
|
||||||
return attrs.find((a) => a.name === key)?.value;
|
return attrs.find((a) => a.name === key)?.value;
|
||||||
}
|
}
|
||||||
@ -71,6 +78,7 @@ export async function createBookingFromOrder(shopDomain: string, order: OrderWeb
|
|||||||
slotEnd: slotEnd.toJSDate(),
|
slotEnd: slotEnd.toJSDate(),
|
||||||
customerEmail: order.email ?? undefined,
|
customerEmail: order.email ?? undefined,
|
||||||
customerPhone: order.phone ?? undefined,
|
customerPhone: order.phone ?? undefined,
|
||||||
|
totalPriceCents: parsePriceCents(order.total_price),
|
||||||
},
|
},
|
||||||
update: {}, // redelivered webhook — the booking already exists, nothing to change
|
update: {}, // redelivered webhook — the booking already exists, nothing to change
|
||||||
});
|
});
|
||||||
|
|||||||
171
app/services/dashboard.server.ts
Normal file
171
app/services/dashboard.server.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { Method } from "@prisma/client";
|
||||||
|
|
||||||
|
// Pure aggregation for the ops/dispatch dashboard (IMPLEMENTATION_PLAN.md
|
||||||
|
// Phase 6). No DB calls — the route loader fetches Bookings/SlotTemplates
|
||||||
|
// and passes plain data in here, per CLAUDE.md's "inject data" rule.
|
||||||
|
|
||||||
|
export interface BookingSummary {
|
||||||
|
id: string;
|
||||||
|
orderName: string | null;
|
||||||
|
locationId: string;
|
||||||
|
locationName: string;
|
||||||
|
method: Method;
|
||||||
|
slotStart: Date; // UTC instant
|
||||||
|
slotEnd: Date;
|
||||||
|
timezone: string; // the booking's location's timezone, for local-date bucketing
|
||||||
|
status: string;
|
||||||
|
totalPriceCents: number | null;
|
||||||
|
customerEmail: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapacitySummary {
|
||||||
|
locationId: string;
|
||||||
|
method: Method;
|
||||||
|
weekday: number;
|
||||||
|
capacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyLocationUtilization {
|
||||||
|
date: string; // location-local ISO date
|
||||||
|
locationId: string;
|
||||||
|
locationName: string;
|
||||||
|
method: Method;
|
||||||
|
booked: number;
|
||||||
|
capacity: number;
|
||||||
|
utilizationPct: number; // 0-100, capped even if overbooked past capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDate(booking: BookingSummary): string {
|
||||||
|
return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).toISODate()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localWeekday(booking: BookingSummary): number {
|
||||||
|
return DateTime.fromJSDate(booking.slotStart, { zone: "utc" }).setZone(booking.timezone).weekday % 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups bookings by their own location's local calendar date — the basis for "bookings by day/slot/location". */
|
||||||
|
export function bucketByLocalDate(bookings: BookingSummary[]): Record<string, BookingSummary[]> {
|
||||||
|
const buckets: Record<string, BookingSummary[]> = {};
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const key = localDate(booking);
|
||||||
|
(buckets[key] ??= []).push(booking);
|
||||||
|
}
|
||||||
|
for (const list of Object.values(buckets)) {
|
||||||
|
list.sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime());
|
||||||
|
}
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirmed/fulfilled revenue, summed per method — cancelled/no_show bookings never counted. */
|
||||||
|
export function revenueByMethod(bookings: BookingSummary[]): Record<Method, number> {
|
||||||
|
const totals: Record<Method, number> = { SHIPPING: 0, LOCAL_DELIVERY: 0, PICKUP: 0 };
|
||||||
|
for (const booking of bookings) {
|
||||||
|
if (booking.status !== "confirmed" && booking.status !== "fulfilled") continue;
|
||||||
|
totals[booking.method] += booking.totalPriceCents ?? 0;
|
||||||
|
}
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacity-utilization view: for each date/location/method that has at
|
||||||
|
* least one booking, what fraction of that weekday's total slot capacity
|
||||||
|
* (summed across every matching SlotTemplate) is booked. Dates/locations
|
||||||
|
* with zero bookings are omitted — there's nothing to show a merchant for
|
||||||
|
* an empty day.
|
||||||
|
*/
|
||||||
|
export function utilizationByDateLocation(
|
||||||
|
bookings: BookingSummary[],
|
||||||
|
capacities: CapacitySummary[],
|
||||||
|
): DailyLocationUtilization[] {
|
||||||
|
const capacityByKey = new Map<string, number>();
|
||||||
|
for (const c of capacities) {
|
||||||
|
const key = `${c.locationId}|${c.method}|${c.weekday}`;
|
||||||
|
capacityByKey.set(key, (capacityByKey.get(key) ?? 0) + c.capacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookedByKey = new Map<
|
||||||
|
string,
|
||||||
|
{ date: string; locationId: string; locationName: string; method: Method; weekday: number; count: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
if (booking.status === "cancelled") continue;
|
||||||
|
const date = localDate(booking);
|
||||||
|
const weekday = localWeekday(booking);
|
||||||
|
const key = `${date}|${booking.locationId}|${booking.method}`;
|
||||||
|
const existing = bookedByKey.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.count += 1;
|
||||||
|
} else {
|
||||||
|
bookedByKey.set(key, {
|
||||||
|
date,
|
||||||
|
locationId: booking.locationId,
|
||||||
|
locationName: booking.locationName,
|
||||||
|
method: booking.method,
|
||||||
|
weekday,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...bookedByKey.values()]
|
||||||
|
.map((entry) => {
|
||||||
|
const capacity = capacityByKey.get(`${entry.locationId}|${entry.method}|${entry.weekday}`) ?? 0;
|
||||||
|
const utilizationPct = capacity > 0 ? Math.min(100, Math.round((entry.count / capacity) * 100)) : 0;
|
||||||
|
return {
|
||||||
|
date: entry.date,
|
||||||
|
locationId: entry.locationId,
|
||||||
|
locationName: entry.locationName,
|
||||||
|
method: entry.method,
|
||||||
|
booked: entry.count,
|
||||||
|
capacity,
|
||||||
|
utilizationPct,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date) || a.locationName.localeCompare(b.locationName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nearest-first list of not-yet-fulfilled bookings, for the "upcoming fulfillment" panel. */
|
||||||
|
export function upcomingFulfillments(bookings: BookingSummary[], limit = 25): BookingSummary[] {
|
||||||
|
return bookings
|
||||||
|
.filter((b) => b.status === "confirmed")
|
||||||
|
.sort((a, b) => a.slotStart.getTime() - b.slotStart.getTime())
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCsvRow(values: Array<string | number>): string {
|
||||||
|
return values
|
||||||
|
.map((v) => {
|
||||||
|
const s = String(v);
|
||||||
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
|
})
|
||||||
|
.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingsToCsv(bookings: BookingSummary[]): string {
|
||||||
|
const header = toCsvRow([
|
||||||
|
"Order",
|
||||||
|
"Date",
|
||||||
|
"Time",
|
||||||
|
"Method",
|
||||||
|
"Location",
|
||||||
|
"Status",
|
||||||
|
"Customer email",
|
||||||
|
"Total",
|
||||||
|
]);
|
||||||
|
const rows = bookings.map((b) => {
|
||||||
|
const local = DateTime.fromJSDate(b.slotStart, { zone: "utc" }).setZone(b.timezone);
|
||||||
|
return toCsvRow([
|
||||||
|
b.orderName ?? "",
|
||||||
|
local.toISODate() ?? "",
|
||||||
|
local.toFormat("h:mm a"),
|
||||||
|
b.method,
|
||||||
|
b.locationName,
|
||||||
|
b.status,
|
||||||
|
b.customerEmail ?? "",
|
||||||
|
b.totalPriceCents != null ? (b.totalPriceCents / 100).toFixed(2) : "",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
return [header, ...rows].join("\n");
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "totalPriceCents" INTEGER;
|
||||||
@ -144,6 +144,7 @@ model Booking {
|
|||||||
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
|
status String @default("confirmed") // confirmed|cancelled|fulfilled|no_show
|
||||||
customerEmail String?
|
customerEmail String?
|
||||||
customerPhone String?
|
customerPhone String?
|
||||||
|
totalPriceCents Int? // order's total_price at booking time, for the dispatch dashboard's revenue-by-method view
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([shopDomain, locationId, method, slotStart])
|
@@index([shopDomain, locationId, method, slotStart])
|
||||||
|
|||||||
@ -87,4 +87,45 @@ describe("createBookingFromOrder", () => {
|
|||||||
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
|
const count = await db.booking.count({ where: { shopDomain, orderId: "gid://shopify/Order/3" } });
|
||||||
expect(count).toBe(0);
|
expect(count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("converts total_price to integer cents for the dashboard's revenue view", async () => {
|
||||||
|
const location = await db.location.create({
|
||||||
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingFromOrder(shopDomain, {
|
||||||
|
admin_graphql_api_id: "gid://shopify/Order/4",
|
||||||
|
total_price: "49.99",
|
||||||
|
note_attributes: [
|
||||||
|
{ name: "dd_method", value: "PICKUP" },
|
||||||
|
{ name: "dd_date", value: "2026-08-25" },
|
||||||
|
{ name: "dd_start_min", value: "540" },
|
||||||
|
{ name: "dd_end_min", value: "600" },
|
||||||
|
{ name: "dd_location_id", value: location.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/4" } });
|
||||||
|
expect(booking?.totalPriceCents).toBe(4999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves totalPriceCents unset when total_price is missing", async () => {
|
||||||
|
const location = await db.location.create({
|
||||||
|
data: { shopDomain, name: "Test Location", address: "", timezone: "America/Toronto" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingFromOrder(shopDomain, {
|
||||||
|
admin_graphql_api_id: "gid://shopify/Order/5",
|
||||||
|
note_attributes: [
|
||||||
|
{ name: "dd_method", value: "PICKUP" },
|
||||||
|
{ name: "dd_date", value: "2026-08-25" },
|
||||||
|
{ name: "dd_start_min", value: "540" },
|
||||||
|
{ name: "dd_end_min", value: "600" },
|
||||||
|
{ name: "dd_location_id", value: location.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const booking = await db.booking.findUnique({ where: { orderId: "gid://shopify/Order/5" } });
|
||||||
|
expect(booking?.totalPriceCents).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
146
tests/unit/dashboard.test.ts
Normal file
146
tests/unit/dashboard.test.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
bookingsToCsv,
|
||||||
|
bucketByLocalDate,
|
||||||
|
revenueByMethod,
|
||||||
|
toCsvRow,
|
||||||
|
upcomingFulfillments,
|
||||||
|
utilizationByDateLocation,
|
||||||
|
type BookingSummary,
|
||||||
|
type CapacitySummary,
|
||||||
|
} from "../../app/services/dashboard.server";
|
||||||
|
|
||||||
|
const ZONE = "America/Toronto";
|
||||||
|
|
||||||
|
function booking(overrides: Partial<BookingSummary> = {}): BookingSummary {
|
||||||
|
return {
|
||||||
|
id: "b1",
|
||||||
|
orderName: "#1001",
|
||||||
|
locationId: "loc_1",
|
||||||
|
locationName: "Main Bakery",
|
||||||
|
method: "PICKUP",
|
||||||
|
slotStart: new Date("2026-08-25T13:00:00.000Z"), // 9 AM EDT, a Tuesday
|
||||||
|
slotEnd: new Date("2026-08-25T14:00:00.000Z"),
|
||||||
|
timezone: ZONE,
|
||||||
|
status: "confirmed",
|
||||||
|
totalPriceCents: 2500,
|
||||||
|
customerEmail: "shopper@example.com",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bucketByLocalDate", () => {
|
||||||
|
it("groups bookings under their location-local calendar date", () => {
|
||||||
|
const buckets = bucketByLocalDate([booking()]);
|
||||||
|
expect(Object.keys(buckets)).toEqual(["2026-08-25"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts bookings within a day by slot start time", () => {
|
||||||
|
const late = booking({ id: "late", slotStart: new Date("2026-08-25T20:00:00.000Z") });
|
||||||
|
const early = booking({ id: "early", slotStart: new Date("2026-08-25T13:00:00.000Z") });
|
||||||
|
const buckets = bucketByLocalDate([late, early]);
|
||||||
|
expect(buckets["2026-08-25"].map((b) => b.id)).toEqual(["early", "late"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a booking near midnight into the correct location-local day, not the UTC day", () => {
|
||||||
|
// 11 PM EDT on Aug 25 is already Aug 26 in UTC.
|
||||||
|
const b = booking({ slotStart: new Date("2026-08-26T03:00:00.000Z") });
|
||||||
|
const buckets = bucketByLocalDate([b]);
|
||||||
|
expect(Object.keys(buckets)).toEqual(["2026-08-25"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("revenueByMethod", () => {
|
||||||
|
it("sums confirmed and fulfilled bookings per method", () => {
|
||||||
|
const bookings = [
|
||||||
|
booking({ method: "PICKUP", totalPriceCents: 1000, status: "confirmed" }),
|
||||||
|
booking({ method: "PICKUP", totalPriceCents: 500, status: "fulfilled" }),
|
||||||
|
booking({ method: "LOCAL_DELIVERY", totalPriceCents: 2000, status: "confirmed" }),
|
||||||
|
];
|
||||||
|
expect(revenueByMethod(bookings)).toEqual({ SHIPPING: 0, LOCAL_DELIVERY: 2000, PICKUP: 1500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes cancelled and no_show bookings", () => {
|
||||||
|
const bookings = [
|
||||||
|
booking({ totalPriceCents: 1000, status: "cancelled" }),
|
||||||
|
booking({ totalPriceCents: 1000, status: "no_show" }),
|
||||||
|
];
|
||||||
|
expect(revenueByMethod(bookings).PICKUP).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a missing totalPriceCents as 0, not an error", () => {
|
||||||
|
expect(revenueByMethod([booking({ totalPriceCents: null })]).PICKUP).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("utilizationByDateLocation", () => {
|
||||||
|
it("computes booked/capacity percentage for a date+location+method with bookings", () => {
|
||||||
|
// 2026-08-25 is a Tuesday -> weekday 2.
|
||||||
|
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }];
|
||||||
|
const bookings = [booking(), booking({ id: "b2" })];
|
||||||
|
const result = utilizationByDateLocation(bookings, capacities);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({ date: "2026-08-25", booked: 2, capacity: 4, utilizationPct: 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sums capacity across multiple slot templates for the same weekday", () => {
|
||||||
|
const capacities: CapacitySummary[] = [
|
||||||
|
{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 },
|
||||||
|
{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 6 },
|
||||||
|
];
|
||||||
|
const result = utilizationByDateLocation([booking()], capacities);
|
||||||
|
expect(result[0].capacity).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps utilization at 100% even if overbooked", () => {
|
||||||
|
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 1 }];
|
||||||
|
const bookings = [booking(), booking({ id: "b2" }), booking({ id: "b3" })];
|
||||||
|
expect(utilizationByDateLocation(bookings, capacities)[0].utilizationPct).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes cancelled bookings from the booked count", () => {
|
||||||
|
const capacities: CapacitySummary[] = [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }];
|
||||||
|
const result = utilizationByDateLocation([booking({ status: "cancelled" })], capacities);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a date/location/method with no bookings at all", () => {
|
||||||
|
expect(utilizationByDateLocation([], [{ locationId: "loc_1", method: "PICKUP", weekday: 2, capacity: 4 }])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("upcomingFulfillments", () => {
|
||||||
|
it("returns only confirmed bookings, nearest-first", () => {
|
||||||
|
const bookings = [
|
||||||
|
booking({ id: "later", slotStart: new Date("2026-08-26T13:00:00.000Z") }),
|
||||||
|
booking({ id: "sooner", slotStart: new Date("2026-08-25T13:00:00.000Z") }),
|
||||||
|
booking({ id: "fulfilled-already", status: "fulfilled" }),
|
||||||
|
];
|
||||||
|
const result = upcomingFulfillments(bookings);
|
||||||
|
expect(result.map((b) => b.id)).toEqual(["sooner", "later"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects the limit", () => {
|
||||||
|
const bookings = Array.from({ length: 5 }, (_, i) => booking({ id: `b${i}` }));
|
||||||
|
expect(upcomingFulfillments(bookings, 2)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toCsvRow / bookingsToCsv", () => {
|
||||||
|
it("quotes a field containing a comma", () => {
|
||||||
|
expect(toCsvRow(["a,b", "c"])).toBe('"a,b",c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes an embedded quote by doubling it", () => {
|
||||||
|
expect(toCsvRow(['say "hi"'])).toBe('"say ""hi"""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces a header row plus one row per booking", () => {
|
||||||
|
const csv = bookingsToCsv([booking()]);
|
||||||
|
const lines = csv.split("\n");
|
||||||
|
expect(lines).toHaveLength(2);
|
||||||
|
expect(lines[0]).toContain("Order");
|
||||||
|
expect(lines[1]).toContain("#1001");
|
||||||
|
expect(lines[1]).toContain("25.00");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user