132 lines
5.5 KiB
TypeScript
132 lines
5.5 KiB
TypeScript
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
|
|
import type { DateRange } from '@/api/insights';
|
|
import {
|
|
DateRangePicker,
|
|
presetRange,
|
|
type RangePreset,
|
|
} from '@/features/store-admin/DateRangePicker';
|
|
|
|
/**
|
|
* The console's date filter, held once for the whole workspace.
|
|
*
|
|
* It sits in the top bar beside the profile, not on the page, and every page
|
|
* reads it from here — the same relationship `BranchScope` already has to the
|
|
* branch picker beside the logo. The two scopes now work the same way: the two
|
|
* questions every page is asked, "which shop" and "when", are answered once in
|
|
* the chrome rather than re-answered on each screen.
|
|
*
|
|
* ── What this changes about the pages ───────────────────────────────────────
|
|
*
|
|
* The range survives navigation. Setting March on Sales and clicking through to
|
|
* Reports shows March, which is what somebody looking into a month actually
|
|
* wants and is the whole reason for lifting it. It also means the live boards —
|
|
* Console, Counters, Terminals — no longer force themselves back to today; they
|
|
* follow the shared range like everything else. That is the trade the move
|
|
* makes, and it is the right one, but it IS a change: those three used to be
|
|
* pinned to the day whatever else you had chosen.
|
|
*/
|
|
|
|
export interface DateScopeValue {
|
|
preset: RangePreset;
|
|
/**
|
|
* What the PAGES read — always a real window.
|
|
*
|
|
* When nobody has picked anything this is the default week, not an empty
|
|
* object. Left empty, every read on the console became unbounded and each
|
|
* part of a page bounded it differently: the Console's chart drew however
|
|
* many days the last 500 orders happened to span (nine, in practice) while
|
|
* the KPI tiles beside it totalled all of them, and the counter figures came
|
|
* from a POS summary with no window at all. One page, three answers.
|
|
*/
|
|
range: DateRange;
|
|
/**
|
|
* What the PICKER shows — empty until somebody chooses.
|
|
*
|
|
* This is the half that keeps the filter unselected on arrival. The two are
|
|
* separate on purpose: "no filter has been set" is a fact about the control,
|
|
* and "which week are we looking at" is a fact about the data, and collapsing
|
|
* them into one value is what forced a choice between a filter nobody set and
|
|
* a page with no window.
|
|
*/
|
|
chosen: DateRange;
|
|
set: (preset: RangePreset, range: DateRange) => void;
|
|
/** Back to no filter at all — what an empty state offers as a way out. */
|
|
clear: () => void;
|
|
/** True when the USER has narrowed the page, not merely that a window exists. */
|
|
isFiltered: boolean;
|
|
}
|
|
|
|
/**
|
|
* The window the console shows when nobody has picked one.
|
|
*
|
|
* A WEEK — while the picker still shows nothing selected. Those are two
|
|
* separate statements and both are deliberate.
|
|
*
|
|
* The filter opens unselected because the console used to narrow every page
|
|
* before anyone asked: somebody signing in to see how trade is going was shown
|
|
* a slice with no sign that it was one, and the range then followed them across
|
|
* the whole console.
|
|
*
|
|
* But unselected must not mean UNBOUNDED. With no dates at all the reads were
|
|
* capped only by `pagesize`, and each part of a page then bounded itself
|
|
* differently — the Console's chart drew however many days the last 500 orders
|
|
* happened to span (nine), the KPI tiles beside it totalled all of those
|
|
* orders, and the counter figures came from a POS summary with no window
|
|
* whatsoever. One page, three different answers to "when".
|
|
*
|
|
* A default window is not a filter. Nothing is hidden from the reader, the
|
|
* control is empty, and one choice replaces it.
|
|
*/
|
|
const DEFAULT_WINDOW: RangePreset = 'week';
|
|
|
|
const DateScopeContext = createContext<DateScopeValue | null>(null);
|
|
|
|
export function DateScopeProvider({ children }: { children: ReactNode }) {
|
|
/* What the user picked. Empty until they do — this is what the picker shows. */
|
|
const [preset, setPreset] = useState<RangePreset>('custom');
|
|
const [chosen, setChosen] = useState<DateRange>({});
|
|
|
|
const value = useMemo<DateScopeValue>(() => {
|
|
const hasChoice = Boolean(chosen.fromdate || chosen.todate);
|
|
return {
|
|
preset,
|
|
chosen,
|
|
/* The window the pages actually read: what was picked, or the default
|
|
week. Resolved here, once, so every read on a page shares it — the
|
|
orders, the POS summaries and the chart cannot end up describing
|
|
different spans. */
|
|
range: hasChoice ? chosen : presetRange(DEFAULT_WINDOW),
|
|
set: (nextPreset, nextRange) => {
|
|
setPreset(nextPreset);
|
|
setChosen(nextRange);
|
|
},
|
|
clear: () => {
|
|
setPreset('custom');
|
|
setChosen({});
|
|
},
|
|
isFiltered: hasChoice,
|
|
};
|
|
}, [preset, chosen]);
|
|
|
|
return <DateScopeContext.Provider value={value}>{children}</DateScopeContext.Provider>;
|
|
}
|
|
|
|
/**
|
|
* The shared range.
|
|
*
|
|
* Throws outside the provider rather than inventing a local range: a page that
|
|
* silently filtered on its own dates while the bar showed something else would
|
|
* be the exact confusion this exists to remove.
|
|
*/
|
|
export function useDateScope(): DateScopeValue {
|
|
const value = useContext(DateScopeContext);
|
|
if (!value) throw new Error('useDateScope must be used inside a DateScopeProvider');
|
|
return value;
|
|
}
|
|
|
|
/** The control itself. Rendered once, in the top bar. */
|
|
export function DateScopePicker() {
|
|
const dates = useDateScope();
|
|
return <DateRangePicker range={dates.chosen} onChange={dates.set} />;
|
|
}
|