datewise/app/routes/app.runsheet.tsx
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

113 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}