delivery slot creation

This commit is contained in:
2026-10-06 17:37:02 +05:30
parent 990dd0dc23
commit 56e8a95c6a
7 changed files with 498 additions and 2 deletions

101
src/api/deliverySlots.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* When a branch delivers.
*
* A branch offers at most three windows a day — morning, afternoon, evening —
* and a shopper picks one at checkout. The window is a PREFERENCE, not a
* promise: every order is accepted and no window ever fills up.
*
* ── A branch with no windows is not broken ──────────────────────────────────
*
* Every tenant trading today has none, and all of them keep taking orders.
* An empty list means "order without a window", never "this shop is closed".
* Nothing here may treat it as an error state.
*
* ── The console edits; it does not decide what is open ──────────────────────
*
* Whether a window is still open today is a clock comparison, and the server
* owns it — see deliverySlotService.go. This module reads what a branch has
* configured and writes it back. The filtered, dated list a shopper sees is an
* app concern and is not fetched here.
*/
import { api, WEB } from './client';
/** The three, in the order a shopper reads them. */
export const SLOT_KEYS = ['morning', 'afternoon', 'evening'] as const;
export type SlotKey = (typeof SLOT_KEYS)[number];
export interface DeliverySlot {
deliveryslotid?: number;
tenantid?: number;
locationid?: number;
slotkey: SlotKey;
/** What the shopper reads. Blank falls back to the capitalised key. */
name: string;
/** "HH:MM", 24-hour, in the shop's own local time. */
starttime: string;
/**
* Also the cut-off. A window takes orders right up to the moment it ends —
* there is deliberately no separate cut-off to configure.
*/
endtime: string;
status: 'active' | 'inactive';
}
/**
* What a branch starts with when nobody has set anything.
*
* Seeded rather than blank so onboarding asks a shopkeeper to CONFIRM rather
* than to invent: three empty time fields is a form most people abandon, and a
* tenant that abandons it has a shop that cannot offer windows at all.
*/
export const DEFAULT_SLOTS: DeliverySlot[] = [
{ slotkey: 'morning', name: 'Morning', starttime: '08:00', endtime: '10:00', status: 'active' },
{ slotkey: 'afternoon', name: 'Afternoon', starttime: '12:00', endtime: '15:00', status: 'active' },
{ slotkey: 'evening', name: 'Evening', starttime: '17:00', endtime: '20:00', status: 'active' },
];
export const deliverySlotsApi = {
/** Everything this branch has configured, active or not. */
list: (tenantid: number, locationid: number) =>
api.get<{ details: DeliverySlot[] }>(`${WEB}/deliveryslots`, { tenantid, locationid }),
/**
* All three together, never one at a time.
*
* They are edited as a set on one screen, and sending them together is what
* lets the server reject the whole edit when one row is wrong instead of
* applying half of it — a shop with two new windows and one old one, and
* nothing on screen saying which took, is worse than a shop with none.
*/
save: (tenantid: number, locationid: number, slots: DeliverySlot[]) =>
api.put<unknown>(`${WEB}/deliveryslots`, { tenantid, locationid, slots }),
};
/**
* Is this set fit to send?
*
* Mirrors the server's rules so the shopkeeper hears about a mistake while
* their hands are still on it, rather than as a 409 after pressing save. The
* server re-checks all of it — this is courtesy, not security.
*/
export function slotProblems(slots: DeliverySlot[]): string[] {
const problems: string[] = [];
for (const slot of slots) {
const label = slot.name.trim() || slot.slotkey;
if (!/^\d{2}:\d{2}$/.test(slot.starttime) || !/^\d{2}:\d{2}$/.test(slot.endtime)) {
problems.push(`${label} needs a start and end time.`);
continue;
}
// An end at or before its start never passes the server's "still running"
// test, so the window would simply never appear to a shopper, with nothing
// saying why.
if (slot.endtime <= slot.starttime) {
problems.push(`${label} ends at or before it starts (${slot.starttime}–${slot.endtime}).`);
}
}
return problems;
}

View File

@@ -685,6 +685,19 @@ export interface OrderRow {
* filters the whole list away — the drop address is the real test.
*/
deliverytype?: string;
/**
* The delivery window the customer chose, and the day it falls on.
*
* Absent on every order placed before windows shipped, and on every order
* from a branch that has set none — which is most of them. Treat absence as
* "no window was asked for", never as a problem with the order.
*
* Distinct from `deliverytime` above, which is a timestamp of what happened.
* These two say what was asked for.
*/
deliveryslotid?: number;
deliveryslotdate?: string;
}
/**

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { errorMessage } from '@/api/client';
import { DEFAULT_SLOTS, deliverySlotsApi, slotProblems, type DeliverySlot } from '@/api/deliverySlots';
import { DeliverySlotsEditor } from './DeliverySlotsEditor';
import { DrawerButton } from './drawerKit';
/**
* A branch's delivery windows, on the shop profile.
*
* Owns its own fetch and save rather than joining the page's one big record:
* the windows live in their own table with their own endpoint, and folding them
* into the profile's save would mean a failed window edit rolling back a
* perfectly good change of phone number.
*
* ── Editing never moves an order already placed ─────────────────────────────
*
* The server upserts by (tenant, branch, key) and keeps slot ids stable, so an
* order that chose this morning still points at this morning. Changing the
* hours changes what FUTURE shoppers are offered, not what past ones agreed to.
* Worth knowing before anyone decides to make this a delete-and-recreate.
*/
export function DeliverySlotsCard({
tenantid,
locationid,
}: {
tenantid: number;
locationid: number;
}) {
const query = useQuery({
queryKey: ['deliveryslots', tenantid, locationid],
queryFn: () => deliverySlotsApi.list(tenantid, locationid),
enabled: tenantid > 0,
staleTime: 60_000,
});
/*
The working copy.
Seeded from the server once it answers, and from the defaults when the
branch has none — which is every branch until somebody sets them. Local so
a half-finished edit is not thrown away by a background refetch.
*/
const [draft, setDraft] = useState<DeliverySlot[] | null>(null);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!query.data) return;
const fromServer = query.data.details ?? [];
setDraft(fromServer.length > 0 ? fromServer : DEFAULT_SLOTS);
}, [query.data]);
const save = useMutation({
mutationFn: (slots: DeliverySlot[]) => deliverySlotsApi.save(tenantid, locationid, slots),
onMutate: () => {
setError(null);
setSaved(false);
},
onSuccess: () => {
setSaved(true);
void query.refetch();
},
// A write that fails silently would leave a shopkeeper believing their
// delivery hours had changed when they had not — and the people who find
// out are customers.
onError: (cause) => setError(errorMessage(cause)),
});
if (query.isLoading || !draft) {
return <p className="sp-empty">Loading delivery windows…</p>;
}
const problems = slotProblems(draft);
const hasNone = (query.data?.details ?? []).length === 0;
return (
<div>
<DeliverySlotsEditor
slots={draft}
onChange={(next) => {
setDraft(next);
setSaved(false);
}}
isDisabled={save.isPending}
{...(hasNone
? {
// Said plainly, because the difference matters: this branch is
// taking orders right now with no window chosen, and will keep
// doing so until these are saved.
intro:
'This branch has no delivery windows yet, so customers order without choosing a time. Set them below to start offering a choice.',
}
: {})}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 14 }}>
<DrawerButton
label={save.isPending ? 'Saving…' : 'Save delivery windows'}
variant="primary"
isDisabled={save.isPending || problems.length > 0}
onClick={() => save.mutate(draft)}
/>
{saved ? (
<span style={{ fontSize: 12, color: 'var(--color-ok, #2f855a)' }}>Saved.</span>
) : null}
{error ? (
<span style={{ fontSize: 12, color: 'var(--color-error, #d64545)' }}>{error}</span>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,161 @@
import { Switch } from '@astryxdesign/core/Switch';
import { DEFAULT_SLOTS, slotProblems, type DeliverySlot, type SlotKey } from '@/api/deliverySlots';
import { Note } from './drawerKit';
/**
* The three delivery windows a branch offers, as an editable block.
*
* Used in two places that look the same to a shopkeeper and are quite different
* underneath: onboarding, where the windows are part of a form that creates a
* branch, and the shop profile, where they are saved on their own. This
* component owns neither the saving nor the fetching — it is handed a value and
* reports changes, so both callers can decide what "save" means for them.
*
* ── Three fixed rows, not a list ────────────────────────────────────────────
*
* There is no add or remove. A shop has a morning, an afternoon and an evening,
* and the app has exactly three places to show them. Building this as a generic
* schedule editor would offer a shopkeeper a fourth window that nothing can
* render, and the cost of that discovery lands on a customer who picked it.
*
* ── Why each row has an on/off ──────────────────────────────────────────────
*
* A shop that does not do evenings needs a way to say so that is not deleting
* the row. Orders already placed against a window still have to resolve to
* something with a name, so a window is switched off and kept, never removed.
*/
export function DeliverySlotsEditor({
slots,
onChange,
isDisabled,
/** Shown above the rows. Onboarding and the profile screen say different things. */
intro,
}: {
slots: DeliverySlot[];
onChange: (slots: DeliverySlot[]) => void;
isDisabled?: boolean;
intro?: string;
}) {
const problems = slotProblems(slots);
function update(key: SlotKey, patch: Partial<DeliverySlot>) {
onChange(slots.map((slot) => (slot.slotkey === key ? { ...slot, ...patch } : slot)));
}
// Ordered by the constant, not by whatever order the server returned, so the
// rows do not reshuffle between a fresh branch and a saved one.
const ordered = DEFAULT_SLOTS.map(
(fallback) => slots.find((slot) => slot.slotkey === fallback.slotkey) ?? fallback,
);
return (
<div>
<Note>
{intro ??
'Customers choose one of these when they order. A window takes orders right up until it ends — after that it disappears for the day and only the later windows are offered.'}
</Note>
<div style={{ display: 'grid', gap: 2, marginTop: 12 }}>
{ordered.map((slot) => (
<SlotRow
key={slot.slotkey}
slot={slot}
isDisabled={Boolean(isDisabled)}
onChange={(patch) => update(slot.slotkey, patch)}
/>
))}
</div>
{/* Mirrors the server's own checks so a mistake is caught while the
shopkeeper's hands are still on it, rather than as a 409 after save. */}
{problems.length > 0 ? (
<ul
style={{
margin: '10px 0 0',
paddingLeft: 18,
fontSize: 11.5,
color: 'var(--color-error, #d64545)',
}}
>
{problems.map((problem) => (
<li key={problem}>{problem}</li>
))}
</ul>
) : null}
</div>
);
}
function SlotRow({
slot,
onChange,
isDisabled,
}: {
slot: DeliverySlot;
onChange: (patch: Partial<DeliverySlot>) => void;
isDisabled: boolean;
}) {
const isOff = slot.status !== 'active';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '11px 0',
borderTop: '1px solid var(--color-line, #e0e4ea)',
// A window that is off stays legible but stops competing for attention.
opacity: isOff ? 0.55 : 1,
}}
>
<input
value={slot.name}
onChange={(event) => onChange({ name: event.target.value })}
placeholder={slot.slotkey}
disabled={isDisabled}
aria-label={`Name for the ${slot.slotkey} window`}
style={{ ...inputStyle, flex: 1, minWidth: 0 }}
/>
<input
type="time"
value={slot.starttime}
onChange={(event) => onChange({ starttime: event.target.value })}
disabled={isDisabled || isOff}
aria-label={`Start of the ${slot.slotkey} window`}
style={{ ...inputStyle, width: 104 }}
/>
<span style={{ color: 'var(--color-ink-4)', fontSize: 12 }}>to</span>
<input
type="time"
value={slot.endtime}
onChange={(event) => onChange({ endtime: event.target.value })}
disabled={isDisabled || isOff}
aria-label={`End of the ${slot.slotkey} window — also when it stops taking orders`}
style={{ ...inputStyle, width: 104 }}
/>
{/* Label hidden: the row's name field says which window this is, and
repeating "Offer the morning window" three times down a block of three
is the same sentence three times. */}
<Switch
label={`Offer the ${slot.slotkey} window`}
isLabelHidden
value={!isOff}
onChange={(on: boolean) => onChange({ status: on ? 'active' : 'inactive' })}
isDisabled={isDisabled}
size="sm"
/>
</div>
);
}
const inputStyle: React.CSSProperties = {
padding: '7px 9px',
fontSize: 13,
border: '1px solid var(--color-line, #d7dce5)',
borderRadius: 7,
background: 'var(--color-surface, #fff)',
color: 'var(--color-ink-1)',
};

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react';
import { ArrowDown, Bike, Check, MapPin, Phone, X } from 'lucide-react';
import { ArrowDown, Bike, Check, Clock, MapPin, Phone, X } from 'lucide-react';
import type { DeliveryRow, OrderItem, OrderRow } from '@/api/types';
import { orderStage, type Stage } from './orderProgress';
import { useDeliveryMoves } from './DeliveryProgress';
@@ -284,6 +284,23 @@ function Sheet({
</Section>
) : null}
{/* The window the customer asked for.
Shown only when one was chosen: most orders have none, and an empty
"Delivery window —" row on every one of them would be noise that
hides the orders where it matters. */}
{'deliveryslotdate' in row && (row as { deliveryslotdate?: string }).deliveryslotdate ? (
<Section title="Delivery window">
<DrawerCard tone="subtle">
<Row
label="Requested"
value={(row as { deliveryslotdate?: string }).deliveryslotdate}
icon={<Clock size={14} />}
/>
</DrawerCard>
</Section>
) : null}
{row.ordernotes || job?.notes ? (
<Section title="Notes">
<DrawerCard tone="subtle">

View File

@@ -1,6 +1,8 @@
import { useMemo, useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DEFAULT_SLOTS, deliverySlotsApi, slotProblems, type DeliverySlot } from '@/api/deliverySlots';
import { DeliverySlotsEditor } from '../DeliverySlotsEditor';
import { Button } from '@astryxdesign/core/Button';
import { Card } from '@astryxdesign/core/Card';
import { HStack } from '@astryxdesign/core/HStack';
@@ -77,6 +79,17 @@ export function OnboardBranchPage() {
const defaultTenantId = branchScope?.tenantid ? String(branchScope.tenantid) : '';
/*
The branch's three delivery windows.
Held apart from `form`, whose fields are all strings bound to inputs, and
seeded with the defaults so a shopkeeper CONFIRMS hours rather than invents
them — three empty time fields is a form most people skip, and a branch that
skips it cannot offer a window at all.
*/
const [slots, setSlots] = useState<DeliverySlot[]>(DEFAULT_SLOTS);
const [slotWarning, setSlotWarning] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(() => ({
...EMPTY,
tenantid: defaultTenantId,
@@ -150,8 +163,32 @@ export function OnboardBranchPage() {
const mutation = useMutation({
mutationFn: (body: CreateBranchRequest) => tenantsApi.createBranch(body),
onSuccess: async () => {
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.tenants.all });
/*
The windows, written AFTER the branch, because they need its id.
Deliberately not fatal. The branch exists and can trade the moment this
returns; failing to write its windows leaves it ordering without one,
which is the ordinary state of every branch on the platform today. So
this warns and moves on rather than making a working branch look like a
failed one — the windows can be set from the shop profile at any time.
*/
const newLocationId = Number(result?.branch?.locationid ?? 0);
if (newLocationId > 0 && slotProblems(slots).length === 0) {
try {
await deliverySlotsApi.save(
Number(form.tenantid || defaultTenantId),
newLocationId,
slots,
);
} catch (cause) {
setSlotWarning(
`The branch was created, but its delivery windows were not saved (${errorMessage(cause)}). You can set them from the store profile.`,
);
}
}
},
onError: (cause) => setError(errorMessage(cause)),
});
@@ -495,9 +532,51 @@ export function OnboardBranchPage() {
step={5}
/>
</div>
{/* Separate from the open/close hours above, which say when the
SHOP is open. These say when it delivers, and a shopper picks
one of them at checkout. */}
<div style={{ marginTop: 16 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--color-ink-1, #0f172a)',
}}
>
Delivery windows
</span>
<DeliverySlotsEditor
slots={slots}
onChange={setSlots}
isDisabled={mutation.isPending}
intro="Customers choose one of these when they order from this branch. A window takes orders right up until it ends. You can change them later from the store profile."
/>
</div>
</VStack>
</Card>
{/* The branch was created but its windows were not.
A warning, not an error: the branch is live and trading, and this
is recoverable from the store profile. Styled apart from the error
alert below so the two are not read as the same severity. */}
{slotWarning ? (
<HStack
align="center"
gap={1}
padding={1.5}
style={{
background: 'var(--color-warn-muted, #fdf6e3)',
borderRadius: 8,
color: 'var(--color-warn, #8a6d1f)',
fontSize: 12.5,
}}
>
{slotWarning}
</HStack>
) : null}
{/* Error Alert */}
{error ? (
<HStack

View File

@@ -41,6 +41,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { DeliverySlotsCard } from '../DeliverySlotsCard';
import { Button } from '@astryxdesign/core/Button';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Text } from '@astryxdesign/core/Text';
@@ -55,6 +56,7 @@ import {
Pencil,
Phone,
ShieldCheck,
Clock,
Sliders,
Store,
TriangleAlert,
@@ -603,6 +605,16 @@ export function ShopProfilePage() {
trades under, whether it is approved — because knowing what is set
and who sets it is more useful than a page of disabled inputs. */
<div className="sp-cards">
{/* First on the tab, because it is the only thing here a merchant
can change. Everything below it is set by Nearle and shown so
they can see what it is, not so they can edit it. */}
<ProfileCard icon={<Clock size={15} />} title="Delivery windows" span="full">
<DeliverySlotsCard
tenantid={Number(record['tenantid'] ?? 0)}
locationid={Number(record['locationid'] ?? 0)}
/>
</ProfileCard>
<ProfileCard icon={<Sliders size={15} />} title="Store configuration" span="full">
<Row label="App location" value={text('applocation')} />
<Row