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(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(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(() => { 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 {children}; } export function useBranchScope(): BranchScopeValue { const value = useContext(BranchScopeContext); if (!value) throw new Error('useBranchScope must be used inside a BranchScopeProvider'); return value; }