metatrondelivery/app/routes/app.slots._index.tsx
metatroncubeswdev 21f0ee704b feat: Phase 1 — core data model & admin CRUD
Adds the scheduling core to Prisma (Shop, Location, Method enum,
SlotTemplate, SlotOverride, BlackoutDate — IMPLEMENTATION_PLAN.md §4) and
Polaris admin screens to manage them:

- /app/locations: list/create/edit/delete locations, one-click vertical
  template seeding (bakery/florist/grocer)
- /app/slots: weekly slot template editor, scoped per location
- /app/blackouts: blackout dates, scoped to one location or all of them

services/templates.server.ts keeps the vertical presets as a pure,
unit-tested function (getVerticalTemplate) separate from the thin I/O
wrapper (seedVerticalTemplate) that does the actual Prisma writes, per
CLAUDE.md's "pure functions, inject data, no I/O in the math" rule.

Switched dev DB from SQLite to Postgres (docker-compose.yml, ports 5433/6380
to avoid clashing with other local projects already on 5432/6379): SQLite
doesn't support Prisma enums at all, and IMPLEMENTATION_PLAN.md's schema
relies on them (Method) plus Postgres-only array fields in later phases
(Zone.postalCodes, ProductRule.allowedLocationIds). Only one throwaway
migration existed, so switching now avoids compounding the rework later.

Also fixed a Remix/Polaris integration issue hit while building these forms:
this Polaris version's TextField/Checkbox are fully controlled (no
defaultValue/defaultChecked), and `data()` imported from `@remix-run/react`
(rather than `@remix-run/node`) breaks useActionData's type inference —
both are now handled correctly across the new routes.

Verified: lint, typecheck, unit tests (incl. template-seeding fixtures),
build, and a live end-to-end run of seedVerticalTemplate against the
Postgres container all pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:47:59 -04:00

269 lines
8.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 { useState } from "react";
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,
EmptyState,
} from "@shopify/polaris";
import { TitleBar } from "@shopify/app-bridge-react";
import type { Method } from "@prisma/client";
import { authenticate } from "../shopify.server";
import db from "../db.server";
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const METHODS: Method[] = ["SHIPPING", "LOCAL_DELIVERY", "PICKUP"];
function minutesToTime(min: number): string {
const h = Math.floor(min / 60)
.toString()
.padStart(2, "0");
const m = (min % 60).toString().padStart(2, "0");
return `${h}:${m}`;
}
function timeToMinutes(time: string): number {
const [h, m] = time.split(":").map(Number);
return h * 60 + m;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { session } = await authenticate.admin(request);
const url = new URL(request.url);
const locationId = url.searchParams.get("locationId");
const locations = await db.location.findMany({
where: { shopDomain: session.shop },
orderBy: { createdAt: "asc" },
});
const activeLocationId = locationId || locations[0]?.id || null;
const slotTemplates = activeLocationId
? await db.slotTemplate.findMany({
where: { shopDomain: session.shop, locationId: activeLocationId },
orderBy: [{ weekday: "asc" }, { startMin: "asc" }],
})
: [];
return { locations, activeLocationId, slotTemplates };
};
export const action = async ({ request }: ActionFunctionArgs) => {
const { session } = await authenticate.admin(request);
const formData = await request.formData();
const intent = formData.get("intent");
const locationId = String(formData.get("locationId") || "");
if (intent === "delete") {
const id = String(formData.get("id") || "");
await db.slotTemplate.deleteMany({ where: { id, shopDomain: session.shop } });
return data({ ok: true });
}
const method = formData.get("method") as Method;
const weekday = Number(formData.get("weekday"));
const startMin = timeToMinutes(String(formData.get("startTime")));
const endMin = timeToMinutes(String(formData.get("endTime")));
const capacity = Number(formData.get("capacity"));
const cutoffMin = Number(formData.get("cutoffMin") || 0);
const leadTimeMin = Number(formData.get("leadTimeMin") || 0);
const errors: Record<string, string> = {};
if (!locationId) errors.locationId = "Choose a location";
if (startMin >= endMin) errors.endTime = "End time must be after start time";
if (!capacity || capacity < 1) errors.capacity = "Capacity must be at least 1";
if (Object.keys(errors).length > 0) {
return data({ errors });
}
await db.slotTemplate.create({
data: {
shopDomain: session.shop,
locationId,
method,
weekday,
startMin,
endMin,
capacity,
cutoffMin,
leadTimeMin,
},
});
return data({ ok: true });
};
export default function SlotsIndex() {
const { locations, activeLocationId, slotTemplates } = useLoaderData<typeof loader>();
const [, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
if (locations.length === 0) {
return (
<Page>
<TitleBar title="Weekly slots" />
<Card>
<EmptyState
heading="Add a location first"
action={{ content: "Add location", url: "/app/locations/new" }}
image="https://cdn.shopify.com/s/files/1/0757/9955/files/empty-state.svg"
>
<Text as="p">Weekly slot templates belong to a location.</Text>
</EmptyState>
</Card>
</Page>
);
}
return (
<Page>
<TitleBar title="Weekly slots" />
<BlockStack gap="400">
<Card>
<Select
label="Location"
options={locations.map((l) => ({ label: l.name, value: l.id }))}
value={activeLocationId ?? undefined}
onChange={(value) => setSearchParams({ locationId: value })}
/>
</Card>
<Card padding="0">
{slotTemplates.length === 0 ? (
<div style={{ padding: 16 }}>
<Text as="p" tone="subdued">
No slot templates yet for this location. Add one below.
</Text>
</div>
) : (
<IndexTable
itemCount={slotTemplates.length}
headings={[
{ title: "Weekday" },
{ title: "Method" },
{ title: "Window" },
{ title: "Capacity" },
{ title: "Cutoff (min)" },
{ title: "Lead time (min)" },
{ title: "" },
]}
selectable={false}
>
{slotTemplates.map((slot, index) => (
<IndexTable.Row id={slot.id} key={slot.id} position={index}>
<IndexTable.Cell>{WEEKDAY_NAMES[slot.weekday]}</IndexTable.Cell>
<IndexTable.Cell>{slot.method.replace("_", " ")}</IndexTable.Cell>
<IndexTable.Cell>
{minutesToTime(slot.startMin)}{minutesToTime(slot.endMin)}
</IndexTable.Cell>
<IndexTable.Cell>{slot.capacity}</IndexTable.Cell>
<IndexTable.Cell>{slot.cutoffMin}</IndexTable.Cell>
<IndexTable.Cell>{slot.leadTimeMin}</IndexTable.Cell>
<IndexTable.Cell>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={slot.id} />
<Button submit variant="plain" tone="critical">
Remove
</Button>
</Form>
</IndexTable.Cell>
</IndexTable.Row>
))}
</IndexTable>
)}
</Card>
<AddSlotForm key={activeLocationId} locationId={activeLocationId ?? ""} isSubmitting={isSubmitting} />
</BlockStack>
</Page>
);
}
function AddSlotForm({ locationId, isSubmitting }: { locationId: string; isSubmitting: boolean }) {
const [startTime, setStartTime] = useState("09:00");
const [endTime, setEndTime] = useState("17:00");
const [capacity, setCapacity] = useState("10");
const [cutoffMin, setCutoffMin] = useState("60");
const [leadTimeMin, setLeadTimeMin] = useState("0");
return (
<Card>
<Form method="post">
<input type="hidden" name="locationId" value={locationId} />
<BlockStack gap="300">
<Text as="h3" variant="headingSm">
Add a slot template
</Text>
<InlineStack gap="300" wrap>
<Select
label="Weekday"
name="weekday"
options={WEEKDAY_NAMES.map((name, value) => ({ label: name, value: String(value) }))}
/>
<Select
label="Method"
name="method"
options={METHODS.map((m) => ({ label: m.replace("_", " "), value: m }))}
/>
<TextField
label="Start time"
name="startTime"
type="time"
value={startTime}
onChange={setStartTime}
autoComplete="off"
/>
<TextField
label="End time"
name="endTime"
type="time"
value={endTime}
onChange={setEndTime}
autoComplete="off"
/>
<TextField
label="Capacity"
name="capacity"
type="number"
value={capacity}
onChange={setCapacity}
autoComplete="off"
/>
<TextField
label="Cutoff (min)"
name="cutoffMin"
type="number"
value={cutoffMin}
onChange={setCutoffMin}
autoComplete="off"
/>
<TextField
label="Lead time (min)"
name="leadTimeMin"
type="number"
value={leadTimeMin}
onChange={setLeadTimeMin}
autoComplete="off"
/>
</InlineStack>
<div>
<Button submit variant="primary" loading={isSubmitting}>
Add slot template
</Button>
</div>
</BlockStack>
</Form>
</Card>
);
}