store profile status updated

This commit is contained in:
2026-10-09 16:29:30 +05:30
parent f91044e61a
commit 42cc08a407
4 changed files with 253 additions and 11 deletions

View File

@@ -267,6 +267,29 @@ export const tenantsApi = {
contactno?: string;
email?: string;
}) => api.put<unknown>(`${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<unknown>(`${WEB}/tenants/storeopen`, body),
};
/**

View File

@@ -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;
}
/* ────────────────────────────────────────────────────────────────────────────

View File

@@ -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<string | null>(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 (
<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' });
}

View File

@@ -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,6 +250,21 @@ export function ShopProfilePage() {
<span className="sp-sub">Store profile &amp; basic information</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, flexWrap: 'wrap' }}>
{/* In the header, not in a tab. This is reached for on a bad
morning — the power has gone, somebody is ill — usually on a
phone. Everything else on this page is edited once a year.
Branch users never see it: this page is behind
RequireRole="store-admin", so the gating is the route's. */}
<StoreOpenControl
tenantid={Number(record['tenantid'] ?? 0)}
locationid={Number(record['locationid'] ?? 0)}
isOpen={record['isopen'] !== false}
{...(record['closeduntil']
? { closedUntil: String(record['closeduntil']) }
: {})}
/>
<Button
label="Edit store"
variant="primary"
@@ -260,6 +276,7 @@ export function ShopProfilePage() {
setOpenCard('basic');
}}
/>
</div>
{/* The four facts worth knowing before reading the cards. */}
<div className="sp-summary" style={{ gridColumn: '1 / -1' }}>