- Assistant
+ Nearle Buddy
{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)' }}
>
- Ask the assistant
+ Ask Nearle Buddy
diff --git a/src/components/DashboardView.tsx b/src/components/DashboardView.tsx
index 25679ef..30716a7 100644
--- a/src/components/DashboardView.tsx
+++ b/src/components/DashboardView.tsx
@@ -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 (
diff --git a/src/components/DispatchHubView.tsx b/src/components/DispatchHubView.tsx
index dc35e12..9a7042a 100644
--- a/src/components/DispatchHubView.tsx
+++ b/src/components/DispatchHubView.tsx
@@ -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(ymd(today));
@@ -57,7 +62,22 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
*/
const [pickedBranch, setPickedBranch] = useState('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 && (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 92062ab..7d09eee 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -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 (
- {/* Brand & Desktop Navigation Tabs */}
-
+ {/* 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. */}
+
{/* Brand cell — width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */}
- {/* 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).
-
- {navItems.map((item) => {
- const Icon = item.icon;
- return (
-
- `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'
- }`
- }
- >
-
- {item.label}
-
- );
- })}
-
- )}
-
- {/* Tenant → branch filter. Sits with the navigation because it scopes
- all of it: one choice, every section follows. */}
- {branches && branches.length > 0 && onBranchChange && (
-
-
- {/* Narrower until xl so the navigation keeps its full width at the
- mid sizes where the two were competing. */}
-
- onBranchChange(v === 'all' ? 'all' : Number(v))}
- options={[
- { value: 'all', label: `All branches (${branches.length})` },
- ...branches.map((b) => ({ value: String(b.locationid), label: b.locationname })),
- ]}
- />
-
-
- )}
-
{/* 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({
)}
+ {/* 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 && (
+
+ {navItems.map((item) => {
+ const Icon = item.icon;
+ return (
+
+ `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'
+ }`
+ }
+ >
+
+ {item.label}
+
+ );
+ })}
+
+ )}
+
{/* Global Actions Bar */}
-
+
+ {/* 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.
+
+
+ onBranchChange(v === 'all' ? 'all' : Number(v))}
+ options={[
+ { value: 'all', label: `All branches (${branches.length})` },
+ ...branches.map((b) => ({ value: String(b.locationid), label: b.locationname })),
+ ]}
+ />
+
+
+ )}
+
{/* Assistant rail toggle. Labelled rather than icon-only: it is the one
control in this bar whose icon carries no established meaning. */}
{onAssistantClick && (
-
-
- Assistant
+
+
)}
@@ -317,13 +359,26 @@ export default function Header({
{/* Store QR — opens the QR modal (user store page only) */}
{onQrClick && (
}
+ icon={
}
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"
>
-
- {/* Initials avatar with live status dot */}
-
-
- {initials}
-
-
+
+
+ {initials}
-
- {/* Identity (hidden on small screens) */}
-
- {profile.name}
- {profile.role}
-
-
-
+
@@ -420,6 +468,38 @@ export default function Header({
)}
+ {/* 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 && (
+ <>
+
+ Settings
+ {settingsSections.map((s) => {
+ const Icon = s.icon;
+ return (
+ 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"
+ >
+
+
+
+
+ {s.label}
+
+
+ );
+ })}
+
+ >
+ )}
+
- (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
diff --git a/src/components/PosConsoleView.tsx b/src/components/PosConsoleView.tsx
index 0f11d84..d1cc100 100644
--- a/src/components/PosConsoleView.tsx
+++ b/src/components/PosConsoleView.tsx
@@ -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={
-
- }
- right={
- // Wraps rather than overflowing: GradientHeader marks this side
- // shrink-0, so a fixed row would push the title off a narrow viewport.
-
- {RANGES.map((r) => (
-
- {
- setRangeId(r.id);
- resetPaging();
- }}
- >
- {r.label}
-
-
- ))}
- {/* IconButton still takes `label` (inherited from ButtonProps) —
- it becomes the accessible name rather than visible text, which
- the old `title`-only button never had. */}
-
}
- onClick={() => {
- healthQ.refetch();
- summaryQ.refetch();
- salesQ.refetch();
- }}
- />
-
- }
+ // 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 and takes no vertical space.
/>
0 ? '#f59e0b' : 'var(--color-slate-500)',
+ value: isError ? '—' : `${tillsOnline}/${allTerminals.length}`,
+ color: isError ? '#ef4444' : pendingTotal > 0 ? '#f59e0b' : 'var(--color-slate-500)',
icon: ,
- 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({
)}
+ {/* 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. */}
-
- {TABS.map((t) => (
-
- setTab(t.id)}
- >
- {t.icon}
- {t.label}
-
-
- ))}
+
+
+ {TABS.map((t) => (
+
+ setTab(t.id)}
+ >
+ {t.icon}
+ {t.label}
+
+
+ ))}
+
+
+
+
Period
+ {RANGES.map((r) => (
+
+ {
+ setRangeId(r.id);
+ resetPaging();
+ }}
+ >
+ {r.label}
+
+
+ ))}
+ {/* IconButton still takes `label` (inherited from ButtonProps) —
+ it becomes the accessible name rather than visible text, which
+ the old `title`-only button never had. */}
+
}
+ onClick={() => {
+ healthQ.refetch();
+ summaryQ.refetch();
+ salesQ.refetch();
+ }}
+ />
+
diff --git a/src/components/ReportsView.tsx b/src/components/ReportsView.tsx
index 65d8cf9..879fc54 100644
--- a/src/components/ReportsView.tsx
+++ b/src/components/ReportsView.tsx
@@ -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('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. */}
-
- {([
- { id: 'overview', label: 'Overview' },
- { id: 'sales_revenue', label: 'Sales & Revenue' },
- ] as const).map((t) => (
- 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'}`}
- />
- ))}
-
+ {/* 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
+ with `aria-current="page"`, which would claim a navigation that
+ never happens. SegmentedControl is the single-select control, which
+ is what this actually is. */}
+ setActiveTab(v as typeof activeTab)}
+ >
+
+
+
- {/* Global Store Filter */}
- {activeTab === 'sales_revenue' && (
-
-
- 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}`,
- })),
- ]}
- />
-
- )}
+ {/* 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. */}
{activeTab === 'overview' ? (
@@ -628,8 +626,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
-
- Weekly Order Distribution
+
+ Weekly order distribution
@@ -667,7 +665,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
- Outlet Name
+ Outlet Name
{WEEK_LABELS.map((w) => (
{w}
))}
@@ -676,26 +674,59 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{insightRows.map((row, idx) => (
-
- {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). */}
+
+ {row.name}
{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 (
-
- {/* 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. */}
+
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}
@@ -713,8 +744,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Leaderboard nodes bar list - 4 Cols */}
-
- Top Performing Nodes
+
+ Top performing nodes
@@ -725,40 +756,44 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
) : (
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 (
-
- {/* Ranking circle avatar */}
-
- {badgeContent}
+
+
+ {node.rank}
-
- {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`. */}
+
+ {node.name}
{index < 3 && (
-
+
Verified
)}
- {node.revenue}
+ {node.revenue}
@@ -781,50 +816,56 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Top 3 Overall Products - 4 Cols */}
{/* Subtle glow background */}
-
+ {/* PASS 4 — a blurred purple glow bleeding out of the corner, removed. */}
-
-
-
-
-
- Overall Top 3
-
+
+ {/* 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. */}
+ Overall top 3
-
+
{topOverallProducts.length === 0 ? (
No sales data available.
) : (
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 (
-
-
- {/* Rank Badge */}
-
- #{index + 1}
+
+
+ {index + 1}
- {/* Image */}
-
-
+
+
-
{prod.name}
+
{prod.name}
-
- ₹{prod.revenue.toLocaleString()} rev
+
+ ₹{prod.revenue.toLocaleString()}
{prod.category}
-
- {isFirst && (
-
- )}
);
})
@@ -833,14 +874,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Store-wise Top Products - 8 Cols */}
-
+ {/* 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. */}
+
-
-
-
-
- Store-Wise Top Performers
-
+
Store-wise top performers
{topProductsByStore.length === 0 ? (
@@ -850,14 +889,14 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{topProductsByStore.map(store => (
{store.topProducts.map((prod, idx) => (
-
+
{idx + 1}
@@ -889,9 +928,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
{/* Table header with filters control */}
-
- Product Performance Matrix
-
+
Product performance matrix
{/* 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'}`}
/>
-
-
-
-
-
-
{prod.name}
-
{prod.category}
+ {/* PASS 1 — `display: flex` was on the
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. */}
+
+
+
+
+
+
+ {prod.name}
+ {prod.category}
+
@@ -973,15 +1022,19 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
₹{prod.revenue.toLocaleString()}
+ {/* 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. */}
-
- {prod.stockStatus}
-
+
@@ -1151,7 +1204,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
)}
>
) : (
-
+
)}
);
diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx
index 5fe3ade..afafcda 100644
--- a/src/components/SettingsView.tsx
+++ b/src/components/SettingsView.tsx
@@ -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('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 = (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."
/>
-
- {/* Tab rail & Merchant Card */}
-
- {/* 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. */}
-
-
-
- {user?.name ? user.name.substring(0, 2).toUpperCase() : tenant ? fstr(tenant.tenantname).substring(0, 2).toUpperCase() : 'ND'}
-
-
-
{user?.name || (tenant ? fstr(tenant.tenantname) : 'Nearle Merchant')}
-
Store ID · #{tenantId}
-
-
-
-
- Status
-
-
-
-
- {/* Navigation tab rail */}
-
- {tabs.map((t) => {
- const Icon = t.icon;
- const active = activeTab === t.key;
- return (
- }
- onClick={() => setActiveTab(t.key)}
- />
- );
- })}
-
-
-
- {/* Panel */}
-
+ {/* 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. */}
+
+
{activeTab === 'profile' && (
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 141f686..af2d918 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -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 {
diff --git a/src/components/StoreDetailView.tsx b/src/components/StoreDetailView.tsx
index 2371e2d..591321a 100644
--- a/src/components/StoreDetailView.tsx
+++ b/src/components/StoreDetailView.tsx
@@ -917,17 +917,14 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
-
-
- Store Audience Registry
-
-
-
- System Sync Active
-
-
-
Customer CRM
-
+ {/* 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. */}
+
Customer CRM
+
{customersList.length} {customersList.length === 1 ? 'customer orders' : 'customers order'} from {store.name}
diff --git a/src/components/UserStorePage.tsx b/src/components/UserStorePage.tsx
index cb43176..30eca98 100644
--- a/src/components/UserStorePage.tsx
+++ b/src/components/UserStorePage.tsx
@@ -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 `
` — 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
;
if (activeSection === 'pos-till')
return
;
- if (activeSection === 'dispatch') return
;
+ if (activeSection === 'dispatch') return
;
if (activeSection === 'reports') return
;
// 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) {
)}
- } />
+ } />
+ {/* `/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. */}
+ } />
{renderAccount()}
@@ -435,7 +456,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
} />
-
+
} />
} />
-
-
-
- } />
diff --git a/src/components/consoleUi.tsx b/src/components/consoleUi.tsx
index 87fe4d6..f075373 100644
--- a/src/components/consoleUi.tsx
+++ b/src/components/consoleUi.tsx
@@ -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 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
+ ? {title}
+ : {title} ;
+
+ // Nothing visible to lay out — render the heading alone so the component
+ // occupies no space at all.
+ if (!isTitleVisible && !status && !right) return heading;
+
return (
-
+
{/* `page-title` carries size, weight, tracking, line-height and colour
as one role — see the compound classes in index.css. */}
-
{title}
+ {heading}
{status}
- {subtitle &&
{subtitle}
}
+ {isTitleVisible && subtitle &&
{subtitle}
}
{right &&
{right}
}
diff --git a/src/services/assistant.ts b/src/services/assistant.ts
index 84c189f..0bbce3c 100644
--- a/src/services/assistant.ts
+++ b/src/services/assistant.ts
@@ -305,7 +305,7 @@ export function answerLocally(question: string, snap: AssistantSnapshot): Omit