diff --git a/src/api/tenants.ts b/src/api/tenants.ts index 16702ea..85cdc12 100644 --- a/src/api/tenants.ts +++ b/src/api/tenants.ts @@ -267,6 +267,29 @@ export const tenantsApi = { contactno?: string; email?: string; }) => api.put(`${WEB}/tenants/updateownprofile`, body), + + /** + * Open or close a branch for the day. + * + * `isopen: false` with no `closeduntil` means closed until somebody says + * otherwise — the right shape for a power cut nobody can put an end to. + * With a date, the branch reopens on that date BY ITSELF: the server works + * the answer out on read rather than running a job, so nobody has to + * remember. + * + * Reopening clears any date, so a branch switched back on cannot keep a + * stale "reopens on" that would read as still closed. + * + * Separate from the branch's `status`, which is how Nearle decommissions a + * branch. A day off must not look like a shop that shut down. + */ + setStoreOpen: (body: { + tenantid: number; + locationid: number; + isopen: boolean; + /** "YYYY-MM-DD", the first day back. Omitted when reopening. */ + closeduntil?: string; + }) => api.put(`${WEB}/tenants/storeopen`, body), }; /** diff --git a/src/api/types.ts b/src/api/types.ts index f51ade3..06a8e2f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -233,6 +233,20 @@ export interface TenantLocation { deliverymins?: number; cancelsecs?: number; status: string; + /** + * Whether this branch is trading today. + * + * Separate from `status`, which is whether the branch exists at all — + * setting that to Inactive is how a branch is decommissioned. A shopkeeper + * closing for an afternoon is saying something else, and conflating the two + * would make a day off read as a shop that shut down. + * + * `closeduntil` is the first day back; the server works out on read whether + * that date has passed, so a branch reopens without anybody remembering. + */ + isopen?: boolean; + closeduntil?: string; + } /* ──────────────────────────────────────────────────────────────────────────── diff --git a/src/features/store-admin/StoreOpenControl.tsx b/src/features/store-admin/StoreOpenControl.tsx new file mode 100644 index 0000000..2d919ea --- /dev/null +++ b/src/features/store-admin/StoreOpenControl.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Switch } from '@astryxdesign/core/Switch'; +import { Button } from '@astryxdesign/core/Button'; +import { errorMessage } from '@/api/client'; +import { tenantsApi } from '@/api/tenants'; +import { queryKeys } from '@/queries/keys'; + +/** + * Open or close this branch for the day. + * + * ── Why it sits in the page header ────────────────────────────────────────── + * + * This is reached for in a hurry — the power has gone, somebody is ill, the + * delivery never came — and usually on a phone. A control for that cannot be + * two taps into a Settings tab. Everything else on this page is edited once a + * year; this is the one thing a shopkeeper touches on a bad morning. + * + * ── Closing is confirmed, opening is not ──────────────────────────────────── + * + * Closing stops the shop taking money, and a mis-tap doing that silently is + * the failure worth preventing. Opening again is harmless, so it is one tap. + * The confirmation is also where the reopening date is offered, because + * "closed until Monday" is the answer most people actually want and nobody + * thinks to ask for it before they have pressed close. + * + * ── Not shown to a branch user ────────────────────────────────────────────── + * + * Gated by the caller. A branch user runs one shop floor; whether the business + * trades today is the owner's call, and Nearle's. + */ +export function StoreOpenControl({ + tenantid, + locationid, + isOpen, + closedUntil, +}: { + tenantid: number; + locationid: number; + /** The stored flag, not the computed one — this control sets what it shows. */ + isOpen: boolean; + closedUntil?: string | undefined; +}) { + const client = useQueryClient(); + + /* + What this control believes it is set to. + + Local, because the record behind this page is fetched once and does not + change under it. Without this the switch would read the same value before + and after a tap, and a save that worked would look exactly like one that + did not. + */ + const [open, setOpen] = useState(isOpen); + const [confirming, setConfirming] = useState(false); + const [until, setUntil] = useState(''); + const [error, setError] = useState(null); + + // Re-seed if the page refetches with a different answer, but never while the + // confirmation is up — that would move the switch under the shopkeeper's hand. + useEffect(() => { + if (!confirming) setOpen(isOpen); + }, [isOpen, confirming]); + + const save = useMutation({ + mutationFn: (next: { isopen: boolean; closeduntil?: string }) => + tenantsApi.setStoreOpen({ tenantid, locationid, ...next }), + onMutate: () => setError(null), + onSuccess: (_data, next) => { + setOpen(next.isopen); + setConfirming(false); + setUntil(''); + void client.invalidateQueries({ queryKey: queryKeys.tenants.all }); + }, + // A close that fails silently is the worst outcome here: the shopkeeper + // walks away believing the shop is shut while it keeps taking orders. + onError: (cause) => { + setOpen(isOpen); + setError(errorMessage(cause)); + }, + }); + + const shownClosedUntil = (closedUntil ?? '').slice(0, 10); + + return ( +
+ { + if (next) { + // Reopening is harmless, so it happens on the tap. + save.mutate({ isopen: true }); + } else { + setOpen(false); + setConfirming(true); + } + }} + isLoading={save.isPending} + size="sm" + labelSpacing="spread" + /> + + {confirming ? ( +
+ + Customers will not be able to order from this branch. + + + + +
+
+
+ ) : null} + + {error ? ( + {error} + ) : null} +
+ ); +} + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** "2026-10-12" → "12 Oct". Falls back to the raw value rather than printing + "Invalid Date" beside a shop's trading status. */ +function friendly(date: string): string { + const parsed = new Date(`${date}T00:00:00`); + if (Number.isNaN(parsed.getTime())) return date; + return parsed.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }); +} diff --git a/src/features/store-admin/pages/ShopProfilePage.tsx b/src/features/store-admin/pages/ShopProfilePage.tsx index ab2174c..5295f9b 100644 --- a/src/features/store-admin/pages/ShopProfilePage.tsx +++ b/src/features/store-admin/pages/ShopProfilePage.tsx @@ -42,6 +42,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { DeliverySlotsCard } from '../DeliverySlotsCard'; +import { StoreOpenControl } from '../StoreOpenControl'; import { Button } from '@astryxdesign/core/Button'; import { NumberInput } from '@astryxdesign/core/NumberInput'; import { Text } from '@astryxdesign/core/Text'; @@ -249,17 +250,33 @@ export function ShopProfilePage() { Store profile & basic information -