datewise/app/routes/app.runsheet.tsx
metatroncubeswdev c102594ca5 feat(security): log staff access to customers' personal data
DataAccessLog records bookings list/view/export and run-sheet views (actor, action,
target, time); pruned after 12 months by the worker; migration 0003.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:44 -04:00

115 lines
5.7 KiB
TypeScript
Raw Permalink 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 { logAccess } from "../lib/audit.server";
import { niceDate, slotLabel } from "../lib/format";
import { rowToBooking } from "../services/reminders.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { cfg, features, actor } = 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 };
logAccess(cfg.shop.id, actor, "runsheet.view", `${loc.id} ${date}`);
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>
);
}