enhancement

This commit is contained in:
2026-09-07 16:46:47 +05:30
parent 05ac76f5f0
commit c31696ce41
35 changed files with 926 additions and 986 deletions

View File

@@ -2,23 +2,32 @@ import type { ReactNode } from 'react';
export interface PageHeaderProps {
title: string;
/** A real count beside the name — "8 on record". Tabular, meta-coloured. */
count?: string;
/** One line of context. */
description?: string;
/** Right-aligned actions, wrapping. */
actions?: ReactNode;
/** An optional tabs row directly under the header rule. */
tabs?: ReactNode;
isLive?: boolean;
/**
* Put the tabs on the SAME line as the actions — tabs left, actions right —
* instead of on a row of their own beneath.
*
* Worth having now that the page titles are gone: with nothing above them the
* tabs and a lone button sat on two nearly empty lines, and pulling them onto
* one gives the page back a row without crowding anything.
*/
isTabsInline?: boolean;
}
/**
* The page frame header, built to KROW's `AdminPage` spec.
*
* Title + count + a live pill on one line, a one-line subtitle beneath, actions
* right-aligned and wrapping. Everything the page stacks below sits on a 24px
* rhythm.
* Actions right-aligned and wrapping, with an optional tabs row beneath.
* Everything the page stacks below sits on a 24px rhythm.
*
* The visible title, count, Live pill and description are gone from every page
* in all three workspaces. They restated what the chrome already says — the top
* bar names the section, the branch picker gives the count — and the
* description was a line of prose above data that nobody reads twice. The title
* survives as a screen-reader-only h1; see the note at the call.
*
* There was a hairline rule under all of this, with 16px of padding above it
* and another 12px below before the tabs — 28px of nothing plus a line, on
@@ -31,87 +40,53 @@ export interface PageHeaderProps {
* The tabs' spacing lives here rather than at each call site, so the five pages
* that have tabs cannot drift apart from each other again.
*/
export function PageHeader({ title, count, description, actions, tabs, isLive }: PageHeaderProps) {
export function PageHeader({ title, actions, tabs, isTabsInline }: PageHeaderProps) {
const hasRow = Boolean(actions || (isTabsInline && tabs));
return (
<>
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<h1
className="page-title"
style={{
margin: 0,
fontFamily: 'var(--font-display)',
lineHeight: 1.2,
fontWeight: 700,
letterSpacing: '-0.02em',
color: 'var(--color-ink-1)',
}}
>
{title}
</h1>
{/*
The title is still here, and still read aloud — it is just not drawn.
{count ? (
<span
style={{
fontSize: 13,
color: 'var(--color-ink-4)',
fontVariantNumeric: 'tabular-nums',
}}
>
{count}
</span>
) : null}
Removing it visually was the ask; removing it outright would leave every
page in the console with no h1, no document outline and nothing for a
screen reader to announce on navigation. `sr-only` is absolutely
positioned, so it is out of flow and costs no layout.
*/}
<h1 className="sr-only">{title}</h1>
{isLive ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
borderRadius: 999,
background: 'var(--color-brand-tint)',
color: 'var(--color-brand)',
padding: '2px 10px',
fontSize: 11,
fontWeight: 600,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: 'var(--color-brand)',
}}
/>
Live
</span>
) : null}
</div>
{/*
No row at all when there is nothing to put in it.
{description ? (
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--color-ink-3)' }}>
{description}
</p>
It used to render an empty <header> regardless. At zero height that
looks free, but it is still a flex child, so it collected the column's
12px gap and pushed everything down — a page with no actions sat 36px
below the nav bar while Inventory sat at 24. The gap now comes from one
place, the column's own padding, and every page matches.
*/}
{hasRow ? (
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
}}
>
{/* Left of the row when inline, so the tabs start at the page's left
edge and the actions stay on the right. */}
{isTabsInline && tabs ? <div>{tabs}</div> : <span />}
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</div>
</header>
) : null}
{actions ? (
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 10 }}>
{actions}
</div>
) : null}
</header>
{tabs ? <div style={{ marginTop: 4 }}>{tabs}</div> : null}
{tabs && !isTabsInline ? <div>{tabs}</div> : null}
</>
);
}

View File

@@ -6,6 +6,7 @@ import { useIsMobile } from '@/hooks/useIsMobile';
import { useAuth } from '@/auth/AuthContext';
import { ROLE_LABEL } from '@/auth/roles';
import { AssistantPanel } from './AssistantPanel';
import { DateScopePicker } from './DateScope';
export interface NavEntry {
to: string;
@@ -133,14 +134,26 @@ export function AppShell({
The ROW inside it is capped by `.app-gutter`, so the logo and nav sit
on exactly the same left edge as the page title below them at every
width, including a 2560px monitor where the body is centred. */}
{/*
Opaque, with no backdrop blur.
The blur cost more than it bought the moment a popover moved into this
bar. `backdrop-filter` makes an element a CONTAINING BLOCK for every
`position: fixed` descendant, and the design system's popovers are fixed
and CSS-anchor-positioned — so the date picker's calendar rendered
inside the header at 0×0 and could not be opened at all. The same
control worked perfectly two pixels lower, on the page.
A solid background is the fix rather than a hack around it: the bar sits
on a near-white page, so at 85% opacity plus blur it was already almost
opaque, and nothing here reads differently for losing it.
*/}
<header
style={{
position: 'sticky',
top: 0,
zIndex: 40,
background: 'rgba(255,255,255,.85)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-line)',
}}
>
@@ -247,6 +260,13 @@ export function AppShell({
the catalogue are real and stay. A control that looks like it
works costs more trust than a missing one. */}
{/* The date filter, beside the profile and common to every page.
Rendered here rather than by each page so the two questions the
console asks — which shop, and when — are both answered in the
chrome, and so the answer survives navigation. */}
<DateScopePicker />
{headerActions}
{/* No notification bell. It was labelled "2 unread" with the dot

View File

@@ -0,0 +1,85 @@
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;
range: 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 a range is actually narrowing the page. */
isFiltered: boolean;
}
/** Where the console starts. Month to date is what most pages want. */
export const DEFAULT_RANGE_PRESET: RangePreset = 'month';
const DateScopeContext = createContext<DateScopeValue | null>(null);
export function DateScopeProvider({ children }: { children: ReactNode }) {
const [preset, setPreset] = useState<RangePreset>(DEFAULT_RANGE_PRESET);
const [range, setRange] = useState<DateRange>(() => presetRange(DEFAULT_RANGE_PRESET));
const value = useMemo<DateScopeValue>(
() => ({
preset,
range,
set: (nextPreset, nextRange) => {
setPreset(nextPreset);
setRange(nextRange);
},
clear: () => {
setPreset('custom');
setRange({});
},
isFiltered: Boolean(range.fromdate || range.todate),
}),
[preset, range],
);
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 preset={dates.preset} range={dates.range} onChange={dates.set} />;
}