208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
import { useEffect, useRef, 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<string | null>(null);
|
|
|
|
/*
|
|
Re-seed only when the PROP ITSELF changes.
|
|
|
|
This used to run whenever `confirming` flipped, and that undid its own
|
|
save: closing the branch set open to false and confirming to false, the
|
|
change of `confirming` re-ran the effect, and it wrote the prop — still
|
|
true, because the page behind had not refetched yet — straight back over
|
|
the answer. The switch snapped open the instant the shop was closed.
|
|
|
|
Comparing against the last value seen means a genuinely new answer from the
|
|
server still lands, and nothing else can reach in and overwrite what the
|
|
shopkeeper just did.
|
|
*/
|
|
const lastSeen = useRef(isOpen);
|
|
useEffect(() => {
|
|
if (lastSeen.current !== isOpen) {
|
|
lastSeen.current = isOpen;
|
|
setOpen(isOpen);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: (next: { isopen: boolean; closeduntil?: string }) =>
|
|
tenantsApi.setStoreOpen({ tenantid, locationid, ...next }),
|
|
onMutate: () => setError(null),
|
|
onSuccess: (_data, next) => {
|
|
// Move the reference too. Otherwise the next render carrying the old
|
|
// prop reads as a change and reverts what was just saved.
|
|
lastSeen.current = next.isopen;
|
|
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) => {
|
|
lastSeen.current = isOpen;
|
|
setOpen(isOpen);
|
|
setError(errorMessage(cause));
|
|
},
|
|
});
|
|
|
|
const shownClosedUntil = (closedUntil ?? '').slice(0, 10);
|
|
|
|
return (
|
|
<div style={{ display: 'grid', gap: 6, justifyItems: 'end' }}>
|
|
<Switch
|
|
label={open ? 'Open today' : 'Closed today'}
|
|
description={
|
|
open
|
|
? 'Customers can order from this branch'
|
|
: shownClosedUntil
|
|
? `Customers cannot order. Reopens on ${friendly(shownClosedUntil)}`
|
|
: 'Customers cannot order from this branch'
|
|
}
|
|
value={open}
|
|
onChange={(next: boolean) => {
|
|
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 ? (
|
|
<div
|
|
style={{
|
|
display: 'grid',
|
|
gap: 8,
|
|
padding: '11px 13px',
|
|
borderRadius: 11,
|
|
border: '1px solid var(--color-line)',
|
|
background: 'var(--color-surface)',
|
|
justifyItems: 'start',
|
|
minWidth: 260,
|
|
}}
|
|
>
|
|
<span style={{ fontSize: 12.5, color: 'var(--color-ink-3)' }}>
|
|
Customers will not be able to order from this branch.
|
|
</span>
|
|
|
|
<label style={{ fontSize: 12, color: 'var(--color-ink-4)', display: 'grid', gap: 4 }}>
|
|
Reopens on <span style={{ opacity: 0.75 }}>(leave blank to stay closed)</span>
|
|
<input
|
|
id="store-reopens-on"
|
|
type="date"
|
|
value={until}
|
|
min={today()}
|
|
onChange={(event) => setUntil(event.target.value)}
|
|
style={{
|
|
padding: '6px 9px',
|
|
fontSize: 13,
|
|
border: '1px solid var(--color-line)',
|
|
borderRadius: 7,
|
|
background: 'var(--color-surface)',
|
|
color: 'var(--color-ink-1)',
|
|
}}
|
|
/>
|
|
</label>
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<Button
|
|
label="Keep open"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setConfirming(false);
|
|
setOpen(true);
|
|
}}
|
|
/>
|
|
<Button
|
|
label={save.isPending ? 'Closing…' : 'Close the branch'}
|
|
variant="destructive"
|
|
size="sm"
|
|
isLoading={save.isPending}
|
|
isDisabled={save.isPending}
|
|
onClick={() =>
|
|
save.mutate({ isopen: false, ...(until ? { closeduntil: until } : {}) })
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<span style={{ fontSize: 12, color: 'var(--color-error, #d64545)' }}>{error}</span>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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' });
|
|
}
|