113 lines
5.6 KiB
TypeScript
113 lines
5.6 KiB
TypeScript
import type { LoaderFunctionArgs } from "react-router";
|
||
import { Form, useLoaderData } from "react-router";
|
||
import prisma from "../db.server";
|
||
import { buildRunSheet, toCsv } from "../engine/runsheet";
|
||
import { localParts } from "../engine/time";
|
||
import { adminContext } from "../lib/admin.server";
|
||
import { niceDate, slotLabel } from "../lib/format";
|
||
import { rowToBooking } from "../services/reminders.server";
|
||
|
||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||
const { cfg, features } = await adminContext(request);
|
||
const sp = new URL(request.url).searchParams;
|
||
const today = localParts(Date.now(), cfg.shop.timezone).date;
|
||
const date = sp.get("date") || today;
|
||
const delivering = cfg.locations.filter((l) => l.methods.delivery?.enabled);
|
||
const loc = delivering.find((l) => l.id === sp.get("location")) ?? delivering[0];
|
||
if (!loc) return { empty: true as const };
|
||
const rows = await prisma.booking.findMany({ where: { shopId: cfg.shop.id, locationId: loc.id, date, method: "delivery", status: "confirmed" } });
|
||
const sheet = buildRunSheet(rows.map(rowToBooking), loc, date);
|
||
const csv = toCsv(
|
||
sheet.stops.map((s) => ({
|
||
stop: s.seq,
|
||
window: `${s.booking.slotStart}-${s.booking.slotEnd}`,
|
||
order: s.booking.orderName ?? "",
|
||
customer: s.booking.customer?.name ?? "",
|
||
phone: s.booking.customer?.phone ?? "",
|
||
address: [s.booking.address?.line1, s.booking.address?.city, s.booking.address?.postal].filter(Boolean).join(", "),
|
||
items: (s.booking.items ?? []).map((i) => `${i.quantity}x ${i.title}`).join("; "),
|
||
leg_km: s.legKm?.toFixed(1) ?? "",
|
||
})),
|
||
);
|
||
if (sp.get("format") === "csv" && features.runSheets)
|
||
return new Response(csv, {
|
||
headers: { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="run-sheet-${date}.csv"` },
|
||
}) as never;
|
||
return {
|
||
empty: false as const,
|
||
allowed: features.runSheets,
|
||
date,
|
||
locationId: loc.id,
|
||
locations: delivering.map((l) => ({ id: l.id, name: l.name })),
|
||
sheet: { ...sheet, stops: sheet.stops.map((s) => ({ seq: s.seq, legKm: s.legKm, window: s.window, b: s.booking })) },
|
||
csvHref: `/app/runsheet?date=${date}&location=${loc.id}&format=csv`,
|
||
};
|
||
};
|
||
|
||
export default function RunSheet() {
|
||
const d = useLoaderData<typeof loader>();
|
||
if (d.empty)
|
||
return (
|
||
<s-page heading="Driver run sheet">
|
||
<s-section><s-paragraph>Turn on local delivery for a location to build run sheets.</s-paragraph></s-section>
|
||
</s-page>
|
||
);
|
||
const { sheet } = d;
|
||
return (
|
||
<s-page heading={`Run sheet · ${niceDate(d.date, true)}`}>
|
||
{d.allowed && <s-button slot="secondary-actions" href={d.csvHref} target="_blank">Download CSV</s-button>}
|
||
{!d.allowed && <s-banner tone="info" heading="Growth plan feature">Run sheets and route links. <s-link href="/app/settings#plan">See plans</s-link></s-banner>}
|
||
<s-section>
|
||
<Form method="get">
|
||
<s-stack direction="inline" gap="base" alignItems="end">
|
||
<s-date-field name="date" label="Date" value={d.date} />
|
||
<s-select name="location" label="From" value={d.locationId}>
|
||
{d.locations.map((l) => <s-option key={l.id} value={l.id}>{l.name}</s-option>)}
|
||
</s-select>
|
||
<s-button type="submit">Show</s-button>
|
||
</s-stack>
|
||
</Form>
|
||
</s-section>
|
||
<s-section heading={`${sheet.stops.length} stops · about ${sheet.totalKm ?? 0} km`}>
|
||
<s-stack gap="base">
|
||
<s-paragraph>Stops are grouped by promised window, then ordered nearest-first. Distances are straight-line estimates; open a leg in Google Maps for turn-by-turn directions.</s-paragraph>
|
||
{d.allowed && (
|
||
<s-stack direction="inline" gap="base">
|
||
{sheet.mapsUrls.map((u, i) => <s-button key={u} href={u} target="_blank">Open leg {i + 1} in Google Maps</s-button>)}
|
||
</s-stack>
|
||
)}
|
||
{sheet.unlocated.length > 0 && (
|
||
<s-banner tone="warning">{sheet.unlocated.length} order(s) have no map position (checkout didn't geocode the address): {sheet.unlocated.map((b) => b.orderName).join(", ")}. Add them by hand.</s-banner>
|
||
)}
|
||
</s-stack>
|
||
</s-section>
|
||
<s-section padding="none">
|
||
<s-table>
|
||
<s-table-header-row>
|
||
<s-table-header>#</s-table-header>
|
||
<s-table-header>Window</s-table-header>
|
||
<s-table-header listSlot="primary">Order</s-table-header>
|
||
<s-table-header>Customer</s-table-header>
|
||
<s-table-header>Address</s-table-header>
|
||
<s-table-header format="numeric">Leg</s-table-header>
|
||
<s-table-header>Items</s-table-header>
|
||
</s-table-header-row>
|
||
<s-table-body>
|
||
{sheet.stops.map((s) => (
|
||
<s-table-row key={s.b.id}>
|
||
<s-table-cell>{s.seq}</s-table-cell>
|
||
<s-table-cell>{slotLabel(s.b.slotStart, s.b.slotEnd)}</s-table-cell>
|
||
<s-table-cell><s-link href={`/app/bookings/${s.b.id}`}>{s.b.orderName}</s-link></s-table-cell>
|
||
<s-table-cell>{[s.b.customer?.name, s.b.customer?.phone].filter(Boolean).join(" · ")}</s-table-cell>
|
||
<s-table-cell>{[s.b.address?.line1, s.b.address?.postal].filter(Boolean).join(", ")}</s-table-cell>
|
||
<s-table-cell>{s.legKm != null ? `${s.legKm.toFixed(1)} km` : "—"}</s-table-cell>
|
||
<s-table-cell>{(s.b.items ?? []).map((i) => `${i.quantity}× ${i.title}`).join(", ")}</s-table-cell>
|
||
</s-table-row>
|
||
))}
|
||
</s-table-body>
|
||
</s-table>
|
||
</s-section>
|
||
</s-page>
|
||
);
|
||
}
|