header changes
This commit is contained in:
35
src/App.tsx
35
src/App.tsx
@@ -20,6 +20,7 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Settings,
|
Settings,
|
||||||
|
Users,
|
||||||
Truck
|
Truck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -164,6 +165,19 @@ export default function App() {
|
|||||||
[locationsQ.data],
|
[locationsQ.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The header filter as a `locationid`, handed to every admin page.
|
||||||
|
*
|
||||||
|
* Until now this state reached nothing but the assistant's scope — the
|
||||||
|
* control looked like it worked and changed nothing on screen, while each
|
||||||
|
* page carried its own store picker instead. Those page-level pickers are
|
||||||
|
* gone; this is the single scope for the admin workspace.
|
||||||
|
*
|
||||||
|
* `undefined` (not 0) for "all branches": every read treats a missing
|
||||||
|
* locationid as tenant-wide, and 0 would be sent as a real id.
|
||||||
|
*/
|
||||||
|
const scopedBranch = adminBranch === 'all' ? undefined : adminBranch;
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today);
|
const monthStart = new Date(today);
|
||||||
monthStart.setDate(today.getDate() - 30);
|
monthStart.setDate(today.getDate() - 30);
|
||||||
@@ -231,6 +245,12 @@ export default function App() {
|
|||||||
const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');
|
const [storesFilter, setStoresFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL');
|
||||||
|
|
||||||
const filteredStoresList = storesList.filter((st) => {
|
const filteredStoresList = storesList.filter((st) => {
|
||||||
|
// The header branch filter scopes the store cards too. These are rendered
|
||||||
|
// here rather than inside DashboardView, so filtering the view's own list
|
||||||
|
// left the cards showing all five outlets while the KPIs above them read
|
||||||
|
// "1 / 1" — the page disagreeing with itself.
|
||||||
|
if (scopedBranch && Number(st.locationid) !== scopedBranch) return false;
|
||||||
|
|
||||||
const q = storesSearch.trim().toLowerCase();
|
const q = storesSearch.trim().toLowerCase();
|
||||||
// Match across every field shown on the card — name, zone, manager/contact,
|
// Match across every field shown on the card — name, zone, manager/contact,
|
||||||
// and the outlet id — coercing each to a string so a missing/numeric value
|
// and the outlet id — coercing each to a string so a missing/numeric value
|
||||||
@@ -616,6 +636,13 @@ export default function App() {
|
|||||||
isAssistantOpen={assistantOpen}
|
isAssistantOpen={assistantOpen}
|
||||||
navItems={ADMIN_NAV_ITEMS}
|
navItems={ADMIN_NAV_ITEMS}
|
||||||
navBasePath="/admin"
|
navBasePath="/admin"
|
||||||
|
// The three panels of the settings page, deep-linked. Kept in sync with
|
||||||
|
// SettingsView's own `tabs` array by the `?tab=` values it accepts.
|
||||||
|
settingsSections={[
|
||||||
|
{ label: 'Business Profile', icon: Building, to: '/admin/settings' },
|
||||||
|
{ label: 'Outlets', icon: Store, to: '/admin/settings?tab=outlets' },
|
||||||
|
{ label: 'Users & Access', icon: Users, to: '/admin/settings?tab=users' },
|
||||||
|
]}
|
||||||
branches={adminBranches}
|
branches={adminBranches}
|
||||||
selectedBranch={adminBranch}
|
selectedBranch={adminBranch}
|
||||||
onBranchChange={setAdminBranch}
|
onBranchChange={setAdminBranch}
|
||||||
@@ -678,7 +705,7 @@ export default function App() {
|
|||||||
renderStoresSection()
|
renderStoresSection()
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} />
|
<DashboardView searchQuery={searchQuery} isCoimbatoreView={isCoimbatoreView} tenantId={tenantId} locationid={scopedBranch} />
|
||||||
<div>
|
<div>
|
||||||
{renderStoresSection()}
|
{renderStoresSection()}
|
||||||
</div>
|
</div>
|
||||||
@@ -692,6 +719,7 @@ export default function App() {
|
|||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
isSidebarOpen={sidebarOpen}
|
isSidebarOpen={sidebarOpen}
|
||||||
|
locationid={scopedBranch}
|
||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -703,6 +731,7 @@ export default function App() {
|
|||||||
isCoimbatoreView={isCoimbatoreView}
|
isCoimbatoreView={isCoimbatoreView}
|
||||||
setIsCoimbatoreView={setIsCoimbatoreView}
|
setIsCoimbatoreView={setIsCoimbatoreView}
|
||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
|
locationid={scopedBranch}
|
||||||
/>
|
/>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -711,13 +740,13 @@ export default function App() {
|
|||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="dispatch" element={
|
<Route path="dispatch" element={
|
||||||
<DispatchHubView tenantId={tenantId} />
|
<DispatchHubView tenantId={tenantId} locationid={scopedBranch} />
|
||||||
} />
|
} />
|
||||||
|
|
||||||
{/* Tenant-wide POS: no locationid, so the view fans out over every
|
{/* Tenant-wide POS: no locationid, so the view fans out over every
|
||||||
outlet under the tenant. */}
|
outlet under the tenant. */}
|
||||||
<Route path="pos" element={
|
<Route path="pos" element={
|
||||||
<PosErrorBoundary><PosConsoleView tenantId={tenantId} /></PosErrorBoundary>
|
<PosErrorBoundary><PosConsoleView tenantId={tenantId} locationid={scopedBranch} /></PosErrorBoundary>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
aria-label="Console assistant"
|
aria-label="Nearle Buddy"
|
||||||
className="fixed right-0 top-16 bottom-0 z-40 flex w-full flex-col sm:w-[380px]"
|
className="fixed right-0 top-16 bottom-0 z-40 flex w-full flex-col sm:w-[380px]"
|
||||||
style={{ background: 'var(--color-surface, #fff)', borderLeft: `1px solid ${BORDER}`, boxShadow: SHADOW_POP }}
|
style={{ background: 'var(--color-surface, #fff)', borderLeft: `1px solid ${BORDER}`, boxShadow: SHADOW_POP }}
|
||||||
>
|
>
|
||||||
@@ -138,7 +138,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-[13px] font-semibold" style={{ color: TEXT }}>
|
<p className="truncate text-[13px] font-semibold" style={{ color: TEXT }}>
|
||||||
Assistant
|
Nearle Buddy
|
||||||
</p>
|
</p>
|
||||||
<p className="truncate text-[11px]" style={{ color: TEXT_2 }}>
|
<p className="truncate text-[11px]" style={{ color: TEXT_2 }}>
|
||||||
{scope.branchName ?? scope.tenantname ?? 'This tenant'}
|
{scope.branchName ?? scope.tenantname ?? 'This tenant'}
|
||||||
@@ -269,7 +269,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
|
|||||||
style={{ border: `1px solid ${BORDER}`, background: 'var(--color-slate-50)' }}
|
style={{ border: `1px solid ${BORDER}`, background: 'var(--color-slate-50)' }}
|
||||||
>
|
>
|
||||||
<label htmlFor="assistant-input" className="sr-only">
|
<label htmlFor="assistant-input" className="sr-only">
|
||||||
Ask the assistant
|
Ask Nearle Buddy
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="assistant-input"
|
id="assistant-input"
|
||||||
@@ -285,7 +285,7 @@ export default function AssistantRail({ open, onClose, scope }: AssistantRailPro
|
|||||||
send(draft);
|
send(draft);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Ask about revenue, orders, branches…"
|
placeholder="Ask Nearle Buddy about revenue, orders, branches…"
|
||||||
className="max-h-28 min-h-[24px] flex-1 resize-none bg-transparent text-[13px] leading-relaxed outline-none"
|
className="max-h-28 min-h-[24px] flex-1 resize-none bg-transparent text-[13px] leading-relaxed outline-none"
|
||||||
style={{ color: TEXT }}
|
style={{ color: TEXT }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -14,6 +14,15 @@ interface DashboardViewProps {
|
|||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
/** Fiesta merchant tenant to scope live store summaries to. */
|
/** Fiesta merchant tenant to scope live store summaries to. */
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
|
/**
|
||||||
|
* The branch chosen in the top bar, or undefined for the whole tenant.
|
||||||
|
*
|
||||||
|
* Until now the header filter drove nothing but the assistant's answers —
|
||||||
|
* it looked like a working control and changed nothing on screen. This is
|
||||||
|
* the wire that makes it real: a real `tenantlocations.locationid`, which is
|
||||||
|
* what every location-scoped read authorises against.
|
||||||
|
*/
|
||||||
|
locationid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ymd = (d: Date) =>
|
const ymd = (d: Date) =>
|
||||||
@@ -21,7 +30,7 @@ const ymd = (d: Date) =>
|
|||||||
|
|
||||||
const str = (v: unknown): string => (v == null ? '' : String(v));
|
const str = (v: unknown): string => (v == null ? '' : String(v));
|
||||||
|
|
||||||
export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID }: DashboardViewProps) {
|
export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID, locationid }: DashboardViewProps) {
|
||||||
// Live data — month-to-date order summary + tenant identity + store locations.
|
// Live data — month-to-date order summary + tenant identity + store locations.
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const monthStart = new Date(today);
|
const monthStart = new Date(today);
|
||||||
@@ -34,7 +43,9 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
|
|||||||
// deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed.
|
// deduped and stripped of test rows, unlike the raw Hasura tenant-locations feed.
|
||||||
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate);
|
const summaryQ = useFiestaOrderSummary(tenantId, fromdate, todate);
|
||||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, fromdate, todate });
|
// Revenue DOES honour locationid — verified against the live endpoint, which
|
||||||
|
// returns only the requested outlet's row. Order counts do not; see below.
|
||||||
|
const revenueQ = useFiestaRevenueSummary({ tenantid: tenantId, fromdate, todate, locationid });
|
||||||
|
|
||||||
const s = summaryQ.data;
|
const s = summaryQ.data;
|
||||||
const tenantName = s?.tenantname || `Tenant ${tenantId}`;
|
const tenantName = s?.tenantname || `Tenant ${tenantId}`;
|
||||||
@@ -51,12 +62,22 @@ export default function DashboardView({ searchQuery, tenantId = FIESTA_TENANT_ID
|
|||||||
const locSummaryQ = useFiestaLocationSummary(tenantId, fromdate, todate);
|
const locSummaryQ = useFiestaLocationSummary(tenantId, fromdate, todate);
|
||||||
const summaries = locSummaryQ.data ?? [];
|
const summaries = locSummaryQ.data ?? [];
|
||||||
|
|
||||||
// Region fulfillment — live month-to-date delivered ÷ total orders for the tenant.
|
/**
|
||||||
const ordersTotal = s?.total ?? 0;
|
* Order counts, scoped to the header branch when one is picked.
|
||||||
const ordersDelivered = s?.delivered ?? 0;
|
*
|
||||||
|
* NOT by passing `locationid` to getordersummary — that endpoint ignores it
|
||||||
|
* and answers tenant-wide. Verified live while building the assistant: it
|
||||||
|
* reported 341 orders for an outlet whose own row said 9. getlocationsummary
|
||||||
|
* really is per-location, so the branch figures come from there.
|
||||||
|
*/
|
||||||
|
const branchRow = locationid ? summaries.find((r) => Number(r.locationid) === locationid) : null;
|
||||||
|
const ordersTotal = branchRow ? branchRow.total : s?.total ?? 0;
|
||||||
|
const ordersDelivered = branchRow ? branchRow.delivered : s?.delivered ?? 0;
|
||||||
const regionFulfillmentPct = ordersTotal > 0 ? (ordersDelivered / ordersTotal) * 100 : null;
|
const regionFulfillmentPct = ordersTotal > 0 ? (ordersDelivered / ordersTotal) * 100 : null;
|
||||||
|
|
||||||
const locations = (locationsQ.data ?? []).filter((loc) => {
|
const locations = (locationsQ.data ?? []).filter((loc) => {
|
||||||
|
// Header branch filter first: everything below counts only what is in scope.
|
||||||
|
if (locationid && Number(loc.locationid) !== locationid) return false;
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -36,13 +36,18 @@ interface DispatchHubViewProps {
|
|||||||
* somebody else's outlet.
|
* somebody else's outlet.
|
||||||
*/
|
*/
|
||||||
locationid?: number;
|
locationid?: number;
|
||||||
|
/**
|
||||||
|
* True when rendered inside the store workspace. Distinct from merely having
|
||||||
|
* a locationid: it withholds the tenant-wide admin catalogue.
|
||||||
|
*/
|
||||||
|
isStoreUser?: boolean;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every surface this console can show. */
|
/** Every surface this console can show. */
|
||||||
type ConsoleTab = 'overview' | 'map' | 'orders' | 'deliveries' | 'products' | 'catalogue' | 'pos';
|
type ConsoleTab = 'overview' | 'map' | 'orders' | 'deliveries' | 'products' | 'catalogue' | 'pos';
|
||||||
|
|
||||||
export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID }: DispatchHubViewProps) {
|
export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_ID, isStoreUser = false }: DispatchHubViewProps) {
|
||||||
const [mapViewMode, setMapViewMode] = useState<'stores' | 'customers'>('stores');
|
const [mapViewMode, setMapViewMode] = useState<'stores' | 'customers'>('stores');
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const [date, setDate] = useState<string>(ymd(today));
|
const [date, setDate] = useState<string>(ymd(today));
|
||||||
@@ -57,7 +62,22 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
|||||||
*/
|
*/
|
||||||
const [pickedBranch, setPickedBranch] = useState<number | 'all'>('all');
|
const [pickedBranch, setPickedBranch] = useState<number | 'all'>('all');
|
||||||
const scopedLocationId = locationid ?? (pickedBranch === 'all' ? undefined : pickedBranch);
|
const scopedLocationId = locationid ?? (pickedBranch === 'all' ? undefined : pickedBranch);
|
||||||
const isPinned = locationid != null;
|
|
||||||
|
/**
|
||||||
|
* Two different things used to share one flag, and now that the admin header
|
||||||
|
* also supplies a `locationid` they have to be told apart:
|
||||||
|
*
|
||||||
|
* • `isScoped` — a locationid arrived from outside, so this page does not
|
||||||
|
* draw its own branch picker. True for a store user AND for an admin who
|
||||||
|
* has picked a branch in the top bar.
|
||||||
|
* • `isStoreUser` — the caller is a store workspace, which must not reach
|
||||||
|
* the tenant-wide admin catalogue at all. An authorisation boundary.
|
||||||
|
*
|
||||||
|
* Conflating them would have taken the Catalogue tab away from an admin the
|
||||||
|
* moment they scoped to a branch, which is not what scoping means.
|
||||||
|
*/
|
||||||
|
const isScoped = locationid != null;
|
||||||
|
const isPinned = isStoreUser;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which tabs make sense right now. The branch-specific surfaces — a store's
|
* Which tabs make sense right now. The branch-specific surfaces — a store's
|
||||||
@@ -188,7 +208,7 @@ export default function DispatchHubView({ locationid, tenantId = FIESTA_TENANT_I
|
|||||||
Hidden when the caller already pinned one: a store user must not
|
Hidden when the caller already pinned one: a store user must not
|
||||||
be able to page through other branches from here. Options come
|
be able to page through other branches from here. Options come
|
||||||
from tenantlocations, so the value is a real locationid. */}
|
from tenantlocations, so the value is a real locationid. */}
|
||||||
{!isPinned && branches.length > 0 && (
|
{!isScoped && branches.length > 0 && (
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Store size={14} className="text-slate-400 shrink-0" />
|
<Store size={14} className="text-slate-400 shrink-0" />
|
||||||
<div className="w-44 sm:w-56">
|
<div className="w-44 sm:w-56">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Menu, HelpCircle, LogOut, ChevronDown, Mail, QrCode, User, Sparkles } from 'lucide-react';
|
import { Menu, HelpCircle, LogOut, Mail, QrCode, User, Sparkles } from 'lucide-react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
@@ -57,6 +57,16 @@ interface HeaderProps {
|
|||||||
selectedBranch?: number | 'all';
|
selectedBranch?: number | 'all';
|
||||||
onBranchChange?: (v: number | 'all') => void;
|
onBranchChange?: (v: number | 'all') => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings sections listed inside the account dropdown.
|
||||||
|
*
|
||||||
|
* Settings left the top navigation: it is configuration, not a place anyone
|
||||||
|
* works from day to day, and it sat in the nav taking a slot from sections
|
||||||
|
* that are. The account menu is where "my workspace's setup" belongs, next to
|
||||||
|
* account and sign-out.
|
||||||
|
*/
|
||||||
|
settingsSections?: { label: string; icon: React.ElementType; to: string }[];
|
||||||
|
|
||||||
/** Signed-in user shown in the profile dropdown. */
|
/** Signed-in user shown in the profile dropdown. */
|
||||||
profile: { name: string; role: string; email: string };
|
profile: { name: string; role: string; email: string };
|
||||||
/** Optional store context to display next to the sidebar toggle. */
|
/** Optional store context to display next to the sidebar toggle. */
|
||||||
@@ -81,6 +91,7 @@ export default function Header({
|
|||||||
branches,
|
branches,
|
||||||
selectedBranch,
|
selectedBranch,
|
||||||
onBranchChange,
|
onBranchChange,
|
||||||
|
settingsSections,
|
||||||
profile,
|
profile,
|
||||||
storeContext
|
storeContext
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
@@ -129,8 +140,16 @@ export default function Header({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-[var(--color-brand)] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-16 text-white">
|
<header className="bg-[var(--color-brand)] flex justify-between items-center w-full px-container-margin py-md fixed top-0 right-0 left-0 z-50 h-16 text-white">
|
||||||
{/* Brand & Desktop Navigation Tabs */}
|
{/* Brand cell.
|
||||||
<div className="flex items-center gap-sm sm:gap-md md:pl-0 pl-1 min-w-0">
|
`flex-1 basis-0` here and on the trailing group is what actually
|
||||||
|
centres the navigation between them: both sides claim an equal share
|
||||||
|
of the leftover width, so the nav sits on the header's true midpoint
|
||||||
|
no matter how wide the logo or the control cluster happen to be.
|
||||||
|
|
||||||
|
Absolute centring (`left-1/2 -translate-x-1/2`) would also look right
|
||||||
|
but can overlap the side groups once they grow — and this bar has
|
||||||
|
already lost nav items to a width squeeze once. Flex cannot overlap. */}
|
||||||
|
<div className="flex-1 basis-0 flex items-center gap-sm sm:gap-md md:pl-0 pl-1 min-w-0">
|
||||||
{/* Brand cell — width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */}
|
{/* Brand cell — width mirrors the sidebar rail (64px collapsed / 256px expanded) so the logo/toggle sit directly above it */}
|
||||||
<div
|
<div
|
||||||
// The fixed widths mirror the rail's 256/64px so the logo sits above
|
// The fixed widths mirror the rail's 256/64px so the logo sits above
|
||||||
@@ -207,60 +226,6 @@ export default function Header({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Horizontal navigation. Rendered only when the shell hands over its
|
|
||||||
items — the rail-based shells pass nothing and are unaffected. */}
|
|
||||||
{navItems && navItems.length > 0 && (
|
|
||||||
// shrink-0: the navigation is the one thing in this bar that must
|
|
||||||
// never be squeezed, because its overflow is a scrollbar-less strip —
|
|
||||||
// a clipped item is simply an unreachable section. Everything beside
|
|
||||||
// it absorbs the width instead (see the branch filter below).
|
|
||||||
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1 shrink-0 overflow-x-auto no-scrollbar">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={item.id}
|
|
||||||
to={`${navBasePath}/${item.id}`}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[13px] font-medium whitespace-nowrap transition-colors ${
|
|
||||||
isActive
|
|
||||||
? 'bg-white/15 text-white font-semibold'
|
|
||||||
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon size={14} />
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tenant → branch filter. Sits with the navigation because it scopes
|
|
||||||
all of it: one choice, every section follows. */}
|
|
||||||
{branches && branches.length > 0 && onBranchChange && (
|
|
||||||
<div className="hidden lg:flex items-center gap-2 min-w-0 shrink-0">
|
|
||||||
<span className="w-px h-6 bg-white/20" aria-hidden="true" />
|
|
||||||
{/* Narrower until xl so the navigation keeps its full width at the
|
|
||||||
mid sizes where the two were competing. */}
|
|
||||||
<div className="w-40 xl:w-48">
|
|
||||||
<Selector
|
|
||||||
label="Filter every section by branch"
|
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
width="100%"
|
|
||||||
value={String(selectedBranch ?? 'all')}
|
|
||||||
onChange={(v) => onBranchChange(v === 'all' ? 'all' : Number(v))}
|
|
||||||
options={[
|
|
||||||
{ value: 'all', label: `All branches (${branches.length})` },
|
|
||||||
...branches.map((b) => ({ value: String(b.locationid), label: b.locationname })),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dynamic Store Name Context.
|
{/* Dynamic Store Name Context.
|
||||||
Hidden on desktop once there is a top nav, for two reasons. It is
|
Hidden on desktop once there is a top nav, for two reasons. It is
|
||||||
redundant — it names the section the active nav pill is already
|
redundant — it names the section the active nav pill is already
|
||||||
@@ -292,24 +257,101 @@ export default function Header({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Horizontal navigation — the centre slot.
|
||||||
|
Its own child of the header rather than part of the brand group, which
|
||||||
|
is what lets it sit on the midpoint. Rendered only when the shell hands
|
||||||
|
over its items; the rail-based shells pass nothing and are unaffected.
|
||||||
|
|
||||||
|
`shrink-0`: the navigation is the one thing in this bar that must never
|
||||||
|
be squeezed, because its overflow is a scrollbar-less strip — a clipped
|
||||||
|
item is simply an unreachable section. The two flex-1 groups either
|
||||||
|
side absorb the width instead. */}
|
||||||
|
{navItems && navItems.length > 0 && (
|
||||||
|
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1 shrink-0 overflow-x-auto no-scrollbar">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.id}
|
||||||
|
to={`${navBasePath}/${item.id}`}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[13px] font-medium whitespace-nowrap transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-white/15 text-white font-semibold'
|
||||||
|
: 'text-purple-200 hover:bg-white/10 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Global Actions Bar */}
|
{/* Global Actions Bar */}
|
||||||
<div className="flex items-center gap-md">
|
<div className="flex-1 basis-0 flex items-center justify-end gap-md min-w-0">
|
||||||
|
{/* Tenant → branch filter.
|
||||||
|
Moved out of the centre and over to the trailing group, beside the
|
||||||
|
assistant. It scopes the whole workspace rather than any one
|
||||||
|
section, so it belongs with the other global controls (assistant,
|
||||||
|
account) rather than sitting inside the navigation as if it were
|
||||||
|
another nav item. */}
|
||||||
|
{branches && branches.length > 0 && onBranchChange && (
|
||||||
|
// The hairline that used to sit between this and the assistant is
|
||||||
|
// gone. The selector already has its own border, so the divider was
|
||||||
|
// a second vertical line 8px from the first.
|
||||||
|
<div className="hidden lg:flex items-center min-w-0 shrink-0">
|
||||||
|
<div className="w-40 xl:w-48">
|
||||||
|
<Selector
|
||||||
|
label="Filter every section by branch"
|
||||||
|
isLabelHidden
|
||||||
|
size="sm"
|
||||||
|
width="100%"
|
||||||
|
value={String(selectedBranch ?? 'all')}
|
||||||
|
onChange={(v) => onBranchChange(v === 'all' ? 'all' : Number(v))}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: `All branches (${branches.length})` },
|
||||||
|
...branches.map((b) => ({ value: String(b.locationid), label: b.locationname })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Assistant rail toggle. Labelled rather than icon-only: it is the one
|
{/* Assistant rail toggle. Labelled rather than icon-only: it is the one
|
||||||
control in this bar whose icon carries no established meaning. */}
|
control in this bar whose icon carries no established meaning. */}
|
||||||
{onAssistantClick && (
|
{onAssistantClick && (
|
||||||
<Button
|
<Button
|
||||||
label={isAssistantOpen ? 'Close assistant' : 'Open assistant'}
|
label={isAssistantOpen ? 'Close Nearle Buddy' : 'Open Nearle Buddy'}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onAssistantClick}
|
onClick={onAssistantClick}
|
||||||
aria-expanded={isAssistantOpen}
|
aria-expanded={isAssistantOpen}
|
||||||
style={{ height: 'auto', background: 'transparent', border: 'none' }}
|
// Icon only. `label` is still "Open/Close assistant", and Button
|
||||||
className={`flex items-center gap-1.5 rounded-full border border-white/15 px-2.5 py-1.5 text-[12px] font-semibold text-white transition-colors cursor-pointer ${
|
// promotes it to the accessible name when it differs from the
|
||||||
isAssistantOpen ? 'bg-white/20' : 'bg-white/10 hover:bg-white/15'
|
// visible children — so dropping the word costs nothing to a screen
|
||||||
}`}
|
// reader, and `tooltip` covers the sighted hover case.
|
||||||
|
tooltip={isAssistantOpen ? 'Close Nearle Buddy' : 'Open Nearle Buddy'}
|
||||||
|
// Ground and border live in `style`, not in classes. Astryx paints
|
||||||
|
// its own button background, so the class had to be overridden
|
||||||
|
// inline — and `background: 'transparent'` was then beating the
|
||||||
|
// `bg-white/10` class beside it. With the label removed that left a
|
||||||
|
// bare glyph floating in the bar with no hit target to look at.
|
||||||
|
style={{
|
||||||
|
height: 36,
|
||||||
|
width: 36,
|
||||||
|
minWidth: 36,
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: isAssistantOpen ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.10)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center transition-colors cursor-pointer hover:brightness-110"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center justify-center">
|
||||||
<Sparkles size={15} />
|
<Sparkles size={16} />
|
||||||
<span className="hidden sm:inline">Assistant</span>
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -317,13 +359,26 @@ export default function Header({
|
|||||||
{/* Store QR — opens the QR modal (user store page only) */}
|
{/* Store QR — opens the QR modal (user store page only) */}
|
||||||
{onQrClick && (
|
{onQrClick && (
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={<QrCode size={18} />}
|
icon={<QrCode size={16} />}
|
||||||
label="Store QR code"
|
label="Store QR code"
|
||||||
tooltip="Store QR code"
|
tooltip="Store QR code"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={onQrClick}
|
onClick={onQrClick}
|
||||||
style={{ height: 'auto', width: 'auto', minWidth: 0, background: 'transparent', color: '#fff', border: 'none' }}
|
// Same 36px round chip as the assistant beside it. It was
|
||||||
className="p-2 rounded-full hover:bg-purple-800 transition-colors cursor-pointer text-white"
|
// `height: auto` + `width: auto` with only padding for size, which
|
||||||
|
// let Astryx's own square ground show through — so two controls
|
||||||
|
// sitting next to each other had different shapes.
|
||||||
|
style={{
|
||||||
|
height: 36,
|
||||||
|
width: 36,
|
||||||
|
minWidth: 36,
|
||||||
|
padding: 0,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: 'rgba(255,255,255,0.10)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.15)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center transition-colors cursor-pointer hover:brightness-110"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -346,28 +401,21 @@ export default function Header({
|
|||||||
onClick={() => setShowProfileDropdown(!showProfileDropdown)}
|
onClick={() => setShowProfileDropdown(!showProfileDropdown)}
|
||||||
aria-expanded={showProfileDropdown}
|
aria-expanded={showProfileDropdown}
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
|
// Avatar only. The name and role were repeated verbatim at the top
|
||||||
|
// of the dropdown this button opens, so the bar was carrying ~150px
|
||||||
|
// of text that is one click away — and `label` still announces
|
||||||
|
// "RMart Store User, Manager. Account menu" either way. The chevron
|
||||||
|
// goes with them: `aria-expanded` already conveys the state, and a
|
||||||
|
// 9px glyph beside a 36px avatar was pure noise.
|
||||||
|
tooltip={`${profile.name} · ${profile.role}`}
|
||||||
style={{ height: 'auto', background: 'transparent', border: 'none' }}
|
style={{ height: 'auto', background: 'transparent', border: 'none' }}
|
||||||
className="group flex items-center pl-1 pr-1 sm:pr-2.5 py-1 rounded-full bg-white/10 hover:bg-white/15 border border-white/15 backdrop-blur-sm focus:ring-2 focus:ring-purple-300/60 outline-none cursor-pointer transition-all duration-150 active:scale-[0.98]"
|
className="group flex items-center justify-center p-0 rounded-full bg-transparent hover:bg-white/10 border-none outline-none cursor-pointer transition-colors duration-150"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2.5">
|
<span className="relative shrink-0 block">
|
||||||
{/* Initials avatar with live status dot */}
|
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
|
||||||
<span className="relative shrink-0">
|
{initials}
|
||||||
<span className="w-9 h-9 rounded-full bg-white/15 ring-2 ring-white/30 flex items-center justify-center text-xs font-bold text-white tracking-wide">
|
|
||||||
{initials}
|
|
||||||
</span>
|
|
||||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[var(--color-brand-ink)]" />
|
|
||||||
</span>
|
</span>
|
||||||
|
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full bg-emerald-400 ring-2 ring-[var(--color-brand-ink)]" />
|
||||||
{/* Identity (hidden on small screens) */}
|
|
||||||
<span className="hidden sm:flex flex-col items-start leading-tight">
|
|
||||||
<span className="text-xs font-bold text-white truncate max-w-[130px]">{profile.name}</span>
|
|
||||||
<span className="text-[10px] text-purple-200 font-medium truncate max-w-[130px]">{profile.role}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<ChevronDown
|
|
||||||
size={15}
|
|
||||||
className={`hidden sm:block text-purple-200 transition-transform duration-200 ${showProfileDropdown ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -420,6 +468,38 @@ export default function Header({
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{/* Settings, moved out of the top navigation.
|
||||||
|
Its three sections are listed here rather than a single
|
||||||
|
"Settings" entry, because a lone entry would land everyone
|
||||||
|
on Business Profile and make them click again — and the
|
||||||
|
sections are what anyone is actually coming here for. Each
|
||||||
|
deep-links via `?tab=`, which SettingsView reads. */}
|
||||||
|
{settingsSections && settingsSections.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 h-px bg-slate-100" />
|
||||||
|
<p className="eyebrow px-2.5 pt-1 pb-1.5">Settings</p>
|
||||||
|
{settingsSections.map((s) => {
|
||||||
|
const Icon = s.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={s.to}
|
||||||
|
to={s.to}
|
||||||
|
onClick={() => setShowProfileDropdown(false)}
|
||||||
|
className="w-full flex items-center px-2.5 py-2 rounded-xl text-xs font-semibold text-slate-700 hover:bg-slate-50 cursor-pointer transition-colors group/item"
|
||||||
|
>
|
||||||
|
<span className="w-full flex items-center gap-2.5">
|
||||||
|
<span className="h-7 w-7 rounded-lg bg-slate-100 text-slate-500 ring-1 ring-slate-200 flex items-center justify-center shrink-0 group-hover/item:scale-110 transition-transform">
|
||||||
|
<Icon size={14} />
|
||||||
|
</span>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="my-1 h-px bg-slate-100" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
label="Help Center"
|
label="Help Center"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -69,13 +69,16 @@ interface InventoryViewProps {
|
|||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
isSidebarOpen?: boolean;
|
isSidebarOpen?: boolean;
|
||||||
|
/** The branch chosen in the top bar; undefined means the whole tenant. */
|
||||||
|
locationid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InventoryView({
|
export default function InventoryView({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
isCoimbatoreView,
|
isCoimbatoreView,
|
||||||
tenantId = FIESTA_TENANT_ID,
|
tenantId = FIESTA_TENANT_ID,
|
||||||
isSidebarOpen = false
|
isSidebarOpen = false,
|
||||||
|
locationid
|
||||||
}: InventoryViewProps) {
|
}: InventoryViewProps) {
|
||||||
const { setHideCompareBar } = useCompare();
|
const { setHideCompareBar } = useCompare();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
@@ -88,14 +91,19 @@ export default function InventoryView({
|
|||||||
// to it); the STORE STOCK section shows each outlet's live stock so the admin
|
// to it); the STORE STOCK section shows each outlet's live stock so the admin
|
||||||
// can see all the stores under them at a glance.
|
// can see all the stores under them at a glance.
|
||||||
const locationsQ = useFiestaTenantLocations(tenantId);
|
const locationsQ = useFiestaTenantLocations(tenantId);
|
||||||
|
// Scoped by the top-bar branch filter. Everything downstream reads from this
|
||||||
|
// list — the stock fan-out, the outlet count, the sync ratio — so narrowing
|
||||||
|
// it here is what makes the header filter reach the whole page.
|
||||||
const locations = useMemo(
|
const locations = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(locationsQ.data ?? []).map((l) => ({
|
(locationsQ.data ?? [])
|
||||||
locationid: Number(l.locationid),
|
.map((l) => ({
|
||||||
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
|
locationid: Number(l.locationid),
|
||||||
status: fstr(l.status) || 'Active',
|
locationname: fstr(l.locationname) || `Outlet ${fstr(l.locationid)}`,
|
||||||
})),
|
status: fstr(l.status) || 'Active',
|
||||||
[locationsQ.data],
|
}))
|
||||||
|
.filter((l) => !locationid || l.locationid === locationid),
|
||||||
|
[locationsQ.data, locationid],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The admin catalogue (imports, curation) reads through this tenant's own
|
// The admin catalogue (imports, curation) reads through this tenant's own
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ import {
|
|||||||
FilterBar,
|
FilterBar,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
KpiStrip,
|
KpiStrip,
|
||||||
LiveStatus,
|
|
||||||
MetricPill,
|
MetricPill,
|
||||||
Pill,
|
Pill,
|
||||||
SlideDrawer,
|
SlideDrawer,
|
||||||
@@ -303,52 +302,16 @@ export default function PosConsoleView({
|
|||||||
? `${outletName.get(billOutlet) || storeName || 'Your store'} · counter sales and till health`
|
? `${outletName.get(billOutlet) || storeName || 'Your store'} · counter sales and till health`
|
||||||
: `${outlets.length} outlet${outlets.length === 1 ? '' : 's'} · counter sales and till health`
|
: `${outlets.length} outlet${outlets.length === 1 ? '' : 's'} · counter sales and till health`
|
||||||
}
|
}
|
||||||
status={
|
// The "N of M tills online · refreshed every 30s" line is gone. The
|
||||||
<LiveStatus
|
// count it carried is already the "Tills online" KPI card directly
|
||||||
state={isError ? 'error' : isLoading ? 'loading' : 'live'}
|
// below, and the refresh cadence is not something anyone acts on.
|
||||||
label={
|
//
|
||||||
isError
|
// Failure is NOT silently dropped with it: `isError` still surfaces on
|
||||||
? 'POS service unreachable'
|
// the Tills online card, which turns red and reads "POS unreachable"
|
||||||
: isLoading
|
// rather than showing a stale figure as if it were current.
|
||||||
? 'Loading counter sales…'
|
//
|
||||||
: `${tillsOnline} of ${allTerminals.length} till${allTerminals.length === 1 ? '' : 's'} online · refreshed every 30s`
|
// With no status and no right-hand slot left, PageHeader collapses to
|
||||||
}
|
// its screen-reader-only <h1> and takes no vertical space.
|
||||||
/>
|
|
||||||
}
|
|
||||||
right={
|
|
||||||
// Wraps rather than overflowing: GradientHeader marks this side
|
|
||||||
// shrink-0, so a fixed row would push the title off a narrow viewport.
|
|
||||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
|
||||||
{RANGES.map((r) => (
|
|
||||||
<React.Fragment key={r.id}>
|
|
||||||
<Pill
|
|
||||||
active={rangeId === r.id}
|
|
||||||
color={BRAND}
|
|
||||||
onClick={() => {
|
|
||||||
setRangeId(r.id);
|
|
||||||
resetPaging();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{r.label}
|
|
||||||
</Pill>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
{/* IconButton still takes `label` (inherited from ButtonProps) —
|
|
||||||
it becomes the accessible name rather than visible text, which
|
|
||||||
the old `title`-only button never had. */}
|
|
||||||
<IconButton
|
|
||||||
label="Refresh"
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
icon={<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />}
|
|
||||||
onClick={() => {
|
|
||||||
healthQ.refetch();
|
|
||||||
summaryQ.refetch();
|
|
||||||
salesQ.refetch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStrip
|
<KpiStrip
|
||||||
@@ -376,11 +339,18 @@ export default function PosConsoleView({
|
|||||||
badge: `${merged?.itemcount ?? 0} items`,
|
badge: `${merged?.itemcount ?? 0} items`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Carries the failure state the removed status line used to show.
|
||||||
|
// Without this an unreachable POS service would render "0/0" and
|
||||||
|
// read as "no tills configured" rather than "we could not ask".
|
||||||
label: 'Tills online',
|
label: 'Tills online',
|
||||||
value: `${tillsOnline}/${allTerminals.length}`,
|
value: isError ? '—' : `${tillsOnline}/${allTerminals.length}`,
|
||||||
color: pendingTotal > 0 ? '#f59e0b' : 'var(--color-slate-500)',
|
color: isError ? '#ef4444' : pendingTotal > 0 ? '#f59e0b' : 'var(--color-slate-500)',
|
||||||
icon: <Wifi size={16} />,
|
icon: <Wifi size={16} />,
|
||||||
badge: pendingTotal > 0 ? `${pendingTotal} unsynced` : undefined,
|
badge: isError
|
||||||
|
? 'POS service unreachable'
|
||||||
|
: pendingTotal > 0
|
||||||
|
? `${pendingTotal} unsynced`
|
||||||
|
: undefined,
|
||||||
onClick: () => setTab('terminals'),
|
onClick: () => setTab('terminals'),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -407,20 +377,59 @@ export default function PosConsoleView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
{/* The section tabs and the period filter share one bar. The period
|
||||||
|
pills used to sit up in the page header, two rows above the content
|
||||||
|
they scope; here they are on the same line as the section they
|
||||||
|
filter, and the bar exists either way — so the page gets its height
|
||||||
|
back and the relationship reads. */}
|
||||||
<FilterBar>
|
<FilterBar>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
{TABS.map((t) => (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<React.Fragment key={t.id}>
|
{TABS.map((t) => (
|
||||||
<Pill
|
<React.Fragment key={t.id}>
|
||||||
active={tab === t.id}
|
<Pill
|
||||||
color={BRAND}
|
active={tab === t.id}
|
||||||
onClick={() => setTab(t.id)}
|
color={BRAND}
|
||||||
>
|
onClick={() => setTab(t.id)}
|
||||||
{t.icon}
|
>
|
||||||
{t.label}
|
{t.icon}
|
||||||
</Pill>
|
{t.label}
|
||||||
</React.Fragment>
|
</Pill>
|
||||||
))}
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||||
|
<span className="eyebrow hidden sm:inline">Period</span>
|
||||||
|
{RANGES.map((r) => (
|
||||||
|
<React.Fragment key={r.id}>
|
||||||
|
<Pill
|
||||||
|
active={rangeId === r.id}
|
||||||
|
color={BRAND}
|
||||||
|
onClick={() => {
|
||||||
|
setRangeId(r.id);
|
||||||
|
resetPaging();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</Pill>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{/* IconButton still takes `label` (inherited from ButtonProps) —
|
||||||
|
it becomes the accessible name rather than visible text, which
|
||||||
|
the old `title`-only button never had. */}
|
||||||
|
<IconButton
|
||||||
|
label="Refresh"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
icon={<RefreshCcw size={13} className={summaryQ.isFetching ? 'animate-spin' : ''} />}
|
||||||
|
onClick={() => {
|
||||||
|
healthQ.refetch();
|
||||||
|
summaryQ.refetch();
|
||||||
|
salesQ.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { stockRowToProduct } from '../services/fiestaMappers';
|
|||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
|
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||||
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
import { ProgressBar } from '@astryxdesign/core/ProgressBar';
|
||||||
import { AppDialog } from './AppDialog';
|
import { AppDialog } from './AppDialog';
|
||||||
import {
|
import {
|
||||||
@@ -49,26 +50,27 @@ import {
|
|||||||
import AwaitingApi from './AwaitingApi';
|
import AwaitingApi from './AwaitingApi';
|
||||||
import SalesRevenueReport from './SalesRevenueReport';
|
import SalesRevenueReport from './SalesRevenueReport';
|
||||||
import { useNotify } from './Toasts';
|
import { useNotify } from './Toasts';
|
||||||
import { KpiStrip, PageHeader } from './consoleUi';
|
import { KpiStrip, PageHeader, StatusChip } from './consoleUi';
|
||||||
|
|
||||||
interface ReportsViewProps {
|
interface ReportsViewProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
isCoimbatoreView: boolean;
|
isCoimbatoreView: boolean;
|
||||||
setIsCoimbatoreView: (val: boolean) => void;
|
setIsCoimbatoreView: (val: boolean) => void;
|
||||||
tenantId?: number;
|
tenantId?: number;
|
||||||
|
/** The branch chosen in the top bar; undefined means the whole tenant. */
|
||||||
|
locationid?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
|
const WEEK_KEYS = ['week1', 'week2', 'week3', 'week4', 'week5'];
|
||||||
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
|
const WEEK_LABELS = ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5'];
|
||||||
|
|
||||||
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID }: ReportsViewProps) {
|
export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimbatoreView, tenantId = FIESTA_TENANT_ID, locationid }: ReportsViewProps) {
|
||||||
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
|
// PHASE 6 — replaces alert(). A native alert freezes the whole browser
|
||||||
// until dismissed; a toast does not, and errors stay until read.
|
// until dismissed; a toast does not, and errors stay until read.
|
||||||
const { notifyOk, notifyFail } = useNotify();
|
const { notifyOk, notifyFail } = useNotify();
|
||||||
|
|
||||||
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
const [selectedTimeframe, setSelectedTimeframe] = useState('This Year (YTD)');
|
||||||
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
const [selectedRegion, setSelectedRegion] = useState<'all' | 'coimbatore' | 'chennai' | 'bangalore'>('all');
|
||||||
const [activeStoreId, setActiveStoreId] = useState<number | 'all'>('all');
|
|
||||||
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
|
const [stockFilter, setStockFilter] = useState<'All' | 'Healthy' | 'Low Stock' | 'Critical'>('All');
|
||||||
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
|
const [selectedCell, setSelectedCell] = useState<{ day: string; hour: string; val: number } | null>(null);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -131,7 +133,13 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
return rawLocations;
|
return rawLocations;
|
||||||
}, [locSummaryQ.data, selectedRegion]);
|
}, [locSummaryQ.data, selectedRegion]);
|
||||||
|
|
||||||
const effectiveStoreId = activeTab === 'sales_revenue' ? activeStoreId : 'all';
|
/**
|
||||||
|
* Scope comes from the top bar now, not from a picker on this page — and it
|
||||||
|
* applies to BOTH tabs. The old rule scoped only "Sales & Revenue" and forced
|
||||||
|
* Overview to 'all', so the same page answered at two different scopes
|
||||||
|
* depending on which tab you were looking at.
|
||||||
|
*/
|
||||||
|
const effectiveStoreId: number | 'all' = locationid ?? 'all';
|
||||||
|
|
||||||
const filteredLocations = useMemo(() => {
|
const filteredLocations = useMemo(() => {
|
||||||
if (effectiveStoreId !== 'all') {
|
if (effectiveStoreId !== 'all') {
|
||||||
@@ -350,7 +358,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
rank: String(i + 1).padStart(2, '0'),
|
rank: String(i + 1).padStart(2, '0'),
|
||||||
name: r.name,
|
name: r.name,
|
||||||
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0,
|
percentage: max > 0 ? Math.round((r.total / max) * 100) : 0,
|
||||||
revenue: `${r.total.toLocaleString()} ord`,
|
revenue: `${r.total.toLocaleString()} order${r.total === 1 ? '' : 's'}`,
|
||||||
}));
|
}));
|
||||||
})();
|
})();
|
||||||
const currentLeaderboard = leaderboard;
|
const currentLeaderboard = leaderboard;
|
||||||
@@ -420,21 +428,26 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
}, [activeRegionStock]);
|
}, [activeRegionStock]);
|
||||||
|
|
||||||
// Heatmap cell color gradient scale (multi-stop violet theme)
|
// Heatmap cell color gradient scale (multi-stop violet theme)
|
||||||
const getHeatmapColorClass = (val: number) => {
|
/**
|
||||||
const ratio = val / heatmapMax;
|
* PASS 2 — the heat ramp, rebuilt on one hue.
|
||||||
if (ratio < 0.15) {
|
*
|
||||||
return 'bg-slate-50 text-zinc-400 border border-slate-100/50 hover:bg-slate-100 hover:text-zinc-650 hover:scale-105';
|
* It was five steps across three different purples — Tailwind `purple-50/100/200`,
|
||||||
}
|
* a hardcoded `#7c3aed`, and the brand `#662582` — so the scale changed hue as
|
||||||
if (ratio <= 0.4) {
|
* it climbed rather than just getting darker. It also gave every cell its own
|
||||||
return 'bg-purple-50 text-purple-700 border border-purple-100/50 hover:bg-purple-100 hover:scale-105';
|
* border, its own `rounded-lg`, and a `hover:scale-105`, which is what made
|
||||||
}
|
* the grid read as 25 separate buttons instead of one heat map.
|
||||||
if (ratio <= 0.7) {
|
*
|
||||||
return 'bg-purple-200/50 text-purple-900 border border-purple-200/40 hover:bg-purple-200 hover:scale-105';
|
* Now: a single alpha ramp on the brand token, no per-cell chrome. The lowest
|
||||||
}
|
* step is transparent, so an all-zero table (the common case for a new tenant)
|
||||||
if (ratio <= 0.9) {
|
* reads as an empty grid rather than a wall of grey boxes.
|
||||||
return 'bg-[#7c3aed]/20 text-[#7c3aed] border border-[#7c3aed]/20 hover:bg-[#7c3aed]/30 hover:scale-105';
|
*/
|
||||||
}
|
const getHeatmapStyle = (val: number): React.CSSProperties => {
|
||||||
return 'bg-[var(--color-brand)] text-white border-none hover:bg-[var(--color-brand-strong)]';
|
const ratio = heatmapMax > 0 ? val / heatmapMax : 0;
|
||||||
|
if (ratio < 0.15) return { background: 'transparent', color: 'var(--color-slate-500)' };
|
||||||
|
if (ratio <= 0.4) return { background: 'color-mix(in oklab, var(--color-brand) 10%, transparent)', color: 'var(--color-brand-ink)' };
|
||||||
|
if (ratio <= 0.7) return { background: 'color-mix(in oklab, var(--color-brand) 22%, transparent)', color: 'var(--color-brand-ink)' };
|
||||||
|
if (ratio <= 0.9) return { background: 'color-mix(in oklab, var(--color-brand) 45%, transparent)', color: '#fff' };
|
||||||
|
return { background: 'var(--color-brand)', color: '#fff' };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Triggers progress bar simulated exporting
|
// Triggers progress bar simulated exporting
|
||||||
@@ -532,43 +545,28 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
Astryx ships no ARIA tablist, so this uses the same pressed-button
|
Astryx ships no ARIA tablist, so this uses the same pressed-button
|
||||||
pattern as the other two single-select groups on this page, and
|
pattern as the other two single-select groups on this page, and
|
||||||
keeps the original underline styling exactly. */}
|
keeps the original underline styling exactly. */}
|
||||||
<div className="flex items-center gap-4">
|
{/* PASS 5 — the same SegmentedControl the store Reports page uses.
|
||||||
{([
|
These two tab strips split the same section on two pages and looked
|
||||||
{ id: 'overview', label: 'Overview' },
|
nothing alike: underlined text here, a pill group there.
|
||||||
{ id: 'sales_revenue', label: 'Sales & Revenue' },
|
|
||||||
] as const).map((t) => (
|
The Phase 6 note that used to sit here argued against Astryx's
|
||||||
<Button
|
TabList, and that reasoning still holds — TabList renders a <nav>
|
||||||
key={t.id}
|
with `aria-current="page"`, which would claim a navigation that
|
||||||
label={t.label}
|
never happens. SegmentedControl is the single-select control, which
|
||||||
aria-pressed={activeTab === t.id}
|
is what this actually is. */}
|
||||||
variant="ghost"
|
<SegmentedControl
|
||||||
onClick={() => setActiveTab(t.id)}
|
label="Report section"
|
||||||
style={{ height: 'auto' }}
|
value={activeTab}
|
||||||
className={`pb-1 text-sm font-bold border-b-2 rounded-none bg-transparent transition-colors ${activeTab === t.id ? 'border-[var(--color-brand-ink)] text-[var(--color-brand-ink)]' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
onChange={(v) => setActiveTab(v as typeof activeTab)}
|
||||||
/>
|
>
|
||||||
))}
|
<SegmentedControlItem value="overview" label="Overview" />
|
||||||
</div>
|
<SegmentedControlItem value="sales_revenue" label="Sales & Revenue" />
|
||||||
|
</SegmentedControl>
|
||||||
|
|
||||||
{/* Global Store Filter */}
|
{/* The page's own "All Stores" picker is gone — the branch filter in the
|
||||||
{activeTab === 'sales_revenue' && (
|
top bar is the single scope control for the whole admin workspace,
|
||||||
<div className="flex items-center gap-2">
|
and two pickers that set the same scope is exactly how a page ends
|
||||||
<Store size={16} className="text-slate-500" />
|
up disagreeing with its own header. */}
|
||||||
<Selector
|
|
||||||
label="Filter reports by store"
|
|
||||||
isLabelHidden
|
|
||||||
size="sm"
|
|
||||||
value={String(activeStoreId)}
|
|
||||||
onChange={(v) => setActiveStoreId(v === 'all' ? 'all' : Number(v))}
|
|
||||||
options={[
|
|
||||||
{ value: 'all', label: 'All Stores' },
|
|
||||||
...regionLocations.map((loc) => ({
|
|
||||||
value: String(Number(loc.locationid)),
|
|
||||||
label: loc.locationname || `Store ${loc.locationid}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'overview' ? (
|
{activeTab === 'overview' ? (
|
||||||
@@ -628,8 +626,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<div className="lg:col-span-8 bg-surface border border-slate-200/70 overflow-hidden flex flex-col justify-between shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
<div className="lg:col-span-8 bg-surface border border-slate-200/70 overflow-hidden flex flex-col justify-between shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||||
|
|
||||||
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
|
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 flex justify-between items-center select-none">
|
||||||
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
<span className="section-title block">
|
||||||
Weekly Order Distribution
|
Weekly order distribution
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
|
<div className="flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase tracking-tight">
|
||||||
@@ -667,7 +665,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<Table className="w-full text-center text-xs font-sans">
|
<Table className="w-full text-center text-xs font-sans">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHeaderCell className="w-32 text-left">Outlet Name</TableHeaderCell>
|
<TableHeaderCell className="text-left" style={{ minWidth: 190 }}>Outlet Name</TableHeaderCell>
|
||||||
{WEEK_LABELS.map((w) => (
|
{WEEK_LABELS.map((w) => (
|
||||||
<TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell>
|
<TableHeaderCell key={w} className="text-center">{w}</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
@@ -676,26 +674,59 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{insightRows.map((row, idx) => (
|
{insightRows.map((row, idx) => (
|
||||||
<TableRow key={idx}>
|
<TableRow key={idx}>
|
||||||
<TableCell className="text-left font-bold text-slate-800 tracking-wide truncate max-w-[8rem] border-r border-slate-100">
|
{/* No max-width cap: the column had `max-w-[8rem]` and
|
||||||
{row.name}
|
then `10rem`, both of which clipped every outlet name
|
||||||
|
("Suriya Store RS P…") while the five week columns sat
|
||||||
|
at 160px each with nothing in them. The table sizes it
|
||||||
|
now; `title` covers the genuinely long names. */}
|
||||||
|
{/* An explicit minWidth, not a max-width cap.
|
||||||
|
The cap (`max-w-[8rem]`) clipped every name to
|
||||||
|
"Suriya Store RS P…"; removing it entirely was worse —
|
||||||
|
the auto table layout then handed the width to the five
|
||||||
|
week columns and squeezed this one down to "Su". A
|
||||||
|
floor lets the column hold a full outlet name and still
|
||||||
|
share what is left. `title` sits on the span because
|
||||||
|
Astryx's TableCellProps omits it (same as the POS
|
||||||
|
username cell). */}
|
||||||
|
<TableCell className="text-left font-semibold text-slate-900 whitespace-nowrap" style={{ minWidth: 190 }}>
|
||||||
|
<span title={row.name}>{row.name}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{WEEK_KEYS.map((key, mIdx) => {
|
{WEEK_KEYS.map((key, mIdx) => {
|
||||||
const val = fnum(row.weeks[key]);
|
const val = fnum(row.weeks[key]);
|
||||||
const isPicked =
|
const isPicked =
|
||||||
selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx];
|
selectedCell?.day === row.name && selectedCell?.hour === WEEK_LABELS[mIdx];
|
||||||
|
// PASS 2 — the click target fills the cell instead of
|
||||||
|
// floating inside it as a rounded box. `padding: 0` on
|
||||||
|
// the cell plus a full-bleed button is what turns 25
|
||||||
|
// buttons back into a heat grid; the hairline row rules
|
||||||
|
// from the shared table treatment then do the separating,
|
||||||
|
// as they do on every other table.
|
||||||
|
//
|
||||||
|
// Still a Button: these cells are selectable, and the
|
||||||
|
// label carries the whole cell — each one used to
|
||||||
|
// announce itself as bare "12", with no hint of which
|
||||||
|
// outlet or which week produced it.
|
||||||
return (
|
return (
|
||||||
<TableCell key={key} className="p-1">
|
<TableCell key={key} style={{ padding: 0 }}>
|
||||||
{/* Every one of these announced itself as nothing
|
|
||||||
but its own number — "12", with no hint of which
|
|
||||||
outlet or which week produced it. The label now
|
|
||||||
carries the whole cell. */}
|
|
||||||
<Button
|
<Button
|
||||||
label={`${row.name}, ${WEEK_LABELS[mIdx]}: ${val} orders`}
|
label={`${row.name}, ${WEEK_LABELS[mIdx]}: ${val} orders`}
|
||||||
aria-pressed={isPicked}
|
aria-pressed={isPicked}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })}
|
onClick={() => setSelectedCell({ day: row.name, hour: WEEK_LABELS[mIdx], val })}
|
||||||
style={{ height: 'auto' }}
|
style={{
|
||||||
className={`w-full py-2.5 rounded-lg font-semibold transition-all duration-200 cursor-pointer ${getHeatmapColorClass(val)}`}
|
height: 'auto',
|
||||||
|
width: '100%',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 0,
|
||||||
|
padding: '14px 8px',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
outline: isPicked ? '2px solid var(--color-brand-ink)' : 'none',
|
||||||
|
outlineOffset: '-2px',
|
||||||
|
...getHeatmapStyle(val),
|
||||||
|
}}
|
||||||
|
className="cursor-pointer transition-colors duration-150"
|
||||||
>
|
>
|
||||||
{val}
|
{val}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -713,8 +744,8 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{/* Leaderboard nodes bar list - 4 Cols */}
|
{/* Leaderboard nodes bar list - 4 Cols */}
|
||||||
<div className="lg:col-span-4 bg-surface border border-slate-200/70 flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
<div className="lg:col-span-4 bg-surface border border-slate-200/70 flex flex-col shadow-[0_1px_3px_rgba(16,24,40,0.05)] hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
|
||||||
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 select-none">
|
<div className="bg-slate-50/50 border-b border-slate-100 px-5 py-4 select-none">
|
||||||
<span className="text-[11px] font-sans font-bold text-slate-800 uppercase tracking-widest block">
|
<span className="section-title block">
|
||||||
Top Performing Nodes
|
Top performing nodes
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -725,40 +756,44 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
currentLeaderboard.map((node, index) => {
|
currentLeaderboard.map((node, index) => {
|
||||||
let badgeStyle = '';
|
// PASS 4 — the top three carried literal 🥇🥈🥉 emoji on amber,
|
||||||
let badgeContent: React.ReactNode = node.rank;
|
// slate and orange grounds. Emoji render as a different typeface
|
||||||
|
// at a different weight on every platform, they cannot be
|
||||||
if (index === 0) {
|
// recoloured with the theme, and a screen reader announces
|
||||||
badgeStyle = 'bg-amber-100 text-amber-900 border border-amber-200';
|
// "1st place medal" where the column means "rank 1". Ranks are
|
||||||
badgeContent = '🥇';
|
// numerals now, the leader marked by the brand rather than by
|
||||||
} else if (index === 1) {
|
// gold. Nothing else in the console uses medals.
|
||||||
badgeStyle = 'bg-slate-100 text-slate-700 border border-slate-200';
|
const isLeader = index === 0;
|
||||||
badgeContent = '🥈';
|
|
||||||
} else if (index === 2) {
|
|
||||||
badgeStyle = 'bg-orange-100 text-orange-900 border border-orange-200';
|
|
||||||
badgeContent = '🥉';
|
|
||||||
} else {
|
|
||||||
badgeStyle = 'bg-slate-100 text-slate-500 border border-slate-200';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.rank} className="flex items-center gap-4 text-xs hover:bg-slate-50 p-2.5 rounded-xl transition-all duration-200 group">
|
<div key={node.rank} className="flex items-center gap-4 hover:bg-slate-50 p-2.5 rounded-control transition-colors duration-150 group">
|
||||||
{/* Ranking circle avatar */}
|
<div
|
||||||
<div className={`w-9 h-9 rounded-full font-bold flex items-center justify-center font-mono shrink-0 select-none text-sm transition-transform duration-300 group-hover:scale-105 ${badgeStyle}`}>
|
className="w-8 h-8 rounded-full flex items-center justify-center font-mono tabular-nums shrink-0 select-none"
|
||||||
{badgeContent}
|
style={
|
||||||
|
isLeader
|
||||||
|
? { background: 'var(--color-brand)', color: '#fff', fontSize: 12, fontWeight: 600 }
|
||||||
|
: { background: 'var(--color-slate-100)', color: 'var(--color-slate-500)', fontSize: 12, fontWeight: 600 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{node.rank}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex justify-between items-center font-medium mb-1.5">
|
<div className="flex justify-between items-center font-medium mb-1.5">
|
||||||
<span className="text-slate-900 font-bold truncate max-w-[12rem] flex items-center gap-1.5">
|
{/* PASS 1 — `truncate max-w-[12rem]` was on the wrapper
|
||||||
{node.name}
|
holding BOTH the name and the chip, so the ellipsis
|
||||||
|
ate the chip instead of the name: "VERIFIED" rendered
|
||||||
|
as "VERIFII". The truncation now applies to the name
|
||||||
|
alone and the chip is `shrink-0`. */}
|
||||||
|
<span className="text-slate-900 font-bold flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="truncate">{node.name}</span>
|
||||||
{index < 3 && (
|
{index < 3 && (
|
||||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-widest bg-emerald-50 text-emerald-600 border border-emerald-100" title="Verified Top Performer">
|
<span className="inline-flex shrink-0 items-center gap-0.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-emerald-50 text-emerald-700 border border-emerald-100" title="Verified top performer">
|
||||||
<CheckCircle2 size={10} /> Verified
|
<CheckCircle2 size={10} /> Verified
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-purple-700 font-mono font-bold">{node.revenue}</span>
|
<span className="text-[var(--color-brand-ink)] font-mono tabular-nums shrink-0 ml-3" style={{ fontSize: 12, fontWeight: 600 }}>{node.revenue}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
<div className="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||||
@@ -781,50 +816,56 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{/* Top 3 Overall Products - 4 Cols */}
|
{/* Top 3 Overall Products - 4 Cols */}
|
||||||
<div className="lg:col-span-4 bg-surface border border-slate-200 rounded-card flex flex-col relative overflow-hidden">
|
<div className="lg:col-span-4 bg-surface border border-slate-200 rounded-card flex flex-col relative overflow-hidden">
|
||||||
{/* Subtle glow background */}
|
{/* Subtle glow background */}
|
||||||
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-400/20 rounded-full blur-[40px] -mr-10 -mt-10 pointer-events-none" />
|
{/* PASS 4 — a blurred purple glow bleeding out of the corner, removed. */}
|
||||||
|
|
||||||
<div className="px-5 py-4 border-b border-purple-100/40 flex items-center justify-between relative z-10">
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
<span className="text-[13px] font-sans font-bold text-slate-800 tracking-tight flex items-center gap-2">
|
{/* PASS 3/4 — one heading role, and the amber trophy medallion is
|
||||||
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center shadow-inner">
|
gone. Five sections on this page carried five different heading
|
||||||
<Trophy className="text-amber-500 fill-amber-100" size={12} />
|
treatments (11px uppercase, two at 13px with icon tiles, one at
|
||||||
</div>
|
14px, one at 16px); none of them was the `section-title` role
|
||||||
Overall Top 3
|
every other page uses. */}
|
||||||
</span>
|
<span className="section-title">Overall top 3</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-5 flex-1 space-y-3 flex flex-col relative z-10">
|
<div className="p-5 flex-1 space-y-2 flex flex-col">
|
||||||
{topOverallProducts.length === 0 ? (
|
{topOverallProducts.length === 0 ? (
|
||||||
<div className="text-center py-6 text-slate-400 font-medium text-[11px]">No sales data available.</div>
|
<div className="text-center py-6 text-slate-400 font-medium text-[11px]">No sales data available.</div>
|
||||||
) : (
|
) : (
|
||||||
topOverallProducts.map((prod, index) => {
|
topOverallProducts.map((prod, index) => {
|
||||||
|
// PASS 4 — the leader no longer gets a cream row, an amber rank
|
||||||
|
// pill, a 2px amber photo border, an amber revenue chip and a
|
||||||
|
// larger type size. Amber appears nowhere else in the console
|
||||||
|
// and five simultaneous signals for "this one is first" is four
|
||||||
|
// too many — the rank numeral already says it. Every row is now
|
||||||
|
// the same row, and the image stops zooming on hover.
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
return (
|
return (
|
||||||
<div key={prod.id} className={`flex items-center gap-3 text-[11px] p-2.5 rounded-xl transition-all duration-300 group cursor-default relative overflow-hidden ${isFirst ? 'bg-amber-50 border border-amber-200' : 'bg-surface border border-slate-100 hover:border-purple-200'}`}>
|
<div key={prod.id} className="flex items-center gap-3 p-2.5 rounded-control bg-surface border border-slate-100 hover:border-purple-200 transition-colors duration-150 cursor-default">
|
||||||
|
<div
|
||||||
{/* Rank Badge */}
|
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 font-mono tabular-nums"
|
||||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 shadow-inner ${isFirst ? 'bg-amber-100 text-amber-900 text-xs font-semibold' : index === 1 ? 'bg-slate-100 text-slate-700 text-[10px] font-semibold' : 'bg-orange-100 text-orange-900 text-[10px] font-semibold'}`}>
|
style={
|
||||||
#{index + 1}
|
isFirst
|
||||||
|
? { background: 'var(--color-brand)', color: '#fff', fontSize: 11, fontWeight: 600 }
|
||||||
|
: { background: 'var(--color-slate-100)', color: 'var(--color-slate-500)', fontSize: 11, fontWeight: 600 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image */}
|
<div className="w-10 h-10 rounded-control overflow-hidden shrink-0 bg-surface border border-slate-200">
|
||||||
<div className={`w-10 h-10 rounded-lg overflow-hidden shrink-0 bg-surface ${isFirst ? 'border-2 border-amber-300 shadow-sm' : 'border border-slate-200 shadow-sm'}`}>
|
<img src={prod.image} alt={prod.name} referrerPolicy="no-referrer" className="w-full h-full object-cover" />
|
||||||
<img src={prod.image} alt={prod.name} referrerPolicy="no-referrer" className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 py-0.5">
|
<div className="flex-1 min-w-0 py-0.5">
|
||||||
<div className={`font-bold truncate ${isFirst ? 'text-slate-900 text-sm' : 'text-slate-800 text-xs'}`}>{prod.name}</div>
|
<div className="font-semibold text-slate-900 truncate" style={{ fontSize: 13 }}>{prod.name}</div>
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
<span className={`text-[10px] font-bold tracking-wide ${isFirst ? 'text-amber-600 bg-amber-100/50 px-1 py-0.5 rounded' : 'text-purple-600 bg-purple-50 px-1 py-0.5 rounded'}`}>
|
<span className="text-[var(--color-brand-ink)] bg-purple-50 rounded-full px-2 py-0.5 tabular-nums" style={{ fontSize: 10, fontWeight: 600 }}>
|
||||||
₹{prod.revenue.toLocaleString()} rev
|
₹{prod.revenue.toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
<span className="w-1 h-1 rounded-full bg-slate-300" />
|
<span className="w-1 h-1 rounded-full bg-slate-300" />
|
||||||
<span className="eyebrow">{prod.category}</span>
|
<span className="eyebrow">{prod.category}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isFirst && (
|
|
||||||
<div className="" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -833,14 +874,12 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Store-wise Top Products - 8 Cols */}
|
{/* Store-wise Top Products - 8 Cols */}
|
||||||
<div className="lg:col-span-8 bg-white/95 backdrop-blur-xl border border-slate-200/80 rounded-[1.5rem] flex flex-col shadow-xl shadow-slate-200/20 relative overflow-hidden">
|
{/* PASS 4 — was `bg-white/95 backdrop-blur-xl` with a 1.5rem radius
|
||||||
|
and an xl shadow: a frosted, extra-round, extra-raised card sitting
|
||||||
|
beside a plain one. Same card as everything else now. */}
|
||||||
|
<div className="lg:col-span-8 bg-surface border border-slate-200 rounded-card flex flex-col overflow-hidden">
|
||||||
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
<span className="text-[13px] font-sans font-bold text-slate-800 tracking-tight flex items-center gap-2">
|
<span className="section-title">Store-wise top performers</span>
|
||||||
<div className="w-6 h-6 rounded-full bg-indigo-50 flex items-center justify-center shadow-inner">
|
|
||||||
<Store className="text-indigo-500" size={12} />
|
|
||||||
</div>
|
|
||||||
Store-Wise Top Performers
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 flex-1 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-300 scrollbar-track-transparent pb-4">
|
<div className="p-5 flex-1 overflow-x-auto scrollbar-thin scrollbar-thumb-slate-300 scrollbar-track-transparent pb-4">
|
||||||
{topProductsByStore.length === 0 ? (
|
{topProductsByStore.length === 0 ? (
|
||||||
@@ -850,14 +889,14 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{topProductsByStore.map(store => (
|
{topProductsByStore.map(store => (
|
||||||
<div key={store.locationname} className="w-[230px] rounded-card bg-surface border border-slate-200 p-4 shrink-0 hover:border-indigo-200 transition-colors duration-200 group">
|
<div key={store.locationname} className="w-[230px] rounded-card bg-surface border border-slate-200 p-4 shrink-0 hover:border-indigo-200 transition-colors duration-200 group">
|
||||||
<div className="font-bold text-slate-800 text-xs mb-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
<div className="font-bold text-slate-800 text-xs mb-4 pb-3 border-b border-slate-100 flex items-center gap-2">
|
||||||
<div className="w-2 h-2 rounded-full bg-indigo-500 shadow-[0_0_8px_rgba(99,102,241,0.6)] animate-pulse" />
|
<div className="w-1.5 h-1.5 rounded-full bg-[var(--color-brand)] shrink-0" />
|
||||||
<span className="truncate">{store.locationname}</span>
|
<span className="truncate">{store.locationname}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{store.topProducts.map((prod, idx) => (
|
{store.topProducts.map((prod, idx) => (
|
||||||
<div key={prod.id} className="flex items-center gap-2.5 group/item">
|
<div key={prod.id} className="flex items-center gap-2.5 group/item">
|
||||||
<div className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 font-semibold text-[10px] ${idx === 0 ? 'bg-amber-100 text-amber-600' : idx === 1 ? 'bg-slate-100 text-slate-600' : 'bg-orange-50 text-orange-600'}`}>
|
<div className={`w-5 h-5 rounded-md flex items-center justify-center shrink-0 font-semibold text-[10px] ${idx === 0 ? 'bg-purple-50 text-[var(--color-brand-ink)]' : 'bg-slate-100 text-slate-500'}`}>
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -889,9 +928,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
{/* Table header with filters control */}
|
{/* Table header with filters control */}
|
||||||
<div className="bg-[var(--color-slate-50)] border-b border-[var(--color-slate-200)] p-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-sm select-none">
|
<div className="bg-[var(--color-slate-50)] border-b border-[var(--color-slate-200)] p-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-sm select-none">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-sans font-bold text-sm text-[var(--color-slate-900)] flex items-center gap-1.5">
|
<h3 className="section-title">Product performance matrix</h3>
|
||||||
<Activity size={15} className="text-[var(--color-brand-ink)]" /> Product Performance Matrix
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick interactive filter pills */}
|
{/* Quick interactive filter pills */}
|
||||||
@@ -950,18 +987,30 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
|
className={`transition-transform duration-200 ${isExpanded ? 'rotate-180 text-purple-650' : 'text-zinc-400'}`}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="flex items-center gap-sm">
|
{/* PASS 1 — `display: flex` was on the <td> itself.
|
||||||
<div className="w-8 h-8 rounded-lg shrink-0 border border-[var(--color-slate-200)] overflow-hidden bg-zinc-50">
|
A flexed table cell drops out of the table layout
|
||||||
<img
|
algorithm entirely: it no longer takes part in column
|
||||||
src={prod.image}
|
sizing, so it collapsed to its content's min-width
|
||||||
alt={prod.name}
|
(32px — the thumbnail) while SKU / Units / Revenue /
|
||||||
referrerPolicy="no-referrer"
|
Status split the remaining 1420px between them. The
|
||||||
className="w-full h-full object-cover"
|
product name then wrapped one character per line
|
||||||
/>
|
inside those 32px, which is what pushed the row to
|
||||||
</div>
|
285px tall with an apparently empty name column.
|
||||||
<div>
|
The flex belongs on a wrapper inside the cell. */}
|
||||||
<span className="font-bold text-[var(--color-slate-900)] block">{prod.name}</span>
|
<TableCell>
|
||||||
<span className="eyebrow block mt-0.5">{prod.category}</span>
|
<div className="flex items-center gap-sm">
|
||||||
|
<div className="w-8 h-8 rounded-control shrink-0 border border-[var(--color-slate-200)] overflow-hidden bg-zinc-50">
|
||||||
|
<img
|
||||||
|
src={prod.image}
|
||||||
|
alt={prod.name}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-bold text-[var(--color-slate-900)] block truncate">{prod.name}</span>
|
||||||
|
<span className="eyebrow block mt-0.5 truncate">{prod.category}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-zinc-500 font-semibold">
|
<TableCell className="font-mono text-zinc-500 font-semibold">
|
||||||
@@ -973,15 +1022,19 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
<TableCell className="text-right font-mono text-zinc-800 font-bold">
|
<TableCell className="text-right font-mono text-zinc-800 font-bold">
|
||||||
₹{prod.revenue.toLocaleString()}
|
₹{prod.revenue.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{/* PASS 2 — onto the shared StatusChip, so a stock state
|
||||||
|
here looks like a stock state anywhere else. It was
|
||||||
|
a hand-rolled 10px uppercase pill; every other chip
|
||||||
|
in the console is 26px / 12px / sentence case. */}
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide inline-block ${prod.stockStatus === 'Healthy'
|
<StatusChip
|
||||||
? 'bg-emerald-50 text-emerald-600 border border-emerald-100'
|
label={prod.stockStatus}
|
||||||
: prod.stockStatus === 'Low Stock'
|
color={
|
||||||
? 'bg-amber-50 text-amber-600 border border-amber-100'
|
prod.stockStatus === 'Healthy' ? '#10b981'
|
||||||
: 'bg-rose-50 text-rose-600 border border-rose-100'
|
: prod.stockStatus === 'Low Stock' ? '#f59e0b'
|
||||||
}`}>
|
: '#ef4444'
|
||||||
{prod.stockStatus}
|
}
|
||||||
</span>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
@@ -1151,7 +1204,7 @@ export default function ReportsView({ searchQuery, isCoimbatoreView, setIsCoimba
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<SalesRevenueReport tenantId={tenantId} locationid={activeStoreId === 'all' ? undefined : Number(activeStoreId)} />
|
<SalesRevenueReport tenantId={tenantId} locationid={locationid} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
Store,
|
Store,
|
||||||
@@ -32,7 +33,7 @@ import { Badge } from '@astryxdesign/core/Badge';
|
|||||||
import { Heading } from '@astryxdesign/core/Heading';
|
import { Heading } from '@astryxdesign/core/Heading';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
import { Spinner } from '@astryxdesign/core/Spinner';
|
import { Spinner } from '@astryxdesign/core/Spinner';
|
||||||
import { StatusChip, PageHeader, SHADOW_MD } from './consoleUi';
|
import { PageHeader } from './consoleUi';
|
||||||
|
|
||||||
type TabKey = 'profile' | 'outlets' | 'users';
|
type TabKey = 'profile' | 'outlets' | 'users';
|
||||||
|
|
||||||
@@ -169,7 +170,24 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
// until dismissed; a toast does not, and errors stay until read.
|
// until dismissed; a toast does not, and errors stay until read.
|
||||||
const { notifyOk, notifyFail } = useNotify();
|
const { notifyOk, notifyFail } = useNotify();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('profile');
|
/**
|
||||||
|
* The open section, deep-linkable via `?tab=`.
|
||||||
|
*
|
||||||
|
* Settings is reached from the account menu now rather than from the top
|
||||||
|
* navigation, and that menu lists the three sections directly — so each entry
|
||||||
|
* has to be able to open its own section rather than dumping everyone on
|
||||||
|
* Business Profile and making them click again.
|
||||||
|
*
|
||||||
|
* `useSearchParams` rather than local state so the section survives a reload
|
||||||
|
* and can be linked to; an unknown or absent value falls back to 'profile'.
|
||||||
|
*/
|
||||||
|
// Read-only now: the account dropdown is the only way in, and each of its
|
||||||
|
// entries carries its own `?tab=`. There is no in-page control left to write
|
||||||
|
// this back, so there is no setter.
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const tabParam = searchParams.get('tab');
|
||||||
|
const activeTab: TabKey =
|
||||||
|
tabParam === 'outlets' || tabParam === 'users' || tabParam === 'profile' ? tabParam : 'profile';
|
||||||
|
|
||||||
// Live tenant profile + outlets.
|
// Live tenant profile + outlets.
|
||||||
// Fetch a larger page size to ensure we find our specific tenant (ID 1087).
|
// Fetch a larger page size to ensure we find our specific tenant (ID 1087).
|
||||||
@@ -271,11 +289,9 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
|
const set = <K extends keyof MerchantSettings>(key: K, value: MerchantSettings[K]) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }));
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
const tabs: Array<{ key: TabKey; label: string; icon: typeof Building2 }> = [
|
// The `tabs` array that used to live here is gone with the rail it fed. The
|
||||||
{ key: 'profile', label: 'Business Profile', icon: Building2 },
|
// same three sections are declared once, in App.tsx, as the account
|
||||||
{ key: 'outlets', label: 'Outlets', icon: Store },
|
// dropdown's `settingsSections` — the labels a user sees now have one source.
|
||||||
{ key: 'users', label: 'Users & Access', icon: Users },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Build role options from the live app-roles API; fall back to the known
|
// Build role options from the live app-roles API; fall back to the known
|
||||||
// numeric roles + roleName() helper when the API has no rows/names.
|
// numeric roles + roleName() helper when the API has no rows/names.
|
||||||
@@ -307,51 +323,16 @@ export default function SettingsView({ tenantId = FIESTA_TENANT_ID, user }: Sett
|
|||||||
subtitle="Business profile, outlets, and who can sign in to this workspace."
|
subtitle="Business profile, outlets, and who can sign in to this workspace."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-gutter items-start">
|
{/* The left column is gone: a section rail and a merchant identity card.
|
||||||
{/* Tab rail & Merchant Card */}
|
Both were saying something already said elsewhere —
|
||||||
<div className="lg:col-span-1 space-y-md bg-slate-50/50 border border-slate-200/60 p-4 shadow-sm">
|
• the rail duplicated the account dropdown, which now lists the same
|
||||||
{/* Merchant ID Card */}
|
three sections and links straight into each;
|
||||||
{/* STAGE 8 — the merchant card was the last dark surface in the
|
• the card showed who is signed in and the store's status, which is
|
||||||
console: a three-stop near-black gradient with a blurred purple
|
the account dropdown's whole job (name, role, email) plus a status
|
||||||
blob, a purple→indigo avatar and a 15px glow ring. It is a plain
|
that never varies.
|
||||||
card now, like everything else. */}
|
The panel takes the full width instead of three quarters of it. */}
|
||||||
<div className="bg-surface border border-slate-200 p-4 rounded-card select-none" style={{ boxShadow: SHADOW_MD }}>
|
<div className="grid grid-cols-1 gap-gutter items-start">
|
||||||
<div className="flex items-center gap-3">
|
<div className="space-y-gutter text-sm pb-24">
|
||||||
<div className="w-10 h-10 rounded-full bg-[var(--color-brand)] text-white flex items-center justify-center shrink-0" style={{ fontSize: 13, fontWeight: 600 }}>
|
|
||||||
{user?.name ? user.name.substring(0, 2).toUpperCase() : tenant ? fstr(tenant.tenantname).substring(0, 2).toUpperCase() : 'ND'}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h4 className="section-title truncate">{user?.name || (tenant ? fstr(tenant.tenantname) : 'Nearle Merchant')}</h4>
|
|
||||||
<p className="font-mono mt-0.5 truncate" style={{ fontSize: 10, color: 'var(--color-slate-500)' }}>Store ID · #{tenantId}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-100 flex justify-between items-center">
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--color-slate-500)' }}>Status</span>
|
|
||||||
<StatusChip label="Online & synced" color="#10b981" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation tab rail */}
|
|
||||||
<nav className="flex lg:flex-col gap-1.5 overflow-x-auto custom-scrollbar no-scrollbar touch-scrolling select-none">
|
|
||||||
{tabs.map((t) => {
|
|
||||||
const Icon = t.icon;
|
|
||||||
const active = activeTab === t.key;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={t.key}
|
|
||||||
label={t.label}
|
|
||||||
variant={active ? 'primary' : 'ghost'}
|
|
||||||
icon={<Icon size={16} />}
|
|
||||||
onClick={() => setActiveTab(t.key)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<div className="lg:col-span-3 space-y-gutter text-sm pb-24">
|
|
||||||
{activeTab === 'profile' && (
|
{activeTab === 'profile' && (
|
||||||
<div className="bg-surface border border-slate-200/60 p-6 shadow-sm space-y-lg animate-in fade-in duration-200">
|
<div className="bg-surface border border-slate-200/60 p-6 shadow-sm space-y-lg animate-in fade-in duration-200">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export const ADMIN_NAV_ITEMS = [
|
|||||||
// bookmarks and the section-height rules in App.tsx keep working.
|
// bookmarks and the section-height rules in App.tsx keep working.
|
||||||
{ id: 'dispatch' as MainSection, label: 'Store Console', icon: Truck },
|
{ id: 'dispatch' as MainSection, label: 'Store Console', icon: Truck },
|
||||||
{ id: 'pos' as MainSection, label: 'POS', icon: Monitor },
|
{ id: 'pos' as MainSection, label: 'POS', icon: Monitor },
|
||||||
{ id: 'settings' as MainSection, label: 'Settings', icon: Settings },
|
// `settings` is deliberately absent. It moved into the account dropdown,
|
||||||
|
// which lists its three sections directly — it is workspace configuration,
|
||||||
|
// not a section anyone works from, and it was holding a nav slot that the
|
||||||
|
// day-to-day sections need. The /admin/settings route is unchanged.
|
||||||
];
|
];
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
|
|||||||
@@ -917,17 +917,14 @@ export default function StoreDetailView({ store, onBack, canManage = true, only,
|
|||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
{/* The title block collapses to a screen-reader-only heading —
|
||||||
<div className="inline-flex items-center gap-2 px-2.5 py-0.5 rounded-full bg-[var(--color-brand)]/10 border border-[var(--color-brand-ink)]/20 text-[var(--color-brand-ink)] text-[10px] font-semibold uppercase tracking-wider">
|
the nav already says "Customers", so the visible 24px title,
|
||||||
Store Audience Registry
|
the "Store audience registry" badge and the "System sync
|
||||||
</div>
|
active" pulse were three lines of chrome above the actual
|
||||||
<div className="flex items-center gap-1.5">
|
counts. The counts stay; they are the only part that says
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
anything. */}
|
||||||
<span className="text-[10px] font-semibold tracking-wider text-emerald-700 uppercase">System Sync Active</span>
|
<h1 className="sr-only">Customer CRM</h1>
|
||||||
</div>
|
<div className="flex flex-wrap items-center gap-2 text-[12px] text-zinc-500 font-medium">
|
||||||
</div>
|
|
||||||
<h1 className="page-title">Customer CRM</h1>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-[12px] text-zinc-500 mt-1 font-medium">
|
|
||||||
<span>
|
<span>
|
||||||
{customersList.length} {customersList.length === 1 ? 'customer orders' : 'customers order'} from <strong className="text-[var(--color-slate-900)] font-bold">{store.name}</strong>
|
{customersList.length} {customersList.length === 1 ? 'customer orders' : 'customers order'} from <strong className="text-[var(--color-slate-900)] font-bold">{store.name}</strong>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -56,11 +56,22 @@ interface UserStorePageProps {
|
|||||||
|
|
||||||
// Sidebar navigation. Add entries here as new user sections are built — each id
|
// Sidebar navigation. Add entries here as new user sections are built — each id
|
||||||
// gets a matching branch in `renderSection` below.
|
// gets a matching branch in `renderSection` below.
|
||||||
|
/**
|
||||||
|
* The old first entry, `console`, is gone.
|
||||||
|
*
|
||||||
|
* It rendered `<StoreDetailView only="overview" canManage={false} />` — which
|
||||||
|
* is character for character what the Console page's own Overview tab renders.
|
||||||
|
* Two nav entries, two routes, one screen: a store user landed on the overview,
|
||||||
|
* and the only way to reach the map, orders, deliveries or till was to click a
|
||||||
|
* second entry that opened the same overview again with tabs around it.
|
||||||
|
*
|
||||||
|
* `dispatch` takes its place and its name. It is the same overview, plus every
|
||||||
|
* other surface, so nothing is lost by landing there instead.
|
||||||
|
*/
|
||||||
const NAV_ITEMS: UserNavItem[] = [
|
const NAV_ITEMS: UserNavItem[] = [
|
||||||
{ id: 'console', label: 'Store Console', icon: LayoutDashboard },
|
{ id: 'dispatch', label: 'Store Console', icon: LayoutDashboard },
|
||||||
{ id: 'inventory', label: 'Products', icon: Layers },
|
{ id: 'inventory', label: 'Products', icon: Layers },
|
||||||
{ id: 'customers', label: 'Customers', icon: Users },
|
{ id: 'customers', label: 'Customers', icon: Users },
|
||||||
{ id: 'dispatch', label: 'Console', icon: RouteIcon },
|
|
||||||
{ id: 'pos', label: 'POS', icon: Monitor },
|
{ id: 'pos', label: 'POS', icon: Monitor },
|
||||||
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
{ id: 'reports', label: 'Reports', icon: ClipboardList },
|
||||||
];
|
];
|
||||||
@@ -85,7 +96,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
|
|
||||||
// Extract the current section from the URL for Header context
|
// Extract the current section from the URL for Header context
|
||||||
const pathParts = location.pathname.split('/');
|
const pathParts = location.pathname.split('/');
|
||||||
const activeSection = pathParts[pathParts.length - 1] || 'console';
|
const activeSection = pathParts[pathParts.length - 1] || 'dispatch';
|
||||||
|
|
||||||
// Scope every query to the signed-in merchant's tenant; the shared constant is
|
// Scope every query to the signed-in merchant's tenant; the shared constant is
|
||||||
// only a fallback for legacy sessions whose record predates tenantid capture.
|
// only a fallback for legacy sessions whose record predates tenantid capture.
|
||||||
@@ -309,7 +320,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
return <PosStoreSection locationid={posLocationId} tenantId={tenantId} storeName={storeName} />;
|
return <PosStoreSection locationid={posLocationId} tenantId={tenantId} storeName={storeName} />;
|
||||||
if (activeSection === 'pos-till')
|
if (activeSection === 'pos-till')
|
||||||
return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
return <PosView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
||||||
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />;
|
if (activeSection === 'dispatch') return <DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} isStoreUser />;
|
||||||
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
|
if (activeSection === 'reports') return <DeliveryReportsView tenantId={tenantId} locationid={resolvedLocationId || undefined} />;
|
||||||
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
// Inventory & Catalog is its own page: the manager-curated catalog the user
|
||||||
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
// stocks from (the catalog query is tenant-level, so it doesn't need the store
|
||||||
@@ -353,7 +364,9 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
navBasePath="/store"
|
navBasePath="/store"
|
||||||
profile={profile}
|
profile={profile}
|
||||||
storeContext={{
|
storeContext={{
|
||||||
storeName: activeSection === 'console'
|
// `dispatch` is the store console now, so it takes the outlet's own
|
||||||
|
// name — the same thing the removed `console` route used to show.
|
||||||
|
storeName: activeSection === 'dispatch'
|
||||||
? storeName
|
? storeName
|
||||||
: activeSection === 'inventory'
|
: activeSection === 'inventory'
|
||||||
? 'Products'
|
? 'Products'
|
||||||
@@ -363,10 +376,8 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
? 'POS Terminal'
|
? 'POS Terminal'
|
||||||
: activeSection === 'account'
|
: activeSection === 'account'
|
||||||
? 'My Account'
|
? 'My Account'
|
||||||
: activeSection === 'dispatch'
|
|
||||||
? 'Console'
|
|
||||||
: activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
|
: activeSection.charAt(0).toUpperCase() + activeSection.slice(1),
|
||||||
icon: activeSection === 'console'
|
icon: activeSection === 'dispatch'
|
||||||
? LayoutDashboard
|
? LayoutDashboard
|
||||||
: activeSection === 'inventory'
|
: activeSection === 'inventory'
|
||||||
? Layers
|
? Layers
|
||||||
@@ -376,11 +387,11 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
? ShoppingBag
|
? ShoppingBag
|
||||||
: activeSection === 'customers'
|
: activeSection === 'customers'
|
||||||
? Users
|
? Users
|
||||||
: activeSection === 'dispatch'
|
// The second `dispatch` test that used to sit here was dead —
|
||||||
? RouteIcon
|
// the first branch above already catches it.
|
||||||
: activeSection === 'reports'
|
: activeSection === 'reports'
|
||||||
? ClipboardList
|
? ClipboardList
|
||||||
: undefined
|
: undefined
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -413,7 +424,17 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="console" replace />} />
|
<Route index element={<Navigate to="dispatch" replace />} />
|
||||||
|
{/* `/store/console` kept as a redirect rather than deleted: it was
|
||||||
|
the landing route, so it is in bookmarks and in any link already
|
||||||
|
sent to a store user. Removing it outright would 404 them.
|
||||||
|
|
||||||
|
ABSOLUTE path, not `to="dispatch"`. A relative target resolves
|
||||||
|
against the matched route, so from `/store/console` it produced
|
||||||
|
`/store/console/dispatch` — a URL matching nothing, which
|
||||||
|
rendered a blank page. The index redirect above can stay
|
||||||
|
relative because it resolves against the parent. */}
|
||||||
|
<Route path="console" element={<Navigate to="/store/dispatch" replace />} />
|
||||||
<Route path="account" element={
|
<Route path="account" element={
|
||||||
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
<div className="w-full p-container-margin md:p-xl space-y-lg transition-all duration-300">
|
||||||
{renderAccount()}
|
{renderAccount()}
|
||||||
@@ -435,7 +456,7 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
} />
|
} />
|
||||||
<Route path="dispatch" element={
|
<Route path="dispatch" element={
|
||||||
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
<div className={`w-full h-full ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} />
|
<DispatchHubView locationid={resolvedLocationId || undefined} tenantId={tenantId} isStoreUser />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="reports" element={
|
<Route path="reports" element={
|
||||||
@@ -448,11 +469,6 @@ export default function UserStorePage({ onLogout, user }: UserStorePageProps) {
|
|||||||
<StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />
|
<StoreCatalogView locationid={resolvedLocationId || undefined} storeName={storeName} tenantId={tenantId} />
|
||||||
</div>
|
</div>
|
||||||
} />
|
} />
|
||||||
<Route path="console" element={
|
|
||||||
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
|
||||||
<StoreDetailView store={buildStore()} canManage={false} only="overview" tenantId={tenantId} />
|
|
||||||
</div>
|
|
||||||
} />
|
|
||||||
<Route path="customers" element={
|
<Route path="customers" element={
|
||||||
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
<div className={`w-full p-container-margin md:p-xl space-y-lg transition-all duration-300 ${isInactive ? 'pointer-events-none opacity-50' : ''}`}>
|
||||||
<StoreDetailView store={buildStore()} canManage={false} only="customers" tenantId={tenantId} />
|
<StoreDetailView store={buildStore()} canManage={false} only="customers" tenantId={tenantId} />
|
||||||
|
|||||||
@@ -153,25 +153,46 @@ export function PageHeader({
|
|||||||
subtitle,
|
subtitle,
|
||||||
status,
|
status,
|
||||||
right,
|
right,
|
||||||
|
isTitleVisible = false,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
/** One line, sentence case. KROW uses this to say what the page is for. */
|
/** One line, sentence case. Not rendered unless `isTitleVisible`. */
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
/** A chip that sits inline after the title — live/stale, count, scope. */
|
/** A chip that sits inline after the title — live/stale, count, scope. */
|
||||||
status?: React.ReactNode;
|
status?: React.ReactNode;
|
||||||
/** Actions aligned to the trailing edge. */
|
/** Actions aligned to the trailing edge. */
|
||||||
right?: React.ReactNode;
|
right?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Opt back in to the visible 24px title + subtitle block. Off by default:
|
||||||
|
* the title/subtitle pair cost roughly 80px of vertical space on every page
|
||||||
|
* and said little the top navigation was not already saying — the active nav
|
||||||
|
* pill names the section, and the subtitles were descriptive filler.
|
||||||
|
*/
|
||||||
|
isTitleVisible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
// The <h1> survives either way. Nine pages had NO heading at all before this
|
||||||
|
// work, which leaves a screen-reader user with no landmark for "what is this
|
||||||
|
// page" and no entry in the document outline. `sr-only` keeps the landmark at
|
||||||
|
// zero visual cost, which is the whole point of the class — hiding it with
|
||||||
|
// `display: none` would remove it from the accessibility tree too.
|
||||||
|
const heading = isTitleVisible
|
||||||
|
? <h1 className="page-title">{title}</h1>
|
||||||
|
: <h1 className="sr-only">{title}</h1>;
|
||||||
|
|
||||||
|
// Nothing visible to lay out — render the heading alone so the component
|
||||||
|
// occupies no space at all.
|
||||||
|
if (!isTitleVisible && !status && !right) return heading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2.5">
|
<div className="flex flex-wrap items-center gap-2.5">
|
||||||
{/* `page-title` carries size, weight, tracking, line-height and colour
|
{/* `page-title` carries size, weight, tracking, line-height and colour
|
||||||
as one role — see the compound classes in index.css. */}
|
as one role — see the compound classes in index.css. */}
|
||||||
<h1 className="page-title">{title}</h1>
|
{heading}
|
||||||
{status}
|
{status}
|
||||||
</div>
|
</div>
|
||||||
{subtitle && <p className="page-sub mt-1">{subtitle}</p>}
|
{isTitleVisible && subtitle && <p className="page-sub mt-1">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
{right && <div className="shrink-0">{right}</div>}
|
{right && <div className="shrink-0">{right}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ export function answerLocally(question: string, snap: AssistantSnapshot): Omit<A
|
|||||||
case 'help':
|
case 'help':
|
||||||
return {
|
return {
|
||||||
text:
|
text:
|
||||||
`I read this tenant's live data and answer from it — I do not guess.\n\n` +
|
`I'm Nearle Buddy. I read this tenant's live data and answer from it — I do not guess.\n\n` +
|
||||||
`Try:\n` +
|
`Try:\n` +
|
||||||
`• "revenue this week"\n` +
|
`• "revenue this week"\n` +
|
||||||
`• "how many orders are pending?"\n` +
|
`• "how many orders are pending?"\n` +
|
||||||
|
|||||||
Reference in New Issue
Block a user