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, Store,
TrendingUp, TrendingUp,
Settings, Settings,
Users,
Truck Truck
} from 'lucide-react'; } from 'lucide-react';
@@ -164,6 +165,19 @@ export default function App() {
[locationsQ.data], [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 today = new Date();
const monthStart = new Date(today); const monthStart = new Date(today);
monthStart.setDate(today.getDate() - 30); monthStart.setDate(today.getDate() - 30);
@@ -231,6 +245,12 @@ export default function App() {
const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL'); const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');
const filteredStoresList = storesList.filter((st) => { 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(); const q = storesSearch.trim().toLowerCase();
// Match across every field shown on the card — name, zone, manager/contact, // 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 // and the outlet id — coercing each to a string so a missing/numeric value
@@ -616,6 +636,13 @@ export default function App() {
isAssistantOpen={assistantOpen} isAssistantOpen={assistantOpen}
navItems={ADMIN_NAV_ITEMS} navItems={ADMIN_NAV_ITEMS}
navBasePath="/admin" 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} branches={adminBranches}
selectedBranch={adminBranch} selectedBranch={adminBranch}
onBranchChange={setAdminBranch} onBranchChange={setAdminBranch}
@@ -678,7 +705,7 @@ export default function App() {
renderStoresSection() renderStoresSection()
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} /> <DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} locationid={scopedBranch} />
<div> <div>
{renderStoresSection()} {renderStoresSection()}
</div> </div>
@@ -692,6 +719,7 @@ export default function App() {
isCoimbatoreView={isCoimbatoreView} isCoimbatoreView={isCoimbatoreView}
tenantId={tenantId} tenantId={tenantId}
isSidebarOpen={sidebarOpen} isSidebarOpen={sidebarOpen}
locationid={scopedBranch}
/> />
} /> } />
@@ -703,6 +731,7 @@ export default function App() {
isCoimbatoreView={isCoimbatoreView} isCoimbatoreView={isCoimbatoreView}
setIsCoimbatoreView={setIsCoimbatoreView} setIsCoimbatoreView={setIsCoimbatoreView}
tenantId={tenantId} tenantId={tenantId}
locationid={scopedBranch}
/> />
} /> } />
@@ -711,13 +740,13 @@ export default function App() {
} /> } />
<Route path="dispatch" element={ <Route path="dispatch" element={
<DispatchHubView tenantId={tenantId} /> <DispatchHubView tenantId={tenantId} locationid={scopedBranch} />
} /> } />
{/* Tenant-wide POS: no locationid, so the view fans out over every {/* Tenant-wide POS: no locationid, so the view fans out over every
outlet under the tenant. */} outlet under the tenant. */}
<Route path="pos" element={ <Route path="pos" element={
<PosErrorBoundary><PosConsoleView tenantId={tenantId} /></PosErrorBoundary> <PosErrorBoundary><PosConsoleView tenantId={tenantId} locationid={scopedBranch} /></PosErrorBoundary>
} /> } />
</Routes> </Routes>

View File

@@ -121,7 +121,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
/> />
<aside <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]" 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 }} 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> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-semibold" style={{ color: TEXT }}> <p className="truncate text-[13px] font-semibold" style={{ color: TEXT }}>
Assistant Nearle Buddy
</p> </p>
<p className="truncate text-[11px]" style={{ color: TEXT_2 }}> <p className="truncate text-[11px]" style={{ color: TEXT_2 }}>
{scope.branchName ?? scope.tenantname ?? 'This tenant'} {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)' }} style={{ border: `1px solid ${BORDER}`, background: 'var(--color-slate-50)' }}
> >
<label htmlFor="assistant-input" className="sr-only"> <label htmlFor="assistant-input" className="sr-only">
Ask the assistant Ask Nearle Buddy
</label> </label>
<textarea <textarea
id="assistant-input" id="assistant-input"
@@ -285,7 +285,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
send(draft); 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" className="max-h-28 min-h-[24px] flex-1 resize-none bg-transparent text-[13px] leading-relaxed outline-none"
style={{ color: TEXT }} style={{ color: TEXT }}
/> />

View File

@@ -14,6 +14,15 @@ interface DashboardViewProps {
isCoimbatoreView: boolean; isCoimbatoreView: boolean;
/** Fiesta merchant tenant to scope live store summaries to. */ /** Fiesta merchant tenant to scope live store summaries to. */
tenantId?: number; 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) => const ymd = (d: Date) =>
@@ -21,7 +30,7 @@ const ymd = (d: Date) =>
const str = (v: unknown): string => (v == null ? '' : String(v)); 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. // Live data — month-to-date order summary + tenant identity + store locations.
const today = new Date(); const today = new Date();
const monthStart = new Date(today); 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. // deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed.
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate); const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate);
const locationsQ = useFiestaTenantLocations(tenantId); 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 s = summaryQ.data;
const tenantName = s?.tenantname || `Tenant ${tenantId}`; 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 locSummaryQ = useFiestaLocationSummary(tenantId, fromdate, todate);
const summaries = locSummaryQ.data ?? []; const summaries = locSummaryQ.data ?? [];
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant. /**
const ordersTotal = s?.total ?? 0; * Order counts, scoped to the header branch when one is picked.
const ordersDelivered = s?.delivered ?? 0; *
* 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 regionFulfillmentPct = ordersTotal > 0 ? (ordersDelivered / ordersTotal) * 100 : null;
const locations = (locationsQ.data ?? []).filter((loc) => { 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; if (!searchQuery) return true;
const q = searchQuery.toLowerCase(); const q = searchQuery.toLowerCase();
return ( return (

View File

@@ -36,13 +36,18 @@ interface DispatchHubViewProps {
* somebody else's outlet. * somebody else's outlet.
*/ */
locationid?: number; 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; tenantId?: number;
} }
/** Every surface this console can show. */ /** Every surface this console can show. */
type ConsoleTab = 'overview' | 'map' | 'orders' | 'deliveries' | 'products' | 'catalogue' | 'pos'; 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 [mapViewMode, setMapViewMode] = useState<'stores' | 'customers'>('stores');
const today = new Date(); const today = new Date();
const [date, setDate] = useState<string>(ymd(today)); 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 [pickedBranch, setPickedBranch] = useState<number | 'all'>('all');
const scopedLocationId = locationid ?? (pickedBranch === 'all' ? undefined : pickedBranch); 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 * 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 Hidden when the caller already pinned one: a store user must not
be able to page through other branches from here. Options come be able to page through other branches from here. Options come
from tenantlocations, so the value is a real locationid. */} 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"> <div className="flex items-center gap-2 min-w-0">
<Store size={14} className="text-slate-400 shrink-0" /> <Store size={14} className="text-slate-400 shrink-0" />
<div className="w-44 sm:w-56"> <div className="w-44 sm:w-56">

View File

@@ -4,7 +4,7 @@
*/ */
import React, { useState, useRef, useEffect } from 'react'; 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 { NavLink } from 'react-router-dom';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
@@ -57,6 +57,16 @@ interface HeaderProps {
selectedBranch?: number | 'all'; selectedBranch?: number | 'all';
onBranchChange?: (v: number | 'all') => void; 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. */ /** Signed-in user shown in the profile dropdown. */
profile: { name: string; role: string; email: string }; profile: { name: string; role: string; email: string };
/** Optional store context to display next to the sidebar toggle. */ /** Optional store context to display next to the sidebar toggle. */
@@ -81,6 +91,7 @@ export default function Header({
branches, branches,
selectedBranch, selectedBranch,
onBranchChange, onBranchChange,
settingsSections,
profile, profile,
storeContext storeContext
}: HeaderProps) { }: HeaderProps) {
@@ -129,8 +140,16 @@ export default function Header({
return ( 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"> <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 */} {/* Brand cell.
<div className="flex items-center gap-sm sm:gap-md md:pl-0 pl-1 min-w-0"> `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 */} {/* Brand cell — width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */}
<div <div
// The fixed widths mirror the rail's 256/64px so the logo sits above // The fixed widths mirror the rail's 256/64px so the logo sits above
@@ -207,60 +226,6 @@ export default function Header({
/> />
</div> </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. {/* Dynamic Store Name Context.
Hidden on desktop once there is a top nav, for two reasons. It is 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 redundant — it names the section the active nav pill is already
@@ -292,24 +257,101 @@ export default function Header({
)} )}
</div> </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 */} {/* 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 {/* Assistant rail toggle. Labelled rather than icon-only: it is the one
control in this bar whose icon carries no established meaning. */} control in this bar whose icon carries no established meaning. */}
{onAssistantClick && ( {onAssistantClick && (
<Button <Button
label={isAssistantOpen ? 'Close assistant' : 'Open assistant'} label={isAssistantOpen ? 'Close Nearle Buddy' : 'Open Nearle Buddy'}
variant="ghost" variant="ghost"
onClick={onAssistantClick} onClick={onAssistantClick}
aria-expanded={isAssistantOpen} aria-expanded={isAssistantOpen}
style={{ height: 'auto', background: 'transparent', border: 'none' }} // Icon only. `label` is still "Open/Close assistant", and Button
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 ${ // promotes it to the accessible name when it differs from the
isAssistantOpen ? 'bg-white/20' : 'bg-white/10 hover:bg-white/15' // 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"> <span className="flex items-center justify-center">
<Sparkles size={15} /> <Sparkles size={16} />
<span className="hidden sm:inline">Assistant</span>
</span> </span>
</Button> </Button>
)} )}
@@ -317,13 +359,26 @@ export default function Header({
{/* Store QR — opens the QR modal (user store page only) */} {/* Store QR — opens the QR modal (user store page only) */}
{onQrClick && ( {onQrClick && (
<IconButton <IconButton
icon={<QrCode size={18} />} icon={<QrCode size={16} />}
label="Store QR code" label="Store QR code"
tooltip="Store QR code" tooltip="Store QR code"
variant="ghost" variant="ghost"
onClick={onQrClick} onClick={onQrClick}
style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }} // Same 36px round chip as the assistant beside it. It was
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white" // `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)} onClick={() => setShowProfileDropdown(!showProfileDropdown)}
aria-expanded={showProfileDropdown} aria-expanded={showProfileDropdown}
aria-haspopup="dialog" 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' }} 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"> <span className="relative shrink-0 block">
{/* Initials avatar with live status dot */} <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">
<span className="relative shrink-0"> {initials}
<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> </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)]" />
{/* 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> </span>
</Button> </Button>
@@ -420,6 +468,38 @@ export default function Header({
</span> </span>
</Button> </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 <Button
label="Help Center" label="Help Center"
variant="ghost" variant="ghost"

View File

@@ -69,13 +69,16 @@ interface InventoryViewProps {
isCoimbatoreView: boolean; isCoimbatoreView: boolean;
tenantId?: number; tenantId?: number;
isSidebarOpen?: boolean; isSidebarOpen?: boolean;
/** The branch chosen in the top bar; undefined means the whole tenant. */
locationid?: number;
} }
export default function InventoryView({ export default function InventoryView({
searchQuery, searchQuery,
isCoimbatoreView, isCoimbatoreView,
tenantId = FIESTA_TENANT_ID, tenantId = FIESTA_TENANT_ID,
isSidebarOpen = false isSidebarOpen = false,
locationid
}: InventoryViewProps) { }: InventoryViewProps) {
const { setHideCompareBar } = useCompare(); const { setHideCompareBar } = useCompare();
const [searchTerm, setSearchTerm] = useState(''); 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 // 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. // can see all the stores under them at a glance.
const locationsQ = useFiestaTenantLocations(tenantId); 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( const locations = useMemo(
() => () =>
(locationsQ.data ?? []).map((l) => ({ (locationsQ.data ?? [])
locationid: Number(l.locationid), .map((l) => ({
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`, locationid: Number(l.locationid),
status: fstr(l.status) || 'Active', locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
})), status: fstr(l.status) || 'Active',
[locationsQ.data], }))
.filter((l) => !locationid || l.locationid === locationid),
[locationsQ.data, locationid],
); );
// The admin catalogue (imports, curation) reads through this tenant's own // The admin catalogue (imports, curation) reads through this tenant's own

View File

@@ -99,7 +99,6 @@ import {
FilterBar, FilterBar,
PageHeader, PageHeader,
KpiStrip, KpiStrip,
LiveStatus,
MetricPill, MetricPill,
Pill, Pill,
SlideDrawer, SlideDrawer,
@@ -303,52 +302,16 @@ export default function PosConsoleView({
? `${outletName.get(billOutlet) || storeName || 'Your store'} · counter sales and till health` ? `${outletName.get(billOutlet) || storeName || 'Your store'} · counter sales and till health`
: `${outlets.length} outlet${outlets.length === 1 ? '' : 's'} · counter sales and till health` : `${outlets.length} outlet${outlets.length === 1 ? '' : 's'} · counter sales and till health`
} }
status={ // The "N of M tills online · refreshed every 30s" line is gone. The
<LiveStatus // count it carried is already the "Tills online" KPI card directly
state={isError ? 'error' : isLoading ? 'loading' : 'live'} // below, and the refresh cadence is not something anyone acts on.
label={ //
isError // Failure is NOT silently dropped with it: `isError` still surfaces on
? 'POS service unreachable' // the Tills online card, which turns red and reads "POS unreachable"
: isLoading // rather than showing a stale figure as if it were current.
? 'Loading counter sales…' //
: `${tillsOnline} of ${allTerminals.length} till${allTerminals.length === 1 ? '' : 's'} online · refreshed every 30s` // With no status and no right-hand slot left, PageHeader collapses to
} // its screen-reader-only <h1> and takes no vertical space.
/>
}
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>
}
/> />
<KpiStrip <KpiStrip
@@ -376,11 +339,18 @@ export default function PosConsoleView({
badge: `${merged?.itemcount ?? 0} items`, 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', label: 'Tills online',
value: `${tillsOnline}/${allTerminals.length}`, value: isError ? '—' : `${tillsOnline}/${allTerminals.length}`,
color: pendingTotal > 0 ? '#f59e0b' : 'var(--color-slate-500)', color: isError ? '#ef4444' : pendingTotal > 0 ? '#f59e0b' : 'var(--color-slate-500)',
icon: <Wifi size={16} />, icon: <Wifi size={16} />,
badge: pendingTotal > 0 ? `${pendingTotal} unsynced` : undefined, badge: isError
? 'POS service unreachable'
: pendingTotal > 0
? `${pendingTotal} unsynced`
: undefined,
onClick: () => setTab('terminals'), onClick: () => setTab('terminals'),
}, },
]} ]}
@@ -407,20 +377,59 @@ export default function PosConsoleView({
)} )}
<div className="mt-4"> <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> <FilterBar>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center justify-between gap-4 flex-wrap">
{TABS.map((t) => ( <div className="flex items-center gap-2 flex-wrap">
<React.Fragment key={t.id}> {TABS.map((t) => (
<Pill <React.Fragment key={t.id}>
active={tab === t.id} <Pill
color={BRAND} active={tab === t.id}
onClick={() => setTab(t.id)} color={BRAND}
> onClick={() => setTab(t.id)}
{t.icon} >
{t.label} {t.icon}
</Pill> {t.label}
</React.Fragment> </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> </div>
</FilterBar> </FilterBar>
</div> </div>

View File

@@ -41,6 +41,7 @@ import { stockRowToProduct } from '../services/fiestaMappers';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { ProgressBar } from '@astryxdesign/core/ProgressBar'; import { ProgressBar } from '@astryxdesign/core/ProgressBar';
import { AppDialog } from './AppDialog'; import { AppDialog } from './AppDialog';
import { import {
@@ -49,26 +50,27 @@ import {
import AwaitingApi from './AwaitingApi'; import AwaitingApi from './AwaitingApi';
import SalesRevenueReport from './SalesRevenueReport'; import SalesRevenueReport from './SalesRevenueReport';
import { useNotify } from './Toasts'; import { useNotify } from './Toasts';
import { KpiStrip, PageHeader } from './consoleUi'; import { KpiStrip, PageHeader, StatusChip } from './consoleUi';
interface ReportsViewProps { interface ReportsViewProps {
searchQuery: string; searchQuery: string;
isCoimbatoreView: boolean; isCoimbatoreView: boolean;
setIsCoimbatoreView: (val: boolean) => void; setIsCoimbatoreView: (val: boolean) => void;
tenantId?: number; 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_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5']; 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 // PHASE 6 — replaces alert(). A native alert freezes the whole browser
// until dismissed; a toast does not, and errors stay until read. // until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify(); const { notifyOk, notifyFail } = useNotify();
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)'); const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all'); 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 [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null); const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -131,7 +133,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
return rawLocations; return rawLocations;
}, [locSummaryQ.data, selectedRegion]); }, [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(() => { const filteredLocations = useMemo(() => {
if (effectiveStoreId !== 'all') { if (effectiveStoreId !== 'all') {
@@ -350,7 +358,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
rank: String(i + 1).padStart(2, '0'), rank: String(i + 1).padStart(2, '0'),
name: r.name, name: r.name,
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0, 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; const currentLeaderboard = leaderboard;
@@ -420,21 +428,26 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
}, [activeRegionStock]); }, [activeRegionStock]);
// Heatmap cell color gradient scale (multi-stop violet theme) // Heatmap cell color gradient scale (multi-stop violet theme)
const getHeatmapColorClass = (val: number) => { /**
const ratio = val / heatmapMax; * PASS 2 — the heat ramp, rebuilt on one hue.
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'; * 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
if (ratio <= 0.4) { * it climbed rather than just getting darker. It also gave every cell its own
return 'bg-purple-50 text-purple-700 border border-purple-100/50 hover:bg-purple-100 hover:scale-105'; * 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.
if (ratio <= 0.7) { *
return 'bg-purple-200/50 text-purple-900 border border-purple-200/40 hover:bg-purple-200 hover:scale-105'; * 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)
if (ratio <= 0.9) { * reads as an empty grid rather than a wall of grey boxes.
return 'bg-[#7c3aed]/20 text-[#7c3aed] border border-[#7c3aed]/20 hover:bg-[#7c3aed]/30 hover:scale-105'; */
} const getHeatmapStyle = (val: number): React.CSSProperties => {
return 'bg-[var(--color-brand)] text-white border-none hover:bg-[var(--color-brand-strong)]'; 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 // 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 Astryx ships no ARIA tablist, so this uses the same pressed-button
pattern as the other two single-select groups on this page, and pattern as the other two single-select groups on this page, and
keeps the original underline styling exactly. */} keeps the original underline styling exactly. */}
<div className="flex items-center gap-4"> {/* PASS 5 — the same SegmentedControl the store Reports page uses.
{([ These two tab strips split the same section on two pages and looked
{ id: 'overview', label: 'Overview' }, nothing alike: underlined text here, a pill group there.
{ id: 'sales_revenue', label: 'Sales & Revenue' },
] as const).map((t) => ( The Phase 6 note that used to sit here argued against Astryx's
<Button TabList, and that reasoning still holds — TabList renders a <nav>
key={t.id} with `aria-current="page"`, which would claim a navigation that
label={t.label} never happens. SegmentedControl is the single-select control, which
aria-pressed={activeTab === t.id} is what this actually is. */}
variant="ghost" <SegmentedControl
onClick={() => setActiveTab(t.id)} label="Report section"
style={{ height: 'auto' }} value={activeTab}
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'}`} onChange={(v) => setActiveTab(v as typeof activeTab)}
/> >
))} <SegmentedControlItem value="overview" label="Overview" />
</div> <SegmentedControlItem value="sales_revenue" label="Sales &amp; Revenue" />
</SegmentedControl>
{/* Global Store Filter */} {/* The page's own "All Stores" picker is gone — the branch filter in the
{activeTab === 'sales_revenue' && ( top bar is the single scope control for the whole admin workspace,
<div className="flex items-center gap-2"> and two pickers that set the same scope is exactly how a page ends
<Store size={16} className="text-slate-500" /> up disagreeing with its own header. */}
<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>
)}
</div> </div>
{activeTab === 'overview' ? ( {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="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"> <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"> <span className="section-title block">
Weekly Order Distribution Weekly order distribution
</span> </span>
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight"> <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"> <Table className="w-full text-center text-xs font-sans">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHeaderCell className="w-32 text-left">Outlet Name</TableHeaderCell> <TableHeaderCell className="text-left" style={{ minWidth: 190 }}>Outlet Name</TableHeaderCell>
{WEEK_LABELS.map((w) => ( {WEEK_LABELS.map((w) => (
<TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell> <TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell>
))} ))}
@@ -676,26 +674,59 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
<TableBody> <TableBody>
{insightRows.map((row, idx) => ( {insightRows.map((row, idx) => (
<TableRow key={idx}> <TableRow key={idx}>
<TableCell className="text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100"> {/* No max-width cap: the column had `max-w-[8rem]` and
{row.name} 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> </TableCell>
{WEEK_KEYS.map((key, mIdx) => { {WEEK_KEYS.map((key, mIdx) => {
const val = fnum(row.weeks[key]); const val = fnum(row.weeks[key]);
const isPicked = const isPicked =
selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx]; 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 ( return (
<TableCell key={key} className="p-1"> <TableCell key={key} style={{ padding: 0 }}>
{/* 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. */}
<Button <Button
label={`${row.name}, ${WEEK_LABELS[mIdx]}: ${val} orders`} label={`${row.name}, ${WEEK_LABELS[mIdx]}: ${val} orders`}
aria-pressed={isPicked} aria-pressed={isPicked}
variant="ghost" variant="ghost"
onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })} onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })}
style={{ height: 'auto' }} style={{
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer ${getHeatmapColorClass(val)}`} 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} {val}
</Button> </Button>
@@ -713,8 +744,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Leaderboard nodes bar list - 4 Cols */} {/* 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="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"> <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"> <span className="section-title block">
Top Performing Nodes Top performing nodes
</span> </span>
</div> </div>
@@ -725,40 +756,44 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div> </div>
) : ( ) : (
currentLeaderboard.map((node, index) => { currentLeaderboard.map((node, index) => {
let badgeStyle = ''; // PASS 4 — the top three carried literal 🥇🥈🥉 emoji on amber,
let badgeContent: React.ReactNode = node.rank; // slate and orange grounds. Emoji render as a different typeface
// at a different weight on every platform, they cannot be
if (index === 0) { // recoloured with the theme, and a screen reader announces
badgeStyle = 'bg-amber-100 text-amber-900 border border-amber-200'; // "1st place medal" where the column means "rank 1". Ranks are
badgeContent = '🥇'; // numerals now, the leader marked by the brand rather than by
} else if (index === 1) { // gold. Nothing else in the console uses medals.
badgeStyle = 'bg-slate-100 text-slate-700 border border-slate-200'; const isLeader = index === 0;
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';
}
return ( 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"> <div key={node.rank} className="flex items-center gap-4 hover:bg-slate-50 p-2.5 rounded-control transition-colors duration-150 group">
{/* Ranking circle avatar */} <div
<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}`}> className="w-8 h-8 rounded-full flex items-center justify-center font-mono tabular-nums shrink-0 select-none"
{badgeContent} 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>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex justify-between items-center font-medium mb-1.5"> <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"> {/* PASS 1 — `truncate max-w-[12rem]` was on the wrapper
{node.name} 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 && ( {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 <CheckCircle2 size={10} /> Verified
</span> </span>
)} )}
</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>
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden"> <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 */} {/* 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"> <div className="lg:col-span-4 bg-surface border border-slate-200 rounded-card flex flex-col relative overflow-hidden">
{/* Subtle glow background */} {/* 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"> <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"> {/* PASS 3/4 — one heading role, and the amber trophy medallion is
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center shadow-inner"> gone. Five sections on this page carried five different heading
<Trophy className="text-amber-500 fill-amber-100" size={12} /> treatments (11px uppercase, two at 13px with icon tiles, one at
</div> 14px, one at 16px); none of them was the `section-title` role
Overall Top 3 every other page uses. */}
</span> <span className="section-title">Overall top 3</span>
</div> </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 ? ( {topOverallProducts.length === 0 ? (
<div className="text-center py-6 text-slate-400 font-medium text-[11px]">No sales data available.</div> <div className="text-center py-6 text-slate-400 font-medium text-[11px]">No sales data available.</div>
) : ( ) : (
topOverallProducts.map((prod, index) => { 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; const isFirst = index === 0;
return ( 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'}`}> <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
{/* Rank Badge */} className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 font-mono tabular-nums"
<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'}`}> style={
#{index + 1} 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> </div>
{/* Image */} <div className="w-10 h-10 rounded-control overflow-hidden shrink-0 bg-surface border border-slate-200">
<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" />
<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> </div>
<div className="flex-1 min-w-0 py-0.5"> <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"> <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'}`}> <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()} rev ₹{prod.revenue.toLocaleString()}
</span> </span>
<span className="w-1 h-1 rounded-full bg-slate-300" /> <span className="w-1 h-1 rounded-full bg-slate-300" />
<span className="eyebrow">{prod.category}</span> <span className="eyebrow">{prod.category}</span>
</div> </div>
</div> </div>
{isFirst && (
<div className="" />
)}
</div> </div>
); );
}) })
@@ -833,14 +874,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
</div> </div>
{/* Store-wise Top Products - 8 Cols */} {/* 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"> <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"> <span className="section-title">Store-wise top performers</span>
<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>
</div> </div>
<div className="p-5 flex-1 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-300 scrollbar-track-transparent pb-4"> <div className="p-5 flex-1 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-300 scrollbar-track-transparent pb-4">
{topProductsByStore.length === 0 ? ( {topProductsByStore.length === 0 ? (
@@ -850,14 +889,14 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{topProductsByStore.map(store => ( {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 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="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> <span className="truncate">{store.locationname}</span>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{store.topProducts.map((prod, idx) => ( {store.topProducts.map((prod, idx) => (
<div key={prod.id} className="flex items-center gap-2.5 group/item"> <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} {idx + 1}
</div> </div>
@@ -889,9 +928,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Table header with filters control */} {/* 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 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> <div>
<h3 className="font-sans font-bold text-sm text-[var(--color-slate-900)] flex items-center gap-1.5"> <h3 className="section-title">Product performance matrix</h3>
<Activity size={15} className="text-[var(--color-brand-ink)]" /> Product Performance Matrix
</h3>
</div> </div>
{/* Quick interactive filter pills */} {/* 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'}`} className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
/> />
</TableCell> </TableCell>
<TableCell className="flex items-center gap-sm"> {/* PASS 1 — `display: flex` was on the <td> itself.
<div className="w-8 h-8 rounded-lg shrink-0 border border-[var(--color-slate-200)] overflow-hidden bg-zinc-50"> A flexed table cell drops out of the table layout
<img algorithm entirely: it no longer takes part in column
src={prod.image} sizing, so it collapsed to its content's min-width
alt={prod.name} (32px — the thumbnail) while SKU / Units / Revenue /
referrerPolicy="no-referrer" Status split the remaining 1420px between them. The
className="w-full h-full object-cover" product name then wrapped one character per line
/> inside those 32px, which is what pushed the row to
</div> 285px tall with an apparently empty name column.
<div> The flex belongs on a wrapper inside the cell. */}
<span className="font-bold text-[var(--color-slate-900)] block">{prod.name}</span> <TableCell>
<span className="eyebrow block mt-0.5">{prod.category}</span> <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> </div>
</TableCell> </TableCell>
<TableCell className="font-mono text-zinc-500 font-semibold"> <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"> <TableCell className="text-right font-mono text-zinc-800 font-bold">
₹{prod.revenue.toLocaleString()} ₹{prod.revenue.toLocaleString()}
</TableCell> </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"> <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' <StatusChip
? 'bg-emerald-50 text-emerald-600 border border-emerald-100' label={prod.stockStatus}
: prod.stockStatus === 'Low Stock' color={
? 'bg-amber-50 text-amber-600 border border-amber-100' prod.stockStatus === 'Healthy' ? '#10b981'
: 'bg-rose-50 text-rose-600 border border-rose-100' : prod.stockStatus === 'Low Stock' ? '#f59e0b'
}`}> : '#ef4444'
{prod.stockStatus} }
</span> />
</TableCell> </TableCell>
</TableRow> </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> </div>
); );

View File

@@ -4,6 +4,7 @@
*/ */
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { import {
Building2, Building2,
Store, Store,
@@ -32,7 +33,7 @@ import { Badge } from '@astryxdesign/core/Badge';
import { Heading } from '@astryxdesign/core/Heading'; import { Heading } from '@astryxdesign/core/Heading';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Spinner } from '@astryxdesign/core/Spinner'; import { Spinner } from '@astryxdesign/core/Spinner';
import { StatusChip, PageHeader, SHADOW_MD } from './consoleUi'; import { PageHeader } from './consoleUi';
type TabKey = 'profile' | 'outlets' | 'users'; 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. // until dismissed; a toast does not, and errors stay until read.
const { notifyOk, notifyFail } = useNotify(); 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. // Live tenant profile + outlets.
// Fetch a larger page size to ensure we find our specific tenant (ID 1087). // 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]) => const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
setForm((f) => ({ ...f, [key]: value })); setForm((f) => ({ ...f, [key]: value }));
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [ // The `tabs` array that used to live here is gone with the rail it fed. The
{ key: 'profile', label: 'Business Profile', icon: Building2 }, // same three sections are declared once, in App.tsx, as the account
{ key: 'outlets', label: 'Outlets', icon: Store }, // dropdown's `settingsSections` — the labels a user sees now have one source.
{ key: 'users', label: 'Users & Access', icon: Users },
];
// Build role options from the live app-roles API; fall back to the known // 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. // 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." 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"> {/* The left column is gone: a section rail and a merchant identity card.
{/* Tab rail & Merchant Card */} Both were saying something already said elsewhere —
<div className="lg:col-span-1 space-y-md bg-slate-50/50 border border-slate-200/60 p-4 shadow-sm"> • the rail duplicated the account dropdown, which now lists the same
{/* Merchant ID Card */} three sections and links straight into each;
{/* STAGE 8 — the merchant card was the last dark surface in the • the card showed who is signed in and the store's status, which is
console: a three-stop near-black gradient with a blurred purple the account dropdown's whole job (name, role, email) plus a status
blob, a purple→indigo avatar and a 15px glow ring. It is a plain that never varies.
card now, like everything else. */} The panel takes the full width instead of three quarters of it. */}
<div className="bg-surface border border-slate-200 p-4 rounded-card select-none" style={{ boxShadow: SHADOW_MD }}> <div className="grid grid-cols-1 gap-gutter items-start">
<div className="flex items-center gap-3"> <div className="space-y-gutter text-sm pb-24">
<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">
{activeTab === 'profile' && ( {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 className="bg-surface border border-slate-200/60 p-6 shadow-sm space-y-lg animate-in fade-in duration-200">
<div> <div>

View File

@@ -37,7 +37,10 @@ export const ADMIN_NAV_ITEMS = [
// bookmarks and the section-height rules in App.tsx keep working. // bookmarks and the section-height rules in App.tsx keep working.
{ id: 'dispatch' as MainSection, label: 'Store Console', icon: Truck }, { id: 'dispatch' as MainSection, label: 'Store Console', icon: Truck },
{ id: 'pos' as MainSection, label: 'POS', icon: Monitor }, { 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 { interface SidebarProps {

View File

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

View File

@@ -56,11 +56,22 @@ interface UserStorePageProps {
// Sidebar navigation. Add entries here as new user sections are built — each id // Sidebar navigation. Add entries here as new user sections are built — each id
// gets a matching branch in `renderSection` below. // 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[] = [ 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: 'inventory', label: 'Products', icon: Layers },
{ id: 'customers', label: 'Customers', icon: Users }, { id: 'customers', label: 'Customers', icon: Users },
{ id: 'dispatch', label: 'Console', icon: RouteIcon },
{ id: 'pos', label: 'POS', icon: Monitor }, { id: 'pos', label: 'POS', icon: Monitor },
{ id: 'reports', label: 'Reports', icon: ClipboardList }, { 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 // Extract the current section from the URL for Header context
const pathParts = location.pathname.split('/'); 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 // 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. // 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} />; return <PosStoreSection locationid={posLocationId} tenantId={tenantId} storeName={storeName} />;
if (activeSection === 'pos-till') if (activeSection === 'pos-till')
return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />; 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} />; if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
// Inventory & Catalog is its own page: the manager-curated catalog the user // 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 // 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" navBasePath="/store"
profile={profile} profile={profile}
storeContext={{ 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 ? storeName
: activeSection === 'inventory' : activeSection === 'inventory'
? 'Products' ? 'Products'
@@ -363,10 +376,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
? 'POS Terminal' ? 'POS Terminal'
: activeSection === 'account' : activeSection === 'account'
? 'My Account' ? 'My Account'
: activeSection === 'dispatch'
? 'Console'
: activeSection.charAt(0).toUpperCase() + activeSection.slice(1), : activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
icon: activeSection === 'console' icon: activeSection === 'dispatch'
? LayoutDashboard ? LayoutDashboard
: activeSection === 'inventory' : activeSection === 'inventory'
? Layers ? Layers
@@ -376,11 +387,11 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
? ShoppingBag ? ShoppingBag
: activeSection === 'customers' : activeSection === 'customers'
? Users ? Users
: activeSection === 'dispatch' // The second `dispatch` test that used to sit here was dead —
? RouteIcon // the first branch above already catches it.
: activeSection === 'reports' : activeSection === 'reports'
? ClipboardList ? ClipboardList
: undefined : undefined
}} }}
/> />
@@ -413,7 +424,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
</div> </div>
)} )}
<Routes> <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={ <Route path="account" element={
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300"> <div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
{renderAccount()} {renderAccount()}
@@ -435,7 +456,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
} /> } />
<Route path="dispatch" element={ <Route path="dispatch" element={
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}> <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> </div>
} /> } />
<Route path="reports" element={ <Route path="reports" element={
@@ -448,11 +469,6 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
<StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} /> <StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />
</div> </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={ <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' : ''}`}> <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} /> <StoreDetailView store={buildStore()} canManage={false} only="customers" tenantId={tenantId} />

View File

@@ -153,25 +153,46 @@ export function PageHeader({
subtitle, subtitle,
status, status,
right, right,
isTitleVisible = false,
}: { }: {
title: string; 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; subtitle?: string;
/** A chip that sits inline after the title — live/stale, count, scope. */ /** A chip that sits inline after the title — live/stale, count, scope. */
status?: React.ReactNode; status?: React.ReactNode;
/** Actions aligned to the trailing edge. */ /** Actions aligned to the trailing edge. */
right?: React.ReactNode; 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 ( 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="min-w-0">
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
{/* `page-title` carries size, weight, tracking, line-height and colour {/* `page-title` carries size, weight, tracking, line-height and colour
as one role — see the compound classes in index.css. */} as one role — see the compound classes in index.css. */}
<h1 className="page-title">{title}</h1> {heading}
{status} {status}
</div> </div>
{subtitle && <p className="page-sub mt-1">{subtitle}</p>} {isTitleVisible && subtitle && <p className="page-sub mt-1">{subtitle}</p>}
</div> </div>
{right && <div className="shrink-0">{right}</div>} {right && <div className="shrink-0">{right}</div>}
</div> </div>

View File

@@ -305,7 +305,7 @@ export function answerLocally(question: string, snap: AssistantSnapshot): Omit<A
case 'help': case 'help':
return { return {
text: 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` + `Try:\n` +
`• "revenue this week"\n` + `• "revenue this week"\n` +
`• "how many orders are pending?"\n` + `• "how many orders are pending?"\n` +