update the adminpage ui
This commit is contained in:
@@ -5,6 +5,7 @@ import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {ScopeControls} from '@/shared/components/scope/ScopeControls';
|
||||
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
|
||||
import {useRecentVisits} from '@/features/dashboard/hooks/useReports';
|
||||
import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
|
||||
/**
|
||||
@@ -14,14 +15,23 @@ import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
* size — one feed implementation, two budgets. The cursor the platform returns
|
||||
* is the supported way to page further; the dashboard never needs it, so it is
|
||||
* wired here first when infinite scroll lands.
|
||||
*
|
||||
* New arrivals are pushed over GET /api/visits/stream and each one re-reads
|
||||
* the feed; "Live" shows only while that stream is actually connected.
|
||||
*/
|
||||
export default function ActivityPage() {
|
||||
const visits = useRecentVisits(50);
|
||||
const {isLive} = useArrivalStream(
|
||||
visits.refetch,
|
||||
// Stale rows from the previous store are not a position to resume from.
|
||||
visits.isRefreshing ? undefined : visits.data?.cursor,
|
||||
);
|
||||
const scopeLabel = useScopeLabel();
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
eyebrow={isLive ? 'Live' : undefined}
|
||||
title="Activity"
|
||||
description={`Every recognised arrival across ${scopeLabel}, newest first.`}
|
||||
controls={<ScopeControls />}
|
||||
|
||||
24
src/app/(workspace)/customers/page.tsx
Normal file
24
src/app/(workspace)/customers/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {PageHeader} from '@/shared/components/primitives/PageHeader';
|
||||
import {CustomerDirectory} from '@/features/customers/components/CustomerDirectory';
|
||||
|
||||
/**
|
||||
* The customer directory, from GET /api/visitors.
|
||||
*
|
||||
* Company-wide, not scoped by the store switcher: a customer belongs to the
|
||||
* business, and somebody who first walked into one branch is the same person
|
||||
* at another.
|
||||
*/
|
||||
export default function CustomersPage() {
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Customers"
|
||||
description="Everyone the cameras have recognised — name them, see their visits, record a purchase."
|
||||
/>
|
||||
<CustomerDirectory />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {AreaChartView} from '@/shared/components/charts/AreaChartView';
|
||||
import {BarChartView} from '@/shared/components/charts/BarChartView';
|
||||
import {KpiRow} from '@/features/dashboard/components/KpiRow';
|
||||
import {ArrivalsFeed} from '@/features/dashboard/components/ArrivalsFeed';
|
||||
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
|
||||
import {EngagementSection} from '@/features/engagement/components/EngagementSection';
|
||||
import {CHART} from '@/shared/components/charts/palette';
|
||||
import {
|
||||
useConversionReport,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useFootfallReport,
|
||||
useRecentVisits,
|
||||
} from '@/features/dashboard/hooks/useDashboard';
|
||||
import {useArrivalStream} from '@/features/dashboard/hooks/useArrivalStream';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {greetingFor} from '@/features/dashboard/services/dashboardService';
|
||||
import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||
@@ -30,23 +31,27 @@ import {formatCompact, formatInrCompact} from '@/shared/utils/format';
|
||||
* endpoint, and no fixture: if the platform returns nothing, this page shows
|
||||
* nothing rather than something plausible.
|
||||
*
|
||||
* ── What was removed and why ─────────────────────────────────────────────
|
||||
* The engagement layer that used to sit here — ten activity types, impact
|
||||
* chains, campaign funnels, a customer journey, generated insights — was built
|
||||
* against a loyalty domain the platform does not expose. It rendered numbers
|
||||
* with no source. Rather than keep them behind a demo flag where a merchant
|
||||
* could mistake them for real, the panels are replaced by a statement of what
|
||||
* they need. The layout, spacing and hierarchy are otherwise untouched.
|
||||
* ── Engagement ───────────────────────────────────────────────────────────
|
||||
* The engagement layer that used to sit here was generated locally and was
|
||||
* removed. It is back, read from the platform's own endpoints — activities and
|
||||
* their impact, campaigns, and the customer journey — so every figure has a
|
||||
* source, and every attributed one says it is estimated.
|
||||
*
|
||||
* Bucket labels from the reports are rendered as STRINGS. They are local wall
|
||||
* time with no offset; parsing one into a Date re-interprets it in the
|
||||
* viewer's zone and shifts every label on the axis.
|
||||
*/
|
||||
export default function DashboardPage() {
|
||||
const kpis = useDashboardKpis();
|
||||
const footfall = useFootfallReport({bucket: 'day'});
|
||||
const conversion = useConversionReport({bucket: 'day'});
|
||||
// One fetch per report, shared by the KPI row and the charts.
|
||||
const footfall = useFootfallReport({bucket: 'day', compare: true});
|
||||
const conversion = useConversionReport({bucket: 'day', compare: true});
|
||||
const kpis = useDashboardKpis(footfall, conversion);
|
||||
const visits = useRecentVisits(6);
|
||||
// Pushes new arrivals into the recent-arrivals panel as they happen.
|
||||
useArrivalStream(
|
||||
visits.refetch,
|
||||
visits.isRefreshing ? undefined : visits.data?.cursor,
|
||||
);
|
||||
const scopeLabel = useScopeLabel();
|
||||
|
||||
return (
|
||||
@@ -119,10 +124,7 @@ export default function DashboardPage() {
|
||||
|
||||
<ArrivalsFeed resource={visits} viewAllHref="/activity" />
|
||||
|
||||
<FeatureUnavailable
|
||||
title="Customer activity and engagement"
|
||||
description="Selfies, spins, scratch cards, challenges, referrals and events are not being recorded by any till or app yet, so there is nothing to measure their effect on repeat visits or revenue. As soon as they are, this section fills in on its own — no number here is estimated."
|
||||
/>
|
||||
<EngagementSection />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,12 @@ import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {NameCustomerDialog} from '@/features/floor/components/NameCustomerDialog';
|
||||
import {SaleEntryDialog} from '@/features/commerce/components/SaleEntryDialog';
|
||||
import {useFloor} from '@/features/floor/hooks/useFloor';
|
||||
import {useFloor, type FloorAction} from '@/features/floor/hooks/useFloor';
|
||||
import {useTodaySummary} from '@/features/floor/hooks/useTodaySummary';
|
||||
import {TodaySummaryCard} from '@/features/floor/components/TodaySummaryCard';
|
||||
import {useScopeLabel} from '@/features/stores/hooks/useStoreDirectory';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {isStaffRole} from '@/features/auth/services/staffAccess';
|
||||
import type {FloorVisit} from '@/features/floor/types/floor';
|
||||
|
||||
/**
|
||||
@@ -35,7 +39,13 @@ function whenSeen(iso: string): string {
|
||||
}
|
||||
|
||||
export default function FloorPage() {
|
||||
const {resource, act, pending, conflict} = useFloor();
|
||||
const {resource, act, pending, conflict, failure} = useFloor();
|
||||
// Today's summary carries revenue — merchant-only (staffAccess.ts).
|
||||
const isStaff = isStaffRole(useSession().user?.role);
|
||||
const today = useTodaySummary(!isStaff);
|
||||
// Taking, releasing or completing a customer changes "on the floor now".
|
||||
const run = (visitId: string, action: FloorAction) =>
|
||||
void act(visitId, action).then(today.refetch);
|
||||
const scopeLabel = useScopeLabel();
|
||||
const [naming, setNaming] = useState<FloorVisit | null>(null);
|
||||
const [selling, setSelling] = useState<FloorVisit | null>(null);
|
||||
@@ -49,9 +59,12 @@ export default function FloorPage() {
|
||||
controls={<ScopeControls />}
|
||||
/>
|
||||
|
||||
{isStaff ? null : <TodaySummaryCard resource={today} />}
|
||||
|
||||
{/* The platform's own refusal, shown verbatim — it names who holds the
|
||||
customer, which is the part staff need. */}
|
||||
{conflict ? <Banner status="warning" title={conflict.message} /> : null}
|
||||
{failure ? <Banner status="error" title={failure.message} /> : null}
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
@@ -120,12 +133,12 @@ export default function FloorPage() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={pending === v.visitId}
|
||||
onClick={() => void act(v.visitId, 'release')}
|
||||
onClick={() => run(v.visitId, 'release')}
|
||||
label="Release"
|
||||
/>
|
||||
<Button
|
||||
isDisabled={pending === v.visitId}
|
||||
onClick={() => void act(v.visitId, 'complete')}
|
||||
onClick={() => run(v.visitId, 'complete')}
|
||||
label="Complete"
|
||||
/>
|
||||
</>
|
||||
@@ -136,7 +149,7 @@ export default function FloorPage() {
|
||||
// which is more useful than a control that vanishes.
|
||||
variant={heldByOther ? 'secondary' : 'primary'}
|
||||
isDisabled={pending === v.visitId}
|
||||
onClick={() => void act(v.visitId, 'attend')}
|
||||
onClick={() => run(v.visitId, 'attend')}
|
||||
label={heldByOther ? 'Taken' : 'Take'}
|
||||
/>
|
||||
)}
|
||||
@@ -178,6 +191,7 @@ export default function FloorPage() {
|
||||
onSaved={() => {
|
||||
setSelling(null);
|
||||
resource.refetch();
|
||||
today.refetch();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {useBreakpoint} from '@/shared/hooks/useBreakpoint';
|
||||
import {SETTINGS_NAV, isSettingsActive} from '@/features/settings/config/settingsNav';
|
||||
import {useRoleFilter} from '@/shared/layouts/workspace/useRoleNav';
|
||||
|
||||
/**
|
||||
* Settings gets its own sub-navigation.
|
||||
@@ -36,13 +37,14 @@ export default function SettingsLayout({
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const settingsNav = useRoleFilter(SETTINGS_NAV);
|
||||
const bp = useBreakpoint();
|
||||
const isNarrow = bp === 'mobile' || bp === 'tablet';
|
||||
|
||||
if (isNarrow) {
|
||||
const active =
|
||||
SETTINGS_NAV.find((s) => isSettingsActive(pathname, s.href)) ??
|
||||
SETTINGS_NAV[0];
|
||||
settingsNav.find((s) => isSettingsActive(pathname, s.href)) ??
|
||||
settingsNav[0];
|
||||
|
||||
// A dropdown, not a TabList.
|
||||
//
|
||||
@@ -64,7 +66,7 @@ export default function SettingsLayout({
|
||||
icon: <Icon icon={active.icon} size="sm" />,
|
||||
}}
|
||||
menuWidth={260}
|
||||
items={SETTINGS_NAV.map((s) => ({
|
||||
items={settingsNav.map((s) => ({
|
||||
label: s.label,
|
||||
icon: s.icon,
|
||||
onClick: () => router.push(s.href),
|
||||
@@ -98,7 +100,7 @@ export default function SettingsLayout({
|
||||
*/}
|
||||
<SideNav className="w-full">
|
||||
<SideNavSection title="Settings">
|
||||
{SETTINGS_NAV.map((s) => (
|
||||
{settingsNav.map((s) => (
|
||||
<SideNavItem
|
||||
key={s.href}
|
||||
label={s.label}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function StoreSettingsPage() {
|
||||
return (
|
||||
<SettingsPage
|
||||
title="Store Locations"
|
||||
description="Branch operations, outlet directory, operating schedules and manager assignments."
|
||||
description="Open, rename and remove the shops in your company."
|
||||
>
|
||||
<StoreManagement />
|
||||
</SettingsPage>
|
||||
|
||||
Reference in New Issue
Block a user