store profile status updated
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
188
src/features/store-admin/StoreOpenControl.tsx
Normal file
188
src/features/store-admin/StoreOpenControl.tsx
Normal 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' });
|
||||
}
|
||||
@@ -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() {
|
||||
<span className="sp-sub">Store profile & basic information</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Edit store"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
icon={<Pencil size={14} />}
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => {
|
||||
setTab('details');
|
||||
setOpenCard('basic');
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
size="sm"
|
||||
icon={<Pencil size={14} />}
|
||||
isDisabled={save.isPending}
|
||||
onClick={() => {
|
||||
setTab('details');
|
||||
setOpenCard('basic');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* The four facts worth knowing before reading the cards. */}
|
||||
<div className="sp-summary" style={{ gridColumn: '1 / -1' }}>
|
||||
|
||||
Reference in New Issue
Block a user