header changes

This commit is contained in:
2026-08-17 22:07:01 +05:30
parent 9c66ac0f74
commit 7a545c8f2b
14 changed files with 650 additions and 412 deletions

View File

@@ -20,6 +20,7 @@ import {
Store,
TrendingUp,
Settings,
Users,
Truck
} from 'lucide-react';
@@ -164,6 +165,19 @@ export default function App() {
[locationsQ.data],
);
/**
* The header filter as a `locationid`, handed to every admin page.
*
* Until now this state reached nothing but the assistant's scope — the
* control looked like it worked and changed nothing on screen, while each
* page carried its own store picker instead. Those page-level pickers are
* gone; this is the single scope for the admin workspace.
*
* `undefined` (not 0) for "all branches": every read treats a missing
* locationid as tenant-wide, and 0 would be sent as a real id.
*/
const scopedBranch = adminBranch === 'all' ? undefined : adminBranch;
const today = new Date();
const monthStart = new Date(today);
monthStart.setDate(today.getDate() - 30);
@@ -231,6 +245,12 @@ export default function App() {
const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');
const filteredStoresList = storesList.filter((st) => {
// The header branch filter scopes the store cards too. These are rendered
// here rather than inside DashboardView, so filtering the view's own list
// left the cards showing all five outlets while the KPIs above them read
// "1 / 1" — the page disagreeing with itself.
if (scopedBranch && Number(st.locationid) !== scopedBranch) return false;
const q = storesSearch.trim().toLowerCase();
// Match across every field shown on the card — name, zone, manager/contact,
// and the outlet id — coercing each to a string so a missing/numeric value
@@ -616,6 +636,13 @@ export default function App() {
isAssistantOpen={assistantOpen}
navItems={ADMIN_NAV_ITEMS}
navBasePath="/admin"
// The three panels of the settings page, deep-linked. Kept in sync with
// SettingsView's own `tabs` array by the `?tab=` values it accepts.
settingsSections={[
{ label: 'Business Profile', icon: Building, to: '/admin/settings' },
{ label: 'Outlets', icon: Store, to: '/admin/settings?tab=outlets' },
{ label: 'Users & Access', icon: Users, to: '/admin/settings?tab=users' },
]}
branches={adminBranches}
selectedBranch={adminBranch}
onBranchChange={setAdminBranch}
@@ -678,7 +705,7 @@ export default function App() {
renderStoresSection()
) : (
<div className="space-y-6">
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} locationid={scopedBranch} />
<div>
{renderStoresSection()}
</div>
@@ -692,6 +719,7 @@ export default function App() {
isCoimbatoreView={isCoimbatoreView}
tenantId={tenantId}
isSidebarOpen={sidebarOpen}
locationid={scopedBranch}
/>
} />
@@ -703,6 +731,7 @@ export default function App() {
isCoimbatoreView={isCoimbatoreView}
setIsCoimbatoreView={setIsCoimbatoreView}
tenantId={tenantId}
locationid={scopedBranch}
/>
} />
@@ -711,13 +740,13 @@ export default function App() {
} />
<Route path="dispatch" element={
<DispatchHubView tenantId={tenantId} />
<DispatchHubView tenantId={tenantId} locationid={scopedBranch} />
} />
{/* Tenant-wide POS: no locationid, so the view fans out over every
outlet under the tenant. */}
<Route path="pos" element={
<PosErrorBoundary><PosConsoleView tenantId={tenantId} /></PosErrorBoundary>
<PosErrorBoundary><PosConsoleView tenantId={tenantId} locationid={scopedBranch} /></PosErrorBoundary>
} />
</Routes>

View File

@@ -121,7 +121,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
/>
<aside
aria-label="Console assistant"
aria-label="Nearle Buddy"
className="fixed right-0 top-16 bottom-0 z-40 flex w-full flex-col sm:w-[380px]"
style={{ background: 'var(--color-surface, #fff)', borderLeft: `1px solid ${BORDER}`, boxShadow: SHADOW_POP }}
>
@@ -138,7 +138,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold" style={{ color: TEXT }}>
Assistant
Nearle Buddy
</p>
<p className="truncate text-[11px]" style={{ color: TEXT_2 }}>
{scope.branchName ?? scope.tenantname ?? 'This tenant'}
@@ -269,7 +269,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
style={{ border: `1px solid ${BORDER}`, background: 'var(--color-slate-50)' }}
>
<label htmlFor="assistant-input" className="sr-only">
Ask the assistant
Ask Nearle Buddy
</label>
<textarea
id="assistant-input"
@@ -285,7 +285,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
send(draft);
}
}}
placeholder="Ask about revenue, orders, branches…"
placeholder="Ask Nearle Buddy about revenue, orders, branches…"
className="max-h-28 min-h-[24px] flex-1 resize-none bg-transparent text-[13px] leading-relaxed outline-none"
style={{ color: TEXT }}
/>

View File

@@ -14,6 +14,15 @@ interface DashboardViewProps {
isCoimbatoreView: boolean;
/** Fiesta merchant tenant to scope live store summaries to. */
tenantId?: number;
/**
* The branch chosen in the top bar, or undefined for the whole tenant.
*
* Until now the header filter drove nothing but the assistant's answers —
* it looked like a working control and changed nothing on screen. This is
* the wire that makes it real: a real `tenantlocations.locationid`, which is
* what every location-scoped read authorises against.
*/
locationid?: number;
}
const ymd = (d: Date) =>
@@ -21,7 +30,7 @@ const ymd = (d: Date) =>
const str = (v: unknown): string => (v == null ? '' : String(v));
export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID }: DashboardViewProps) {
export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID, locationid }: DashboardViewProps) {
// Live data — month-to-date order summary + tenant identity + store locations.
const today = new Date();
const monthStart = new Date(today);
@@ -34,7 +43,9 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
// deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed.
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate);
const locationsQ = useFiestaTenantLocations(tenantId);
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, fromdate, todate });
// Revenue DOES honour locationid — verified against the live endpoint, which
// returns only the requested outlet's row. Order counts do not; see below.
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, fromdate, todate, locationid });
const s = summaryQ.data;
const tenantName = s?.tenantname || `Tenant ${tenantId}`;
@@ -51,12 +62,22 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
const locSummaryQ = useFiestaLocationSummary(tenantId, fromdate, todate);
const summaries = locSummaryQ.data ?? [];
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
const ordersTotal = s?.total ?? 0;
const ordersDelivered = s?.delivered ?? 0;
/**
* Order counts, scoped to the header branch when one is picked.
*
* NOT by passing `locationid` to getordersummary — that endpoint ignores it
* and answers tenant-wide. Verified live while building the assistant: it
* reported 341 orders for an outlet whose own row said 9. getlocationsummary
* really is per-location, so the branch figures come from there.
*/
const branchRow = locationid ? summaries.find((r) => Number(r.locationid) === locationid) : null;
const ordersTotal = branchRow ? branchRow.total : s?.total ?? 0;
const ordersDelivered = branchRow ? branchRow.delivered : s?.delivered ?? 0;
const regionFulfillmentPct = ordersTotal > 0 ? (ordersDelivered / ordersTotal) * 100 : null;
const locations = (locationsQ.data ?? []).filter((loc) => {
// Header branch filter first: everything below counts only what is in scope.
if (locationid && Number(loc.locationid) !== locationid) return false;
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (

View File

@@ -36,13 +36,18 @@ interface DispatchHubViewProps {
* somebody else's outlet.
*/
locationid?: number;
/**
* True when rendered inside the store workspace. Distinct from merely having
* a locationid: it withholds the tenant-wide admin catalogue.
*/
isStoreUser?: boolean;
tenantId?: number;
}
/** Every surface this console can show. */
type ConsoleTab = 'overview' | 'map' | 'orders' | 'deliveries' | 'products' | 'catalogue' | 'pos';
export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) {
export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID, isStoreUser = false }: DispatchHubViewProps) {
const [mapViewMode, setMapViewMode] = useState<'stores' | 'customers'>('stores');
const today = new Date();
const [date, setDate] = useState<string>(ymd(today));
@@ -57,7 +62,22 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
*/
const [pickedBranch, setPickedBranch] = useState<number | 'all'>('all');
const scopedLocationId = locationid ?? (pickedBranch === 'all' ? undefined : pickedBranch);
const isPinned = locationid != null;
/**
* Two different things used to share one flag, and now that the admin header
* also supplies a `locationid` they have to be told apart:
*
* • `isScoped` — a locationid arrived from outside, so this page does not
* draw its own branch picker. True for a store user AND for an admin who
* has picked a branch in the top bar.
* • `isStoreUser` — the caller is a store workspace, which must not reach
* the tenant-wide admin catalogue at all. An authorisation boundary.
*
* Conflating them would have taken the Catalogue tab away from an admin the
* moment they scoped to a branch, which is not what scoping means.
*/
const isScoped = locationid != null;
const isPinned = isStoreUser;
/**
* Which tabs make sense right now. The branch-specific surfaces — a store's
@@ -188,7 +208,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
Hidden when the caller already pinned one: a store user must not
be able to page through other branches from here. Options come
from tenantlocations, so the value is a real locationid. */}
{!isPinned && branches.length > 0 && (
{!isScoped && branches.length > 0 && (
<div className="flex items-center gap-2 min-w-0">
<Store size={14} className="text-slate-400 shrink-0" />
<div className="w-44 sm:w-56">

View File

@@ -4,7 +4,7 @@
*/
import React, { useState, useRef, useEffect } from 'react';
import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User, Sparkles } from 'lucide-react';
import { Menu, HelpCircle, LogOut, Mail, QrCode, User, Sparkles } from 'lucide-react';
import { NavLink } from 'react-router-dom';
import { Selector } from '@astryxdesign/core/Selector';
import { Button } from '@astryxdesign/core/Button';
@@ -57,6 +57,16 @@ interface HeaderProps {
selectedBranch?: number | 'all';
onBranchChange?: (v: number | 'all') => void;
/**
* Settings sections listed inside the account dropdown.
*
* Settings left the top navigation: it is configuration, not a place anyone
* works from day to day, and it sat in the nav taking a slot from sections
* that are. The account menu is where "my workspace's setup" belongs, next to
* account and sign-out.
*/
settingsSections?: { label: string; icon: React.ElementType; to: string }[];
/** Signed-in user shown in the profile dropdown. */
profile: { name: string; role: string; email: string };
/** Optional store context to display next to the sidebar toggle. */
@@ -81,6 +91,7 @@ export default function Header({
branches,
selectedBranch,
onBranchChange,
settingsSections,
profile,
storeContext
}: HeaderProps) {
@@ -129,8 +140,16 @@ export default function Header({
return (
<header className="bg-[var(--color-brand)] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-16 text-white">
{/* Brand & Desktop Navigation Tabs */}
<div className="flex items-center gap-sm sm:gap-md md:pl-0 pl-1 min-w-0">
{/* Brand cell.
`flex-1 basis-0` here and on the trailing group is what actually
centres the navigation between them: both sides claim an equal share
of the leftover width, so the nav sits on the header's true midpoint
no matter how wide the logo or the control cluster happen to be.
Absolute centring (`left-1/2 -translate-x-1/2`) would also look right
but can overlap the side groups once they grow — and this bar has
already lost nav items to a width squeeze once. Flex cannot overlap. */}
<div className="flex-1 basis-0 flex items-center gap-sm sm:gap-md md:pl-0 pl-1 min-w-0">
{/* Brand cell — width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */}
<div
// The fixed widths mirror the rail's 256/64px so the logo sits above
@@ -207,60 +226,6 @@ export default function Header({
/>
</div>
{/* Horizontal navigation. Rendered only when the shell hands over its
items — the rail-based shells pass nothing and are unaffected. */}
{navItems && navItems.length > 0 && (
// shrink-0: the navigation is the one thing in this bar that must
// never be squeezed, because its overflow is a scrollbar-less strip —
// a clipped item is simply an unreachable section. Everything beside
// it absorbs the width instead (see the branch filter below).
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1 shrink-0 overflow-x-auto no-scrollbar">
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.id}
to={`${navBasePath}/${item.id}`}
className={({ isActive }) =>
`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[13px] font-medium whitespace-nowrap transition-colors ${
isActive
? 'bg-white/15 text-white font-semibold'
: 'text-purple-200 hover:bg-white/10 hover:text-white'
}`
}
>
<Icon size={14} />
{item.label}
</NavLink>
);
})}
</nav>
)}
{/* Tenant → branch filter. Sits with the navigation because it scopes
all of it: one choice, every section follows. */}
{branches && branches.length > 0 && onBranchChange && (
<div className="hidden lg:flex items-center gap-2 min-w-0 shrink-0">
<span className="w-px h-6 bg-white/20" aria-hidden="true" />
{/* Narrower until xl so the navigation keeps its full width at the
mid sizes where the two were competing. */}
<div className="w-40 xl:w-48">
<Selector
label="Filter every section by branch"
isLabelHidden
size="sm"
width="100%"
value={String(selectedBranch ?? 'all')}
onChange={(v) => onBranchChange(v === 'all' ? 'all' : Number(v))}
options={[
{ value: 'all', label: `All branches (${branches.length})` },
...branches.map((b) => ({ value: String(b.locationid), label: b.locationname })),
]}
/>
</div>
</div>
)}
{/* Dynamic Store Name Context.
Hidden on desktop once there is a top nav, for two reasons. It is
redundant — it names the section the active nav pill is already
@@ -292,24 +257,101 @@ export default function Header({
)}
</div>
{/* Horizontal navigation — the centre slot.
Its own child of the header rather than part of the brand group, which
is what lets it sit on the midpoint. Rendered only when the shell hands
over its items; the rail-based shells pass nothing and are unaffected.
`shrink-0`: the navigation is the one thing in this bar that must never
be squeezed, because its overflow is a scrollbar-less strip — a clipped
item is simply an unreachable section. The two flex-1 groups either
side absorb the width instead. */}
{navItems && navItems.length > 0 && (
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1 shrink-0 overflow-x-auto no-scrollbar">
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.id}
to={`${navBasePath}/${item.id}`}
className={({ isActive }) =>
`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[13px] font-medium whitespace-nowrap transition-colors ${
isActive
? 'bg-white/15 text-white font-semibold'
: 'text-purple-200 hover:bg-white/10 hover:text-white'
}`
}
>
<Icon size={14} />
{item.label}
</NavLink>
);
})}
</nav>
)}
{/* Global Actions Bar */}
<div className="flex items-center gap-md">
<div className="flex-1 basis-0 flex items-center justify-end gap-md min-w-0">
{/* Tenant → branch filter.
Moved out of the centre and over to the trailing group, beside the
assistant. It scopes the whole workspace rather than any one
section, so it belongs with the other global controls (assistant,
account) rather than sitting inside the navigation as if it were
another nav item. */}
{branches && branches.length > 0 && onBranchChange && (
// The hairline that used to sit between this and the assistant is
// gone. The selector already has its own border, so the divider was
// a second vertical line 8px from the first.
<div className="hidden lg:flex items-center min-w-0 shrink-0">
<div className="w-40 xl:w-48">
<Selector
label="Filter every section by branch"
isLabelHidden
size="sm"
width="100%"
value={String(selectedBranch ?? 'all')}
onChange={(v) => onBranchChange(v === 'all' ? 'all' : Number(v))}
options={[
{ value: 'all', label: `All branches (${branches.length})` },
...branches.map((b) => ({ value: String(b.locationid), label: b.locationname })),
]}
/>
</div>
</div>
)}
{/* Assistant rail toggle. Labelled rather than icon-only: it is the one
control in this bar whose icon carries no established meaning. */}
{onAssistantClick && (
<Button
label={isAssistantOpen ? 'Close assistant' : 'Open assistant'}
label={isAssistantOpen ? 'Close Nearle Buddy' : 'Open Nearle Buddy'}
variant="ghost"
onClick={onAssistantClick}
aria-expanded={isAssistantOpen}
style={{ height: 'auto', background: 'transparent', border: 'none' }}
className={`flex items-center gap-1.5 rounded-full border border-white/15 px-2.5 py-1.5 text-[12px] font-semibold text-white transition-colors cursor-pointer ${
isAssistantOpen ? 'bg-white/20' : 'bg-white/10 hover:bg-white/15'
}`}
// Icon only. `label` is still "Open/Close assistant", and Button
// promotes it to the accessible name when it differs from the
// visible children — so dropping the word costs nothing to a screen
// reader, and `tooltip` covers the sighted hover case.
tooltip={isAssistantOpen ? 'Close Nearle Buddy' : 'Open Nearle Buddy'}
// Ground and border live in `style`, not in classes. Astryx paints
// its own button background, so the class had to be overridden
// inline — and `background: 'transparent'` was then beating the
// `bg-white/10` class beside it. With the label removed that left a
// bare glyph floating in the bar with no hit target to look at.
style={{
height: 36,
width: 36,
minWidth: 36,
padding: 0,
borderRadius: 9999,
background: isAssistantOpen ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.10)',
border: '1px solid rgba(255,255,255,0.15)',
color: '#fff',
}}
className="flex items-center justify-center transition-colors cursor-pointer hover:brightness-110"
>
<span className="flex items-center gap-1.5">
<Sparkles size={15} />
<span className="hidden sm:inline">Assistant</span>
<span className="flex items-center justify-center">
<Sparkles size={16} />
</span>
</Button>
)}
@@ -317,13 +359,26 @@ export default function Header({
{/* Store QR — opens the QR modal (user store page only) */}
{onQrClick && (
<IconButton
icon={<QrCode size={18} />}
icon={<QrCode size={16} />}
label="Store QR code"
tooltip="Store QR code"
variant="ghost"
onClick={onQrClick}
style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }}
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
// Same 36px round chip as the assistant beside it. It was
// `height: auto` + `width: auto` with only padding for size, which
// let Astryx's own square ground show through — so two controls
// sitting next to each other had different shapes.
style={{
height: 36,
width: 36,
minWidth: 36,
padding: 0,
borderRadius: 9999,
background: 'rgba(255,255,255,0.10)',
border: '1px solid rgba(255,255,255,0.15)',
color: '#fff',
}}
className="flex items-center justify-center transition-colors cursor-pointer hover:brightness-110"
/>
)}
@@ -346,28 +401,21 @@ export default function Header({
onClick={() => setShowProfileDropdown(!showProfileDropdown)}
aria-expanded={showProfileDropdown}
aria-haspopup="dialog"
// Avatar only. The name and role were repeated verbatim at the top
// of the dropdown this button opens, so the bar was carrying ~150px
// of text that is one click away — and `label` still announces
// "RMart Store User, Manager. Account menu" either way. The chevron
// goes with them: `aria-expanded` already conveys the state, and a
// 9px glyph beside a 36px avatar was pure noise.
tooltip={`${profile.name} · ${profile.role}`}
style={{ height: 'auto', background: 'transparent', border: 'none' }}
className="group flex items-center pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]"
className="group flex items-center justify-center p-0 rounded-full bg-transparent hover:bg-white/10 border-none outline-none cursor-pointer transition-colors duration-150"
>
<span className="flex items-center gap-2.5">
{/* Initials avatar with live status dot */}
<span className="relative shrink-0">
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
{initials}
</span>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[var(--color-brand-ink)]" />
<span className="relative shrink-0 block">
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
{initials}
</span>
{/* Identity (hidden on small screens) */}
<span className="hidden sm:flex flex-col items-start leading-tight">
<span className="text-xs font-bold text-white truncate max-w-[130px]">{profile.name}</span>
<span className="text-[10px] text-purple-200 font-medium truncate max-w-[130px]">{profile.role}</span>
</span>
<ChevronDown
size={15}
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
/>
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[var(--color-brand-ink)]" />
</span>
</Button>
@@ -420,6 +468,38 @@ export default function Header({
</span>
</Button>
)}
{/* Settings, moved out of the top navigation.
Its three sections are listed here rather than a single
"Settings" entry, because a lone entry would land everyone
on Business Profile and make them click again — and the
sections are what anyone is actually coming here for. Each
deep-links via `?tab=`, which SettingsView reads. */}
{settingsSections && settingsSections.length > 0 && (
<>
<div className="my-1 h-px bg-slate-100" />
<p className="eyebrow px-2.5 pt-1 pb-1.5">Settings</p>
{settingsSections.map((s) => {
const Icon = s.icon;
return (
<NavLink
key={s.to}
to={s.to}
onClick={() => setShowProfileDropdown(false)}
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
>
<span className="w-full flex items-center gap-2.5">
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
<Icon size={14} />
</span>
{s.label}
</span>
</NavLink>
);
})}
<div className="my-1 h-px bg-slate-100" />
</>
)}
<Button
label="Help Center"
variant="ghost"

View File

@@ -69,13 +69,16 @@ interface InventoryViewProps {
isCoimbatoreView: boolean;
tenantId?: number;
isSidebarOpen?: boolean;
/** The branch chosen in the top bar; undefined means the whole tenant. */
locationid?: number;
}
export default function InventoryView({
searchQuery,
isCoimbatoreView,
tenantId = FIESTA_TENANT_ID,
isSidebarOpen = false
isSidebarOpen = false,
locationid
}: InventoryViewProps) {
const { setHideCompareBar } = useCompare();
const [searchTerm, setSearchTerm] = useState('');
@@ -88,14 +91,19 @@ export default function InventoryView({
// to it); the STORE STOCK section shows each outlet's live stock so the admin
// can see all the stores under them at a glance.
const locationsQ = useFiestaTenantLocations(tenantId);
// Scoped by the top-bar branch filter. Everything downstream reads from this
// list — the stock fan-out, the outlet count, the sync ratio — so narrowing
// it here is what makes the header filter reach the whole page.
const locations = useMemo(
() =>
(locationsQ.data ?? []).map((l) => ({
locationid: Number(l.locationid),
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
status: fstr(l.status) || 'Active',
})),
[locationsQ.data],
(locationsQ.data ?? [])
.map((l) => ({
locationid: Number(l.locationid),
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
status: fstr(l.status) || 'Active',
}))
.filter((l) => !locationid || l.locationid === locationid),
[locationsQ.data, locationid],
);
// The admin catalogue (imports, curation) reads through this tenant's own

View File

@@ -99,7 +99,6 @@ import {
FilterBar,
PageHeader,
KpiStrip,
LiveStatus,
MetricPill,
Pill,
SlideDrawer,
@@ -303,52 +302,16 @@ export default function PosConsoleView({
? `${outletName.get(billOutlet) || storeName || 'Your store'} · counter sales and till health`
: `${outlets.length} outlet${outlets.length === 1 ? '' : 's'} · counter sales and till health`
}
status={
<LiveStatus
state={isError ? 'error' : isLoading ? 'loading' : 'live'}
label={
isError
? 'POS service unreachable'
: isLoading
? 'Loading counter sales…'
: `${tillsOnline} of ${allTerminals.length} till${allTerminals.length === 1 ? '' : 's'} online · refreshed every 30s`
}
/>
}
right={
// Wraps rather than overflowing: GradientHeader marks this side
// shrink-0, so a fixed row would push the title off a narrow viewport.
<div className="flex items-center gap-2 flex-wrap justify-end">
{RANGES.map((r) => (
<React.Fragment key={r.id}>
<Pill
active={rangeId === r.id}
color={BRAND}
onClick={() => {
setRangeId(r.id);
resetPaging();
}}
>
{r.label}
</Pill>
</React.Fragment>
))}
{/* IconButton still takes `label` (inherited from ButtonProps) —
it becomes the accessible name rather than visible text, which
the old `title`-only button never had. */}
<IconButton
label="Refresh"
variant="secondary"
size="sm"
icon={<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />}
onClick={() => {
healthQ.refetch();
summaryQ.refetch();
salesQ.refetch();
}}
/>
</div>
}
// The "N of M tills online · refreshed every 30s" line is gone. The
// count it carried is already the "Tills online" KPI card directly
// below, and the refresh cadence is not something anyone acts on.
//
// Failure is NOT silently dropped with it: `isError` still surfaces on
// the Tills online card, which turns red and reads "POS unreachable"
// rather than showing a stale figure as if it were current.
//
// With no status and no right-hand slot left, PageHeader collapses to
// its screen-reader-only <h1> and takes no vertical space.
/>
<KpiStrip
@@ -376,11 +339,18 @@ export default function PosConsoleView({
badge: `${merged?.itemcount ?? 0} items`,
},
{
// Carries the failure state the removed status line used to show.
// Without this an unreachable POS service would render "0/0" and
// read as "no tills configured" rather than "we could not ask".
label: 'Tills online',
value: `${tillsOnline}/${allTerminals.length}`,
color: pendingTotal > 0 ? '#f59e0b' : 'var(--color-slate-500)',
value: isError ? '—' : `${tillsOnline}/${allTerminals.length}`,
color: isError ? '#ef4444' : pendingTotal > 0 ? '#f59e0b' : 'var(--color-slate-500)',
icon: <Wifi size={16} />,
badge: pendingTotal > 0 ? `${pendingTotal} unsynced` : undefined,
badge: isError
? 'POS service unreachable'
: pendingTotal > 0
? `${pendingTotal} unsynced`
: undefined,
onClick: () => setTab('terminals'),
},
]}
@@ -407,20 +377,59 @@ export default function PosConsoleView({
)}
<div className="mt-4">
{/* The section tabs and the period filter share one bar. The period
pills used to sit up in the page header, two rows above the content
they scope; here they are on the same line as the section they
filter, and the bar exists either way — so the page gets its height
back and the relationship reads. */}
<FilterBar>
<div className="flex items-center gap-2 flex-wrap">
{TABS.map((t) => (
<React.Fragment key={t.id}>
<Pill
active={tab === t.id}
color={BRAND}
onClick={() => setTab(t.id)}
>
{t.icon}
{t.label}
</Pill>
</React.Fragment>
))}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
{TABS.map((t) => (
<React.Fragment key={t.id}>
<Pill
active={tab === t.id}
color={BRAND}
onClick={() => setTab(t.id)}
>
{t.icon}
{t.label}
</Pill>
</React.Fragment>
))}
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<span className="eyebrow hidden sm:inline">Period</span>
{RANGES.map((r) => (
<React.Fragment key={r.id}>
<Pill
active={rangeId === r.id}
color={BRAND}
onClick={() => {
setRangeId(r.id);
resetPaging();
}}
>
{r.label}
</Pill>
</React.Fragment>
))}
{/* IconButton still takes `label` (inherited from ButtonProps) —
it becomes the accessible name rather than visible text, which
the old `title`-only button never had. */}
<IconButton
label="Refresh"
variant="secondary"
size="sm"
icon={<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />}
onClick={() => {
healthQ.refetch();
summaryQ.refetch();
salesQ.refetch();
}}
/>
</div>
</div>
</FilterBar>
</div>

View File

@@ -41,6 +41,7 @@ import { stockRowToProduct } from '../services/fiestaMappers';
import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton';
import { Selector } from '@astryxdesign/core/Selector';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { AppDialog } from './AppDialog';
import {
@@ -49,26 +50,27 @@ import {
import AwaitingApi from './AwaitingApi';
import SalesRevenueReport from './SalesRevenueReport';
import { useNotify } from './Toasts';
import { KpiStrip, PageHeader } from './consoleUi';
import { KpiStrip, PageHeader, StatusChip } from './consoleUi';
interface ReportsViewProps {
searchQuery: string;
isCoimbatoreView: boolean;
setIsCoimbatoreView: (val: boolean) => void;
tenantId?: number;
/** The branch chosen in the top bar; undefined means the whole tenant. */
locationid?: number;
}
const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID, locationid }: ReportsViewProps) {
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
const [activeStoreId, setActiveStoreId] = useState<number | 'all'>('all');
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
const [currentPage, setCurrentPage] = useState(1);
@@ -131,7 +133,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
return rawLocations;
}, [locSummaryQ.data, selectedRegion]);
const effectiveStoreId = activeTab === 'sales_revenue' ? activeStoreId : 'all';
/**
* Scope comes from the top bar now, not from a picker on this page — and it
* applies to BOTH tabs. The old rule scoped only "Sales & Revenue" and forced
* Overview to 'all', so the same page answered at two different scopes
* depending on which tab you were looking at.
*/
const effectiveStoreId: number | 'all' = locationid ?? 'all';
const filteredLocations = useMemo(() => {
if (effectiveStoreId !== 'all') {
@@ -350,7 +358,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
rank: String(i + 1).padStart(2, '0'),
name: r.name,
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0,
revenue: `${r.total.toLocaleString()} ord`,
revenue: `${r.total.toLocaleString()} order${r.total === 1 ? '' : 's'}`,
}));
})();
const currentLeaderboard = leaderboard;
@@ -420,21 +428,26 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}, [activeRegionStock]);
// Heatmap cell color gradient scale (multi-stop violet theme)
const getHeatmapColorClass = (val: number) => {
const ratio = val / heatmapMax;
if (ratio < 0.15) {
return 'bg-slate-50 text-zinc-400 border border-slate-100/50 hover:bg-slate-100 hover:text-zinc-650 hover:scale-105';
}
if (ratio <= 0.4) {
return 'bg-purple-50 text-purple-700 border border-purple-100/50 hover:bg-purple-100 hover:scale-105';
}
if (ratio <= 0.7) {
return 'bg-purple-200/50 text-purple-900 border border-purple-200/40 hover:bg-purple-200 hover:scale-105';
}
if (ratio <= 0.9) {
return 'bg-[#7c3aed]/20 text-[#7c3aed] border border-[#7c3aed]/20 hover:bg-[#7c3aed]/30 hover:scale-105';
}
return 'bg-[var(--color-brand)] text-white border-none hover:bg-[var(--color-brand-strong)]';
/**
* PASS 2 — the heat ramp, rebuilt on one hue.
*
* It was five steps across three different purples — Tailwind `purple-50/100/200`,
* a hardcoded `#7c3aed`, and the brand `#662582` — so the scale changed hue as
* it climbed rather than just getting darker. It also gave every cell its own
* border, its own `rounded-lg`, and a `hover:scale-105`, which is what made
* the grid read as 25 separate buttons instead of one heat map.
*
* Now: a single alpha ramp on the brand token, no per-cell chrome. The lowest
* step is transparent, so an all-zero table (the common case for a new tenant)
* reads as an empty grid rather than a wall of grey boxes.
*/
const getHeatmapStyle = (val: number): React.CSSProperties => {
const ratio = heatmapMax > 0 ? val / heatmapMax : 0;
if (ratio < 0.15) return { background: 'transparent', color: 'var(--color-slate-500)' };
if (ratio <= 0.4) return { background: 'color-mix(in oklab, var(--color-brand) 10%, transparent)', color: 'var(--color-brand-ink)' };
if (ratio <= 0.7) return { background: 'color-mix(in oklab, var(--color-brand) 22%, transparent)', color: 'var(--color-brand-ink)' };
if (ratio <= 0.9) return { background: 'color-mix(in oklab, var(--color-brand) 45%, transparent)', color: '#fff' };
return { background: 'var(--color-brand)', color: '#fff' };
};
// Triggers progress bar simulated exporting
@@ -532,43 +545,28 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
Astryx ships no ARIA tablist, so this uses the same pressed-button
pattern as the other two single-select groups on this page, and
keeps the original underline styling exactly. */}
<div className="flex items-center gap-4">
{([
{ id: 'overview', label: 'Overview' },
{ id: 'sales_revenue', label: 'Sales & Revenue' },
] as const).map((t) => (
<Button
key={t.id}
label={t.label}
aria-pressed={activeTab === t.id}
variant="ghost"
onClick={() => setActiveTab(t.id)}
style={{ height: 'auto' }}
className={`pb-1 text-sm font-bold border-b-2 rounded-none bg-transparent transition-colors ${activeTab === t.id ? 'border-[var(--color-brand-ink)] text-[var(--color-brand-ink)]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
/>
))}
</div>
{/* PASS 5 — the same SegmentedControl the store Reports page uses.
These two tab strips split the same section on two pages and looked
nothing alike: underlined text here, a pill group there.
The Phase 6 note that used to sit here argued against Astryx's
TabList, and that reasoning still holds — TabList renders a <nav>
with `aria-current="page"`, which would claim a navigation that
never happens. SegmentedControl is the single-select control, which
is what this actually is. */}
<SegmentedControl
label="Report section"
value={activeTab}
onChange={(v) => setActiveTab(v as typeof activeTab)}
>
<SegmentedControlItem value="overview" label="Overview" />
<SegmentedControlItem value="sales_revenue" label="Sales &amp; Revenue" />
</SegmentedControl>
{/* Global Store Filter */}
{activeTab === 'sales_revenue' && (
<div className="flex items-center gap-2">
<Store size={16} className="text-slate-500" />
<Selector
label="Filter reports by store"
isLabelHidden
size="sm"
value={String(activeStoreId)}
onChange={(v) => setActiveStoreId(v === 'all' ? 'all' : Number(v))}
options={[
{ value: 'all', label: 'All Stores' },
...regionLocations.map((loc) => ({
value: String(Number(loc.locationid)),
label: loc.locationname || `Store ${loc.locationid}`,
})),
]}
/>
</div>
)}
{/* The page's own "All Stores" picker is gone — the branch filter in the
top bar is the single scope control for the whole admin workspace,
and two pickers that set the same scope is exactly how a page ends
up disagreeing with its own header. */}
</div>
{activeTab === 'overview' ? (
@@ -628,8 +626,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<div className="lg:col-span-8 bg-surface border border-slate-200/70 overflow-hidden flex flex-col justify-between shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
Weekly Order Distribution
<span className="section-title block">
Weekly order distribution
</span>
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
@@ -667,7 +665,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<Table className="w-full text-center text-xs font-sans">
<TableHeader>
<TableRow>
<TableHeaderCell className="w-32 text-left">Outlet Name</TableHeaderCell>
<TableHeaderCell className="text-left" style={{ minWidth: 190 }}>Outlet Name</TableHeaderCell>
{WEEK_LABELS.map((w) => (
<TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell>
))}
@@ -676,26 +674,59 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<TableBody>
{insightRows.map((row, idx) => (
<TableRow key={idx}>
<TableCell className="text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100">
{row.name}
{/* No max-width cap: the column had `max-w-[8rem]` and
then `10rem`, both of which clipped every outlet name
("Suriya Store RS P…") while the five week columns sat
at 160px each with nothing in them. The table sizes it
now; `title` covers the genuinely long names. */}
{/* An explicit minWidth, not a max-width cap.
The cap (`max-w-[8rem]`) clipped every name to
"Suriya Store RS P…"; removing it entirely was worse —
the auto table layout then handed the width to the five
week columns and squeezed this one down to "Su". A
floor lets the column hold a full outlet name and still
share what is left. `title` sits on the span because
Astryx's TableCellProps omits it (same as the POS
username cell). */}
<TableCell className="text-left font-semibold text-slate-900 whitespace-nowrap" style={{ minWidth: 190 }}>
<span title={row.name}>{row.name}</span>
</TableCell>
{WEEK_KEYS.map((key, mIdx) => {
const val = fnum(row.weeks[key]);
const isPicked =
selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx];
// PASS 2 — the click target fills the cell instead of
// floating inside it as a rounded box. `padding: 0` on
// the cell plus a full-bleed button is what turns 25
// buttons back into a heat grid; the hairline row rules
// from the shared table treatment then do the separating,
// as they do on every other table.
//
// Still a Button: these cells are selectable, and the
// label carries the whole cell — each one used to
// announce itself as bare "12", with no hint of which
// outlet or which week produced it.
return (
<TableCell key={key} className="p-1">
{/* Every one of these announced itself as nothing
but its own number — "12", with no hint of which
outlet or which week produced it. The label now
carries the whole cell. */}
<TableCell key={key} style={{ padding: 0 }}>
<Button
label={`${row.name}, ${WEEK_LABELS[mIdx]}: ${val} orders`}
aria-pressed={isPicked}
variant="ghost"
onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })}
style={{ height: 'auto' }}
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer ${getHeatmapColorClass(val)}`}
style={{
height: 'auto',
width: '100%',
border: 'none',
borderRadius: 0,
padding: '14px 8px',
fontSize: 13,
fontWeight: 500,
fontVariantNumeric: 'tabular-nums',
outline: isPicked ? '2px solid var(--color-brand-ink)' : 'none',
outlineOffset: '-2px',
...getHeatmapStyle(val),
}}
className="cursor-pointer transition-colors duration-150"
>
{val}
</Button>
@@ -713,8 +744,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Leaderboard nodes bar list - 4 Cols */}
<div className="lg:col-span-4 bg-surface border border-slate-200/70 flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 select-none">
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
Top Performing Nodes
<span className="section-title block">
Top performing nodes
</span>
</div>
@@ -725,40 +756,44 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div>
) : (
currentLeaderboard.map((node, index) => {
let badgeStyle = '';
let badgeContent: React.ReactNode = node.rank;
if (index === 0) {
badgeStyle = 'bg-amber-100 text-amber-900 border border-amber-200';
badgeContent = '🥇';
} else if (index === 1) {
badgeStyle = 'bg-slate-100 text-slate-700 border border-slate-200';
badgeContent = '🥈';
} else if (index === 2) {
badgeStyle = 'bg-orange-100 text-orange-900 border border-orange-200';
badgeContent = '🥉';
} else {
badgeStyle = 'bg-slate-100 text-slate-500 border border-slate-200';
}
// PASS 4 — the top three carried literal 🥇🥈🥉 emoji on amber,
// slate and orange grounds. Emoji render as a different typeface
// at a different weight on every platform, they cannot be
// recoloured with the theme, and a screen reader announces
// "1st place medal" where the column means "rank 1". Ranks are
// numerals now, the leader marked by the brand rather than by
// gold. Nothing else in the console uses medals.
const isLeader = index === 0;
return (
<div key={node.rank} className="flex items-center gap-4 text-xs hover:bg-slate-50 p-2.5 rounded-xl transition-all duration-200 group">
{/* Ranking circle avatar */}
<div className={`w-9 h-9 rounded-full font-bold flex items-center justify-center font-mono shrink-0 select-none text-sm transition-transform duration-300 group-hover:scale-105 ${badgeStyle}`}>
{badgeContent}
<div key={node.rank} className="flex items-center gap-4 hover:bg-slate-50 p-2.5 rounded-control transition-colors duration-150 group">
<div
className="w-8 h-8 rounded-full flex items-center justify-center font-mono tabular-nums shrink-0 select-none"
style={
isLeader
? { background: 'var(--color-brand)', color: '#fff', fontSize: 12, fontWeight: 600 }
: { background: 'var(--color-slate-100)', color: 'var(--color-slate-500)', fontSize: 12, fontWeight: 600 }
}
>
{node.rank}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center font-medium mb-1.5">
<span className="text-slate-900 font-bold truncate max-w-[12rem] flex items-center gap-1.5">
{node.name}
{/* PASS 1 — `truncate max-w-[12rem]` was on the wrapper
holding BOTH the name and the chip, so the ellipsis
ate the chip instead of the name: "VERIFIED" rendered
as "VERIFII". The truncation now applies to the name
alone and the chip is `shrink-0`. */}
<span className="text-slate-900 font-bold flex items-center gap-1.5 min-w-0">
<span className="truncate">{node.name}</span>
{index < 3 && (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-widest bg-emerald-50 text-emerald-600 border border-emerald-100" title="Verified Top Performer">
<span className="inline-flex shrink-0 items-center gap-0.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-emerald-50 text-emerald-700 border border-emerald-100" title="Verified top performer">
<CheckCircle2 size={10} /> Verified
</span>
)}
</span>
<span className="text-purple-700 font-mono font-bold">{node.revenue}</span>
<span className="text-[var(--color-brand-ink)] font-mono tabular-nums shrink-0 ml-3" style={{ fontSize: 12, fontWeight: 600 }}>{node.revenue}</span>
</div>
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
@@ -781,50 +816,56 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Top 3 Overall Products - 4 Cols */}
<div className="lg:col-span-4 bg-surface border border-slate-200 rounded-card flex flex-col relative overflow-hidden">
{/* Subtle glow background */}
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-400/20 rounded-full blur-[40px] -mr-10 -mt-10 pointer-events-none" />
{/* PASS 4 — a blurred purple glow bleeding out of the corner, removed. */}
<div className="px-5 py-4 border-b border-purple-100/40 flex items-center justify-between relative z-10">
<span className="text-[13px] font-sans font-bold text-slate-800 tracking-tight flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center shadow-inner">
<Trophy className="text-amber-500 fill-amber-100" size={12} />
</div>
Overall Top 3
</span>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
{/* PASS 3/4 — one heading role, and the amber trophy medallion is
gone. Five sections on this page carried five different heading
treatments (11px uppercase, two at 13px with icon tiles, one at
14px, one at 16px); none of them was the `section-title` role
every other page uses. */}
<span className="section-title">Overall top 3</span>
</div>
<div className="p-5 flex-1 space-y-3 flex flex-col relative z-10">
<div className="p-5 flex-1 space-y-2 flex flex-col">
{topOverallProducts.length === 0 ? (
<div className="text-center py-6 text-slate-400 font-medium text-[11px]">No sales data available.</div>
) : (
topOverallProducts.map((prod, index) => {
// PASS 4 — the leader no longer gets a cream row, an amber rank
// pill, a 2px amber photo border, an amber revenue chip and a
// larger type size. Amber appears nowhere else in the console
// and five simultaneous signals for "this one is first" is four
// too many — the rank numeral already says it. Every row is now
// the same row, and the image stops zooming on hover.
const isFirst = index === 0;
return (
<div key={prod.id} className={`flex items-center gap-3 text-[11px] p-2.5 rounded-xl transition-all duration-300 group cursor-default relative overflow-hidden ${isFirst ? 'bg-amber-50 border border-amber-200' : 'bg-surface border border-slate-100 hover:border-purple-200'}`}>
{/* Rank Badge */}
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 shadow-inner ${isFirst ? 'bg-amber-100 text-amber-900 text-xs font-semibold' : index === 1 ? 'bg-slate-100 text-slate-700 text-[10px] font-semibold' : 'bg-orange-100 text-orange-900 text-[10px] font-semibold'}`}>
#{index + 1}
<div key={prod.id} className="flex items-center gap-3 p-2.5 rounded-control bg-surface border border-slate-100 hover:border-purple-200 transition-colors duration-150 cursor-default">
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 font-mono tabular-nums"
style={
isFirst
? { background: 'var(--color-brand)', color: '#fff', fontSize: 11, fontWeight: 600 }
: { background: 'var(--color-slate-100)', color: 'var(--color-slate-500)', fontSize: 11, fontWeight: 600 }
}
>
{index + 1}
</div>
{/* Image */}
<div className={`w-10 h-10 rounded-lg overflow-hidden shrink-0 bg-surface ${isFirst ? 'border-2 border-amber-300 shadow-sm' : 'border border-slate-200 shadow-sm'}`}>
<img src={prod.image} alt={prod.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
<div className="w-10 h-10 rounded-control overflow-hidden shrink-0 bg-surface border border-slate-200">
<img src={prod.image} alt={prod.name} referrerPolicy="no-referrer" className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0 py-0.5">
<div className={`font-bold truncate ${isFirst ? 'text-slate-900 text-sm' : 'text-slate-800 text-xs'}`}>{prod.name}</div>
<div className="font-semibold text-slate-900 truncate" style={{ fontSize: 13 }}>{prod.name}</div>
<div className="flex items-center gap-1.5 mt-1">
<span className={`text-[10px] font-bold tracking-wide ${isFirst ? 'text-amber-600 bg-amber-100/50 px-1 py-0.5 rounded' : 'text-purple-600 bg-purple-50 px-1 py-0.5 rounded'}`}>
₹{prod.revenue.toLocaleString()} rev
<span className="text-[var(--color-brand-ink)] bg-purple-50 rounded-full px-2 py-0.5 tabular-nums" style={{ fontSize: 10, fontWeight: 600 }}>
₹{prod.revenue.toLocaleString()}
</span>
<span className="w-1 h-1 rounded-full bg-slate-300" />
<span className="eyebrow">{prod.category}</span>
</div>
</div>
{isFirst && (
<div className="" />
)}
</div>
);
})
@@ -833,14 +874,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div>
{/* Store-wise Top Products - 8 Cols */}
<div className="lg:col-span-8 bg-white/95 backdrop-blur-xl border border-slate-200/80 rounded-[1.5rem] flex flex-col shadow-xl shadow-slate-200/20 relative overflow-hidden">
{/* PASS 4 — was `bg-white/95 backdrop-blur-xl` with a 1.5rem radius
and an xl shadow: a frosted, extra-round, extra-raised card sitting
beside a plain one. Same card as everything else now. */}
<div className="lg:col-span-8 bg-surface border border-slate-200 rounded-card flex flex-col overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<span className="text-[13px] font-sans font-bold text-slate-800 tracking-tight flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-indigo-50 flex items-center justify-center shadow-inner">
<Store className="text-indigo-500" size={12} />
</div>
Store-Wise Top Performers
</span>
<span className="section-title">Store-wise top performers</span>
</div>
<div className="p-5 flex-1 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-300 scrollbar-track-transparent pb-4">
{topProductsByStore.length === 0 ? (
@@ -850,14 +889,14 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{topProductsByStore.map(store => (
<div key={store.locationname} className="w-[230px] rounded-card bg-surface border border-slate-200 p-4 shrink-0 hover:border-indigo-200 transition-colors duration-200 group">
<div className="font-bold text-slate-800 text-xs mb-4 pb-3 border-b border-slate-100 flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.6)] animate-pulse" />
<div className="w-1.5 h-1.5 rounded-full bg-[var(--color-brand)] shrink-0" />
<span className="truncate">{store.locationname}</span>
</div>
<div className="space-y-3">
{store.topProducts.map((prod, idx) => (
<div key={prod.id} className="flex items-center gap-2.5 group/item">
<div className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 font-semibold text-[10px] ${idx === 0 ? 'bg-amber-100 text-amber-600' : idx === 1 ? 'bg-slate-100 text-slate-600' : 'bg-orange-50 text-orange-600'}`}>
<div className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 font-semibold text-[10px] ${idx === 0 ? 'bg-purple-50 text-[var(--color-brand-ink)]' : 'bg-slate-100 text-slate-500'}`}>
{idx + 1}
</div>
@@ -889,9 +928,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Table header with filters control */}
<div className="bg-[var(--color-slate-50)] border-b border-[var(--color-slate-200)] p-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-sm select-none">
<div>
<h3 className="font-sans font-bold text-sm text-[var(--color-slate-900)] flex items-center gap-1.5">
<Activity size={15} className="text-[var(--color-brand-ink)]" /> Product Performance Matrix
</h3>
<h3 className="section-title">Product performance matrix</h3>
</div>
{/* Quick interactive filter pills */}
@@ -950,18 +987,30 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
/>
</TableCell>
<TableCell className="flex items-center gap-sm">
<div className="w-8 h-8 rounded-lg shrink-0 border border-[var(--color-slate-200)] overflow-hidden bg-zinc-50">
<img
src={prod.image}
alt={prod.name}
referrerPolicy="no-referrer"
className="w-full h-full object-cover"
/>
</div>
<div>
<span className="font-bold text-[var(--color-slate-900)] block">{prod.name}</span>
<span className="eyebrow block mt-0.5">{prod.category}</span>
{/* PASS 1 — `display: flex` was on the <td> itself.
A flexed table cell drops out of the table layout
algorithm entirely: it no longer takes part in column
sizing, so it collapsed to its content's min-width
(32px — the thumbnail) while SKU / Units / Revenue /
Status split the remaining 1420px between them. The
product name then wrapped one character per line
inside those 32px, which is what pushed the row to
285px tall with an apparently empty name column.
The flex belongs on a wrapper inside the cell. */}
<TableCell>
<div className="flex items-center gap-sm">
<div className="w-8 h-8 rounded-control shrink-0 border border-[var(--color-slate-200)] overflow-hidden bg-zinc-50">
<img
src={prod.image}
alt={prod.name}
referrerPolicy="no-referrer"
className="w-full h-full object-cover"
/>
</div>
<div className="min-w-0">
<span className="font-bold text-[var(--color-slate-900)] block truncate">{prod.name}</span>
<span className="eyebrow block mt-0.5 truncate">{prod.category}</span>
</div>
</div>
</TableCell>
<TableCell className="font-mono text-zinc-500 font-semibold">
@@ -973,15 +1022,19 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<TableCell className="text-right font-mono text-zinc-800 font-bold">
₹{prod.revenue.toLocaleString()}
</TableCell>
{/* PASS 2 — onto the shared StatusChip, so a stock state
here looks like a stock state anywhere else. It was
a hand-rolled 10px uppercase pill; every other chip
in the console is 26px / 12px / sentence case. */}
<TableCell className="text-right">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide inline-block ${prod.stockStatus === 'Healthy'
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
: prod.stockStatus === 'Low Stock'
? 'bg-amber-50 text-amber-600 border border-amber-100'
: 'bg-rose-50 text-rose-600 border border-rose-100'
}`}>
{prod.stockStatus}
</span>
<StatusChip
label={prod.stockStatus}
color={
prod.stockStatus === 'Healthy' ? '#10b981'
: prod.stockStatus === 'Low Stock' ? '#f59e0b'
: '#ef4444'
}
/>
</TableCell>
</TableRow>
@@ -1151,7 +1204,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
)}
</>
) : (
<SalesRevenueReport tenantId={tenantId} locationid={activeStoreId === 'all' ? undefined : Number(activeStoreId)} />
<SalesRevenueReport tenantId={tenantId} locationid={locationid} />
)}
</div>
);

View File

@@ -4,6 +4,7 @@
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Building2,
Store,
@@ -32,7 +33,7 @@ import { Badge } from '@astryxdesign/core/Badge';
import { Heading } from '@astryxdesign/core/Heading';
import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner';
import { StatusChip, PageHeader, SHADOW_MD } from './consoleUi';
import { PageHeader } from './consoleUi';
type TabKey = 'profile' | 'outlets' | 'users';
@@ -169,7 +170,24 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
// until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify();
const [activeTab, setActiveTab] = useState<TabKey>('profile');
/**
* The open section, deep-linkable via `?tab=`.
*
* Settings is reached from the account menu now rather than from the top
* navigation, and that menu lists the three sections directly — so each entry
* has to be able to open its own section rather than dumping everyone on
* Business Profile and making them click again.
*
* `useSearchParams` rather than local state so the section survives a reload
* and can be linked to; an unknown or absent value falls back to 'profile'.
*/
// Read-only now: the account dropdown is the only way in, and each of its
// entries carries its own `?tab=`. There is no in-page control left to write
// this back, so there is no setter.
const [searchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const activeTab: TabKey =
tabParam === 'outlets' || tabParam === 'users' || tabParam === 'profile' ? tabParam : 'profile';
// Live tenant profile + outlets.
// Fetch a larger page size to ensure we find our specific tenant (ID 1087).
@@ -271,11 +289,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
setForm((f) => ({ ...f, [key]: value }));
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
{ key: 'profile', label: 'Business Profile', icon: Building2 },
{ key: 'outlets', label: 'Outlets', icon: Store },
{ key: 'users', label: 'Users & Access', icon: Users },
];
// The `tabs` array that used to live here is gone with the rail it fed. The
// same three sections are declared once, in App.tsx, as the account
// dropdown's `settingsSections` — the labels a user sees now have one source.
// Build role options from the live app-roles API; fall back to the known
// numeric roles + roleName() helper when the API has no rows/names.
@@ -307,51 +323,16 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
subtitle="Business profile, outlets, and who can sign in to this workspace."
/>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">
{/* Tab rail & Merchant Card */}
<div className="lg:col-span-1 space-y-md bg-slate-50/50 border border-slate-200/60 p-4 shadow-sm">
{/* Merchant ID Card */}
{/* STAGE 8 — the merchant card was the last dark surface in the
console: a three-stop near-black gradient with a blurred purple
blob, a purple→indigo avatar and a 15px glow ring. It is a plain
card now, like everything else. */}
<div className="bg-surface border border-slate-200 p-4 rounded-card select-none" style={{ boxShadow: SHADOW_MD }}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[var(--color-brand)] text-white flex items-center justify-center shrink-0" style={{ fontSize: 13, fontWeight: 600 }}>
{user?.name ? user.name.substring(0, 2).toUpperCase() : tenant ? fstr(tenant.tenantname).substring(0, 2).toUpperCase() : 'ND'}
</div>
<div className="min-w-0">
<h4 className="section-title truncate">{user?.name || (tenant ? fstr(tenant.tenantname) : 'Nearle Merchant')}</h4>
<p className="font-mono mt-0.5 truncate" style={{ fontSize: 10, color: 'var(--color-slate-500)' }}>Store ID · #{tenantId}</p>
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-100 flex justify-between items-center">
<span style={{ fontSize: 12, color: 'var(--color-slate-500)' }}>Status</span>
<StatusChip label="Online &amp; synced" color="#10b981" />
</div>
</div>
{/* Navigation tab rail */}
<nav className="flex lg:flex-col gap-1.5 overflow-x-auto custom-scrollbar no-scrollbar touch-scrolling select-none">
{tabs.map((t) => {
const Icon = t.icon;
const active = activeTab === t.key;
return (
<Button
key={t.key}
label={t.label}
variant={active ? 'primary' : 'ghost'}
icon={<Icon size={16} />}
onClick={() => setActiveTab(t.key)}
/>
);
})}
</nav>
</div>
{/* Panel */}
<div className="lg:col-span-3 space-y-gutter text-sm pb-24">
{/* The left column is gone: a section rail and a merchant identity card.
Both were saying something already said elsewhere —
• the rail duplicated the account dropdown, which now lists the same
three sections and links straight into each;
• the card showed who is signed in and the store's status, which is
the account dropdown's whole job (name, role, email) plus a status
that never varies.
The panel takes the full width instead of three quarters of it. */}
<div className="grid grid-cols-1 gap-gutter items-start">
<div className="space-y-gutter text-sm pb-24">
{activeTab === 'profile' && (
<div className="bg-surface border border-slate-200/60 p-6 shadow-sm space-y-lg animate-in fade-in duration-200">
<div>

View File

@@ -37,7 +37,10 @@ export const ADMIN_NAV_ITEMS = [
// bookmarks and the section-height rules in App.tsx keep working.
{ id: 'dispatch' as MainSection, label: 'Store Console', icon: Truck },
{ id: 'pos' as MainSection, label: 'POS', icon: Monitor },
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings },
// `settings` is deliberately absent. It moved into the account dropdown,
// which lists its three sections directly — it is workspace configuration,
// not a section anyone works from, and it was holding a nav slot that the
// day-to-day sections need. The /admin/settings route is unchanged.
];
interface SidebarProps {

View File

@@ -917,17 +917,14 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
<div className="mb-5">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-3 mb-1">
<div className="inline-flex items-center gap-2 px-2.5 py-0.5 rounded-full bg-[var(--color-brand)]/10 border border-[var(--color-brand-ink)]/20 text-[var(--color-brand-ink)] text-[10px] font-semibold uppercase tracking-wider">
Store Audience Registry
</div>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[10px] font-semibold tracking-wider text-emerald-700 uppercase">System Sync Active</span>
</div>
</div>
<h1 className="page-title">Customer CRM</h1>
<div className="flex flex-wrap items-center gap-2 text-[12px] text-zinc-500 mt-1 font-medium">
{/* The title block collapses to a screen-reader-only heading —
the nav already says "Customers", so the visible 24px title,
the "Store audience registry" badge and the "System sync
active" pulse were three lines of chrome above the actual
counts. The counts stay; they are the only part that says
anything. */}
<h1 className="sr-only">Customer CRM</h1>
<div className="flex flex-wrap items-center gap-2 text-[12px] text-zinc-500 font-medium">
<span>
{customersList.length} {customersList.length === 1 ? 'customer orders' : 'customers order'} from <strong className="text-[var(--color-slate-900)] font-bold">{store.name}</strong>
</span>

View File

@@ -56,11 +56,22 @@ interface UserStorePageProps {
// Sidebar navigation. Add entries here as new user sections are built — each id
// gets a matching branch in `renderSection` below.
/**
* The old first entry, `console`, is gone.
*
* It rendered `<StoreDetailView only="overview" canManage={false} />` — which
* is character for character what the Console page's own Overview tab renders.
* Two nav entries, two routes, one screen: a store user landed on the overview,
* and the only way to reach the map, orders, deliveries or till was to click a
* second entry that opened the same overview again with tabs around it.
*
* `dispatch` takes its place and its name. It is the same overview, plus every
* other surface, so nothing is lost by landing there instead.
*/
const NAV_ITEMS: UserNavItem[] = [
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
{ id: 'dispatch', label: 'Store Console', icon: LayoutDashboard },
{ id: 'inventory', label: 'Products', icon: Layers },
{ id: 'customers', label: 'Customers', icon: Users },
{ id: 'dispatch', label: 'Console', icon: RouteIcon },
{ id: 'pos', label: 'POS', icon: Monitor },
{ id: 'reports', label: 'Reports', icon: ClipboardList },
];
@@ -85,7 +96,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
// Extract the current section from the URL for Header context
const pathParts = location.pathname.split('/');
const activeSection = pathParts[pathParts.length - 1] || 'console';
const activeSection = pathParts[pathParts.length - 1] || 'dispatch';
// Scope every query to the signed-in merchant's tenant; the shared constant is
// only a fallback for legacy sessions whose record predates tenantid capture.
@@ -309,7 +320,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
return <PosStoreSection locationid={posLocationId} tenantId={tenantId} storeName={storeName} />;
if (activeSection === 'pos-till')
return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} isStoreUser />;
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
// Inventory & Catalog is its own page: the manager-curated catalog the user
// stocks from (the catalog query is tenant-level, so it doesn't need the store
@@ -353,7 +364,9 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
navBasePath="/store"
profile={profile}
storeContext={{
storeName: activeSection === 'console'
// `dispatch` is the store console now, so it takes the outlet's own
// name — the same thing the removed `console` route used to show.
storeName: activeSection === 'dispatch'
? storeName
: activeSection === 'inventory'
? 'Products'
@@ -363,10 +376,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
? 'POS Terminal'
: activeSection === 'account'
? 'My Account'
: activeSection === 'dispatch'
? 'Console'
: activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
icon: activeSection === 'console'
icon: activeSection === 'dispatch'
? LayoutDashboard
: activeSection === 'inventory'
? Layers
@@ -376,11 +387,11 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
? ShoppingBag
: activeSection === 'customers'
? Users
: activeSection === 'dispatch'
? RouteIcon
: activeSection === 'reports'
? ClipboardList
: undefined
// The second `dispatch` test that used to sit here was dead —
// the first branch above already catches it.
: activeSection === 'reports'
? ClipboardList
: undefined
}}
/>
@@ -413,7 +424,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
</div>
)}
<Routes>
<Route index element={<Navigate to="console" replace />} />
<Route index element={<Navigate to="dispatch" replace />} />
{/* `/store/console` kept as a redirect rather than deleted: it was
the landing route, so it is in bookmarks and in any link already
sent to a store user. Removing it outright would 404 them.
ABSOLUTE path, not `to="dispatch"`. A relative target resolves
against the matched route, so from `/store/console` it produced
`/store/console/dispatch` — a URL matching nothing, which
rendered a blank page. The index redirect above can stay
relative because it resolves against the parent. */}
<Route path="console" element={<Navigate to="/store/dispatch" replace />} />
<Route path="account" element={
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
{renderAccount()}
@@ -435,7 +456,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
} />
<Route path="dispatch" element={
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
<DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
<DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} isStoreUser />
</div>
} />
<Route path="reports" element={
@@ -448,11 +469,6 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
<StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />
</div>
} />
<Route path="console" element={
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
<StoreDetailView store={buildStore()} canManage={false} only="overview" tenantId={tenantId} />
</div>
} />
<Route path="customers" element={
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
<StoreDetailView store={buildStore()} canManage={false} only="customers" tenantId={tenantId} />

View File

@@ -153,25 +153,46 @@ export function PageHeader({
subtitle,
status,
right,
isTitleVisible = false,
}: {
title: string;
/** One line, sentence case. KROW uses this to say what the page is for. */
/** One line, sentence case. Not rendered unless `isTitleVisible`. */
subtitle?: string;
/** A chip that sits inline after the title — live/stale, count, scope. */
status?: React.ReactNode;
/** Actions aligned to the trailing edge. */
right?: React.ReactNode;
/**
* Opt back in to the visible 24px title + subtitle block. Off by default:
* the title/subtitle pair cost roughly 80px of vertical space on every page
* and said little the top navigation was not already saying — the active nav
* pill names the section, and the subtitles were descriptive filler.
*/
isTitleVisible?: boolean;
}) {
// The <h1> survives either way. Nine pages had NO heading at all before this
// work, which leaves a screen-reader user with no landmark for "what is this
// page" and no entry in the document outline. `sr-only` keeps the landmark at
// zero visual cost, which is the whole point of the class — hiding it with
// `display: none` would remove it from the accessibility tree too.
const heading = isTitleVisible
? <h1 className="page-title">{title}</h1>
: <h1 className="sr-only">{title}</h1>;
// Nothing visible to lay out — render the heading alone so the component
// occupies no space at all.
if (!isTitleVisible && !status && !right) return heading;
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
{/* `page-title` carries size, weight, tracking, line-height and colour
as one role — see the compound classes in index.css. */}
<h1 className="page-title">{title}</h1>
{heading}
{status}
</div>
{subtitle && <p className="page-sub mt-1">{subtitle}</p>}
{isTitleVisible && subtitle && <p className="page-sub mt-1">{subtitle}</p>}
</div>
{right && <div className="shrink-0">{right}</div>}
</div>

View File

@@ -305,7 +305,7 @@ export function answerLocally(question: string, snap: AssistantSnapshot): Omit<A
case 'help':
return {
text:
`I read this tenant's live data and answer from it — I do not guess.\n\n` +
`I'm Nearle Buddy. I read this tenant's live data and answer from it — I do not guess.\n\n` +
`Try:\n` +
`• "revenue this week"\n` +
`• "how many orders are pending?"\n` +