Files
daily_console_web/src/features/store-admin/StoreOpenControl.tsx
2026-10-09 17:23:34 +05:30

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' });
}