store profile status updated
This commit is contained in:
@@ -267,6 +267,29 @@ export const tenantsApi = {
|
|||||||
contactno?: string;
|
contactno?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
}) => api.put<unknown>(`${WEB}/tenants/updateownprofile`, body),
|
}) => 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;
|
deliverymins?: number;
|
||||||
cancelsecs?: number;
|
cancelsecs?: number;
|
||||||
status: string;
|
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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { DeliverySlotsCard } from '../DeliverySlotsCard';
|
import { DeliverySlotsCard } from '../DeliverySlotsCard';
|
||||||
|
import { StoreOpenControl } from '../StoreOpenControl';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
@@ -249,6 +250,21 @@ export function ShopProfilePage() {
|
|||||||
<span className="sp-sub">Store profile & basic information</span>
|
<span className="sp-sub">Store profile & basic information</span>
|
||||||
</div>
|
</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
|
<Button
|
||||||
label="Edit store"
|
label="Edit store"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -260,6 +276,7 @@ export function ShopProfilePage() {
|
|||||||
setOpenCard('basic');
|
setOpenCard('basic');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* The four facts worth knowing before reading the cards. */}
|
{/* The four facts worth knowing before reading the cards. */}
|
||||||
<div className="sp-summary" style={{ gridColumn: '1 / -1' }}>
|
<div className="sp-summary" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user