185 lines
7.3 KiB
TypeScript
185 lines
7.3 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import { useAuth } from '@/auth/AuthContext';
|
|
import { useTenantLocations } from '@/queries/hooks';
|
|
import type { TenantLocation } from '@/api/types';
|
|
|
|
/** `null` means All branches. */
|
|
export type BranchSelection = number | null;
|
|
|
|
export interface BranchScopeValue {
|
|
/** Every branch this tenant runs, in a stable order. */
|
|
branches: TenantLocation[];
|
|
isLoading: boolean;
|
|
/** The tenant these branches belong to — from the session, never the URL. */
|
|
tenantid: number;
|
|
/** `null` = All branches. */
|
|
selected: BranchSelection;
|
|
select: (next: BranchSelection) => void;
|
|
/**
|
|
* The branches a page should actually read.
|
|
*
|
|
* All branches → every one of them; a specific branch → just that one. Pages
|
|
* fan out over this rather than branching on `selected` themselves, which is
|
|
* what keeps "All" and "one" the same code path.
|
|
*/
|
|
scoped: TenantLocation[];
|
|
/** The selected branch, or undefined under All. */
|
|
current: TenantLocation | undefined;
|
|
/** True in the Store user workspace: one branch, fixed, `select` inert. */
|
|
isPinned: boolean;
|
|
}
|
|
|
|
const BranchScopeContext = createContext<BranchScopeValue | null>(null);
|
|
|
|
/** The URL param. Mirrors the selection so a link carries its branch with it. */
|
|
const PARAM = 'branch';
|
|
|
|
/**
|
|
* Which branch the Store Admin is looking at.
|
|
*
|
|
* Held in component state, mirrored to the URL. The mirror is what lets a link
|
|
* to "Inventory, Peelamedu" survive being pasted into a chat; holding the state
|
|
* here rather than reading it back out of the address bar is what stops a nav
|
|
* click — which replaces the query string — from resetting the operator to All
|
|
* branches mid-shift while they read a number that only means anything for one
|
|
* shop. See the long note on `chosen` below.
|
|
*
|
|
* The tenant, by contrast, comes from the session and is deliberately NOT in
|
|
* the URL. Fiesta has no web auth, so tenant scoping is enforced by this client
|
|
* alone — putting the tenant id in an editable address bar would turn the one
|
|
* boundary we control into a text field.
|
|
*/
|
|
export function BranchScopeProvider({
|
|
children,
|
|
pin,
|
|
}: {
|
|
children: ReactNode;
|
|
/**
|
|
* Fix the scope to one branch — the Store user workspace.
|
|
*
|
|
* When set, `selected` is always this id, `branches` and `scoped` hold only
|
|
* that outlet, and `select` does nothing. The URL param is ignored rather
|
|
* than merely defaulted: Fiesta authorises a POS or catalogue read on
|
|
* `locationid` alone, so an address bar that can change it is not a filter,
|
|
* it is the authorisation boundary in a text field.
|
|
*/
|
|
pin?: number;
|
|
}) {
|
|
const { user } = useAuth();
|
|
const [params, setParams] = useSearchParams();
|
|
const tenantid = user?.tenantid ?? 0;
|
|
|
|
const { data, isLoading } = useTenantLocations(tenantid || undefined);
|
|
|
|
const all = useMemo(
|
|
() => [...(data ?? [])].sort((a, b) => a.locationid - b.locationid),
|
|
[data],
|
|
);
|
|
|
|
// Pinned: the one outlet, and nothing else is ever in the list. Unpinned: all
|
|
// of them, with All-branches available.
|
|
const branches = useMemo(
|
|
() => (pin === undefined ? all : all.filter((branch) => branch.locationid === pin)),
|
|
[all, pin],
|
|
);
|
|
|
|
const raw = params.get(PARAM);
|
|
|
|
/*
|
|
The selection lives here, and the URL only mirrors it.
|
|
|
|
── The bug this fixes ──────────────────────────────────────────────────────
|
|
|
|
It used to be derived straight from the search param, with an absent param
|
|
meaning All branches. That is wrong, because absent does not mean "show me
|
|
everything" — it mostly means "you just clicked a nav tab". Every link in
|
|
`AppShell` is a bare path (`to="/admin/sales"`), so React Router replaces the
|
|
whole location, query string included, and the param is simply gone. The
|
|
branch filter therefore reset to All on every navigation: pick a shop on
|
|
Console, click Sales, and you were back to all six with nothing saying so.
|
|
Reproduced on tenant 1087 (Ragul Stores, 6 branches) — the label went from
|
|
"Ragul stores Selvapuram" back to "All branches (6)".
|
|
|
|
Copying the whole search string onto the nav links would have fixed it and
|
|
broken something else: `InventoryPage` and the global catalogue keep their own
|
|
params, and those would then follow the operator from page to page.
|
|
|
|
── The rule ────────────────────────────────────────────────────────────────
|
|
|
|
A param that is PRESENT is obeyed, so a link to "Inventory, Peelamedu" still
|
|
survives being pasted into a chat, and editing the id in the address bar still
|
|
works. A param that is ABSENT changes nothing, so navigation cannot silently
|
|
widen the operator's scope. The effect below then writes the param back, which
|
|
is what keeps the URL honest after a nav click.
|
|
*/
|
|
const [chosen, setChosen] = useState<BranchSelection>(null);
|
|
|
|
useEffect(() => {
|
|
if (pin !== undefined || raw === null) return;
|
|
if (raw === 'all') {
|
|
setChosen(null);
|
|
return;
|
|
}
|
|
const parsed = Number(raw);
|
|
if (Number.isFinite(parsed) && branches.some((b) => b.locationid === parsed)) {
|
|
setChosen(parsed);
|
|
} else if (!isLoading) {
|
|
// An id this tenant does not own falls back to All rather than showing an
|
|
// empty page — the id is user-editable, so it is untrusted. Guarded on
|
|
// `isLoading` because `branches` is empty until the fetch lands, and
|
|
// resetting then would throw away a perfectly good deep link.
|
|
setChosen(null);
|
|
}
|
|
}, [raw, branches, isLoading, pin]);
|
|
|
|
const selected = pin !== undefined ? pin : chosen;
|
|
|
|
// The URL follows the selection, including putting the param back after a nav
|
|
// click has dropped it. Built from the current params so a page's own query
|
|
// state is carried through untouched.
|
|
useEffect(() => {
|
|
if (pin !== undefined) return;
|
|
const want = selected === null ? null : String(selected);
|
|
if ((params.get(PARAM) ?? null) === want) return;
|
|
const next = new URLSearchParams(params);
|
|
if (want === null) next.delete(PARAM);
|
|
else next.set(PARAM, want);
|
|
setParams(next, { replace: true });
|
|
}, [selected, params, setParams, pin]);
|
|
|
|
const value = useMemo<BranchScopeValue>(() => {
|
|
const current = selected === null ? undefined : branches.find((b) => b.locationid === selected);
|
|
return {
|
|
branches,
|
|
isLoading,
|
|
tenantid,
|
|
selected,
|
|
current,
|
|
isPinned: pin !== undefined,
|
|
scoped: current ? [current] : branches,
|
|
// State only. The URL is updated by the mirroring effect above, so there
|
|
// is one place that writes the param rather than two that can disagree.
|
|
select: (next) => {
|
|
if (pin !== undefined) return;
|
|
setChosen(next);
|
|
},
|
|
};
|
|
}, [branches, isLoading, tenantid, selected, pin]);
|
|
|
|
return <BranchScopeContext.Provider value={value}>{children}</BranchScopeContext.Provider>;
|
|
}
|
|
|
|
export function useBranchScope(): BranchScopeValue {
|
|
const value = useContext(BranchScopeContext);
|
|
if (!value) throw new Error('useBranchScope must be used inside a BranchScopeProvider');
|
|
return value;
|
|
}
|