header changes
This commit is contained in:
35
src/App.tsx
35
src/App.tsx
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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` +
|
||||
|
||||
Reference in New Issue
Block a user