first commit
This commit is contained in:
78
src/features/copilot/CopilotPanel.tsx
Normal file
78
src/features/copilot/CopilotPanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import {TabList, Tab} from '@astryxdesign/core/TabList';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Heading} from '@astryxdesign/core/Text';
|
||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import {useCopilot} from './CopilotProvider';
|
||||
import type {CopilotTab} from './CopilotProvider';
|
||||
import {AiTab} from './tabs/AiTab';
|
||||
import {AnalyticsTab} from './tabs/AnalyticsTab';
|
||||
import {ChatTab} from './tabs/ChatTab';
|
||||
|
||||
/**
|
||||
* The Copilot body. Rendered identically inside the inline LayoutPanel and
|
||||
* inside the slide-over — presentation differs, this does not. All of its
|
||||
* state comes from CopilotProvider, so it is safe to unmount and remount.
|
||||
*/
|
||||
export function CopilotPanel({onDismiss}: {onDismiss?: () => void}) {
|
||||
const {tab, setTab} = useCopilot();
|
||||
|
||||
return (
|
||||
<VStack gap={0} height="100%">
|
||||
<HStack
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
paddingInline={4}
|
||||
paddingBlock={3}
|
||||
>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Icon icon={ICONS.ai} size="sm" />
|
||||
<Heading level={2}>Copilot</Heading>
|
||||
</HStack>
|
||||
{onDismiss ? (
|
||||
<IconButton
|
||||
icon={<Icon icon={ICONS.panelClose} />}
|
||||
label="Hide Copilot"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
/>
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
<TabList
|
||||
value={tab}
|
||||
onChange={(v) => setTab(v as CopilotTab)}
|
||||
layout="fill"
|
||||
size="sm"
|
||||
hasDivider
|
||||
>
|
||||
<Tab value="ai" label="AI" icon={<Icon icon={ICONS.ai} size="sm" />} />
|
||||
<Tab
|
||||
value="analytics"
|
||||
label="Analytics"
|
||||
icon={<Icon icon={ICONS.analytics} size="sm" />}
|
||||
/>
|
||||
<Tab
|
||||
value="chat"
|
||||
label="Chat"
|
||||
icon={<Icon icon={ICONS.chat} size="sm" />}
|
||||
/>
|
||||
</TabList>
|
||||
|
||||
{/* The chat tab owns its own scroll container (ChatLayout docks the
|
||||
composer), so it must not be wrapped in a scrolling VStack. */}
|
||||
{tab === 'chat' ? (
|
||||
<ChatTab />
|
||||
) : (
|
||||
<VStack height="100%" isScrollable>
|
||||
{tab === 'ai' ? <AiTab /> : <AnalyticsTab />}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
102
src/features/copilot/CopilotProvider.tsx
Normal file
102
src/features/copilot/CopilotProvider.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import {createContext, useCallback, useContext, useMemo, useState} from 'react';
|
||||
|
||||
/**
|
||||
* All Copilot state lives here, and this provider is mounted in
|
||||
* app/providers.tsx — ABOVE the route tree.
|
||||
*
|
||||
* Two things force that placement:
|
||||
* 1. Route changes. The panel itself sits in (workspace)/layout.tsx, which the
|
||||
* App Router preserves across sibling navigations, so it would survive
|
||||
* /dashboard → /staff on its own.
|
||||
* 2. The tablet breakpoint. Crossing it swaps the inline LayoutPanel for a
|
||||
* slide-over, which genuinely unmounts the panel. Holding state up here
|
||||
* means presentation can change without losing a half-typed message.
|
||||
*
|
||||
* The panel is a pure view over this context. Nothing below it owns state.
|
||||
*/
|
||||
|
||||
export type CopilotTab = 'ai' | 'analytics' | 'chat';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
sender: 'user' | 'assistant';
|
||||
text: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
interface CopilotValue {
|
||||
tab: CopilotTab;
|
||||
setTab: (t: CopilotTab) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (v: boolean) => void;
|
||||
toggle: () => void;
|
||||
/** Slide-over visibility, used below the laptop breakpoint. */
|
||||
isSlideOverOpen: boolean;
|
||||
setSlideOverOpen: (v: boolean) => void;
|
||||
messages: ChatMessage[];
|
||||
/** The in-progress composer value. Kept here so navigation never drops it. */
|
||||
draft: string;
|
||||
setDraft: (v: string) => void;
|
||||
send: (text: string) => void;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
const CopilotContext = createContext<CopilotValue | null>(null);
|
||||
|
||||
export function CopilotProvider({children}: {children: React.ReactNode}) {
|
||||
const [tab, setTab] = useState<CopilotTab>('ai');
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [isSlideOverOpen, setSlideOverOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [isStreaming] = useState(false);
|
||||
|
||||
const send = useCallback((text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `m${prev.length + 1}`,
|
||||
sender: 'user',
|
||||
text: trimmed,
|
||||
// Timestamps are assigned on send (a user event), never during render —
|
||||
// Date.now() in a render path is a hydration mismatch waiting to happen.
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
setDraft('');
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => setIsOpen((v) => !v), []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
tab,
|
||||
setTab,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
toggle,
|
||||
isSlideOverOpen,
|
||||
setSlideOverOpen,
|
||||
messages,
|
||||
draft,
|
||||
setDraft,
|
||||
send,
|
||||
isStreaming,
|
||||
}),
|
||||
[tab, isOpen, isSlideOverOpen, messages, draft, send, toggle, isStreaming],
|
||||
);
|
||||
|
||||
return <CopilotContext value={value}>{children}</CopilotContext>;
|
||||
}
|
||||
|
||||
export function useCopilot(): CopilotValue {
|
||||
const ctx = useContext(CopilotContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useCopilot must be used inside <CopilotProvider>');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
37
src/features/copilot/CopilotSlideOver.tsx
Normal file
37
src/features/copilot/CopilotSlideOver.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import {MobileNav} from '@astryxdesign/core/MobileNav';
|
||||
import {useBreakpoint} from '@/lib/breakpoints';
|
||||
import {useCopilot} from './CopilotProvider';
|
||||
import {CopilotPanel} from './CopilotPanel';
|
||||
|
||||
/**
|
||||
* Tablet and mobile presentation of the Copilot.
|
||||
*
|
||||
* MobileNav rather than Dialog: a Dialog is content-sized (height: fit-content,
|
||||
* maxHeight 480px) and ignores top/bottom insets, so it renders as a floating
|
||||
* card rather than a drawer. MobileNav is the edge-anchored, full-height
|
||||
* primitive this actually needs.
|
||||
*
|
||||
* It renders the SAME <CopilotPanel /> as the inline path — crossing the
|
||||
* breakpoint changes where the panel lives, never what it contains or
|
||||
* remembers, because all state sits in CopilotProvider above the route tree.
|
||||
*/
|
||||
export function CopilotSlideOver() {
|
||||
const bp = useBreakpoint();
|
||||
const {isSlideOverOpen, setSlideOverOpen} = useCopilot();
|
||||
|
||||
return (
|
||||
<MobileNav
|
||||
isOpen={isSlideOverOpen}
|
||||
onOpenChange={setSlideOverOpen}
|
||||
side="end"
|
||||
// Full-bleed on mobile, where a 380px drawer would leave nothing
|
||||
// behind it worth looking at.
|
||||
width={bp === 'mobile' ? undefined : 380}
|
||||
label="AI Copilot"
|
||||
>
|
||||
<CopilotPanel onDismiss={() => setSlideOverOpen(false)} />
|
||||
</MobileNav>
|
||||
);
|
||||
}
|
||||
30
src/features/copilot/CopilotToggle.tsx
Normal file
30
src/features/copilot/CopilotToggle.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import {IconButton} from '@astryxdesign/core/IconButton';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
|
||||
import {useCopilot} from './CopilotProvider';
|
||||
|
||||
/**
|
||||
* One control, two behaviours: above the laptop breakpoint it collapses the
|
||||
* inline panel to give the workspace full width; below it, it opens the
|
||||
* slide-over. Both read and write the same provider, so the Copilot's contents
|
||||
* never notice which one is in play.
|
||||
*/
|
||||
export function CopilotToggle() {
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen, toggle, isSlideOverOpen, setSlideOverOpen} = useCopilot();
|
||||
const inline = isPanelInline(bp);
|
||||
|
||||
const isShown = inline ? isOpen : isSlideOverOpen;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<Icon icon={isShown ? ICONS.panelClose : ICONS.panelOpen} />}
|
||||
label={isShown ? 'Hide Copilot' : 'Show Copilot'}
|
||||
variant="ghost"
|
||||
onClick={() => (inline ? toggle() : setSlideOverOpen(!isSlideOverOpen))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
77
src/features/copilot/tabs/AiTab.tsx
Normal file
77
src/features/copilot/tabs/AiTab.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
|
||||
/**
|
||||
* Phase 1 renders the panel's shape with representative content so the layout
|
||||
* can be judged. The insight list becomes `useResource(endpoints['copilot.insights'])`
|
||||
* in Phase 2 — the markup below is already the success branch of that.
|
||||
*/
|
||||
export function AiTab() {
|
||||
return (
|
||||
<VStack gap={4} padding={4}>
|
||||
<Card variant="muted">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Icon icon={ICONS.ai} size="sm" />
|
||||
<Heading level={3}>Daily summary</Heading>
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
Footfall is tracking 8% above last Tuesday across all five stores.
|
||||
LYT redemption is concentrated in Indiranagar, which is running at
|
||||
2.3× the network average.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
|
||||
<VStack gap={2}>
|
||||
<Text size="sm" color="secondary">
|
||||
Needs attention
|
||||
</Text>
|
||||
<Banner
|
||||
status="warning"
|
||||
title="Sales dropped 12% at Whitefield"
|
||||
description="Down against the trailing 4-week Tuesday average."
|
||||
/>
|
||||
<Banner
|
||||
status="error"
|
||||
title="3 rewards expire in 48 hours"
|
||||
description="Free Coffee, Combo 20%, Weekend Bonus."
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<VStack gap={2}>
|
||||
<Text size="sm" color="secondary">
|
||||
Recommendations
|
||||
</Text>
|
||||
<Card>
|
||||
<VStack gap={1.5}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="medium">Extend Weekend Bonus</Text>
|
||||
<Badge variant="success" label="High confidence" />
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">
|
||||
It converts at 34% — the best of any active reward — and expires
|
||||
Friday.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
<Card>
|
||||
<VStack gap={1.5}>
|
||||
<Text weight="medium">Add evening cover at Koramangala</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Peak footfall starts at 18:30 but only two staff are rostered
|
||||
after 18:00.
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
25
src/features/copilot/tabs/AnalyticsTab.tsx
Normal file
25
src/features/copilot/tabs/AnalyticsTab.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
|
||||
/**
|
||||
* Placeholder until the chart primitives land in 1.7. It then hosts revenue
|
||||
* forecast, customer growth and store/reward comparison at height={180} —
|
||||
* the narrow-column variant of the same ChartCard used in the workspace.
|
||||
*/
|
||||
export function AnalyticsTab() {
|
||||
return (
|
||||
<VStack gap={4} padding={4}>
|
||||
<Text size="sm" color="secondary">
|
||||
Forecasts and comparisons, scoped to the current store and range.
|
||||
</Text>
|
||||
<EmptyPanel
|
||||
icon="analytics"
|
||||
title="Charts arrive with the analytics layer"
|
||||
description="Revenue forecast, customer growth, store and reward comparison."
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
113
src/features/copilot/tabs/ChatTab.tsx
Normal file
113
src/features/copilot/tabs/ChatTab.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ChatComposer,
|
||||
ChatComposerInput,
|
||||
ChatDictationButton,
|
||||
ChatLayout,
|
||||
ChatMessage,
|
||||
ChatMessageBubble,
|
||||
ChatMessageList,
|
||||
ChatMessageMetadata,
|
||||
useChatDictation,
|
||||
} from '@astryxdesign/core/Chat';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {useCopilot} from '../CopilotProvider';
|
||||
|
||||
/**
|
||||
* Suggested prompts double as the empty state — an empty AI panel with a bare
|
||||
* input tells a merchant nothing about what it can answer.
|
||||
*/
|
||||
const SUGGESTED = [
|
||||
"Generate today's report",
|
||||
'Compare stores',
|
||||
'Suggest rewards',
|
||||
'Analyze sales',
|
||||
'Show best staff',
|
||||
'Predict weekend traffic',
|
||||
];
|
||||
|
||||
function SuggestedPrompts({onPick}: {onPick: (p: string) => void}) {
|
||||
return (
|
||||
<VStack gap={2} padding={4}>
|
||||
<Text size="sm" color="secondary">
|
||||
Ask about footfall, LYT redemption, staff or store performance.
|
||||
</Text>
|
||||
<VStack gap={1.5}>
|
||||
{SUGGESTED.map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label={p}
|
||||
onClick={() => onPick(p)}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatTab() {
|
||||
const {messages, draft, setDraft, send, isStreaming} = useCopilot();
|
||||
|
||||
// Appends recognised speech to whatever is already typed rather than
|
||||
// replacing it, so dictation composes with the keyboard.
|
||||
const dictation = useChatDictation({
|
||||
onResult: (text: string) => setDraft(draft ? `${draft} ${text}` : text),
|
||||
});
|
||||
|
||||
return (
|
||||
<ChatLayout
|
||||
density="compact"
|
||||
emptyState={<SuggestedPrompts onPick={send} />}
|
||||
composer={
|
||||
<ChatComposer
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
onSubmit={send}
|
||||
placeholder="Ask about footfall, LYTs, staff…"
|
||||
density="compact"
|
||||
elevation="none"
|
||||
isStopShown={isStreaming}
|
||||
input={<ChatComposerInput maxRows={6} />}
|
||||
// Renders nothing when SpeechRecognition is unsupported
|
||||
// (isHiddenWhenUnsupported defaults true) — progressive, no polyfill.
|
||||
sendActions={<ChatDictationButton dictation={dictation} size="sm" />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{messages.length > 0 ? (
|
||||
<ChatMessageList isStreaming={isStreaming}>
|
||||
{messages.map((m) => (
|
||||
<ChatMessage
|
||||
key={m.id}
|
||||
sender={m.sender}
|
||||
density="compact"
|
||||
avatar={
|
||||
m.sender === 'assistant' ? (
|
||||
<Avatar name="Loyaly AI" size="xsm" tooltip={false} />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<ChatMessageBubble
|
||||
variant={m.sender === 'assistant' ? 'ghost' : 'filled'}
|
||||
metadata={
|
||||
<ChatMessageMetadata
|
||||
timestamp={<Timestamp value={m.at} />}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{m.text}
|
||||
</ChatMessageBubble>
|
||||
</ChatMessage>
|
||||
))}
|
||||
</ChatMessageList>
|
||||
) : null}
|
||||
</ChatLayout>
|
||||
);
|
||||
}
|
||||
58
src/features/dashboard/ActivityTimeline.tsx
Normal file
58
src/features/dashboard/ActivityTimeline.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
||||
import {ActivityFeed} from '@/components/patterns/ActivityItem';
|
||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import type {ActivityEntry} from '@/components/patterns/ActivityItem';
|
||||
import type {ActivityEvent, ActivityKind} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
|
||||
|
||||
/**
|
||||
* Only one activity kind carries semantic colour. An expiry is the single
|
||||
* event in this feed a merchant may need to act on; redemptions, purchases,
|
||||
* check-ins and openings are the shop working normally, and colouring them
|
||||
* would make the one thing that matters harder to find.
|
||||
*/
|
||||
const KIND: Record<ActivityKind, {tone: StatusDotVariant; toneLabel: string}> = {
|
||||
reward_redeemed: {tone: 'neutral', toneLabel: 'Redemption'},
|
||||
purchase: {tone: 'neutral', toneLabel: 'Purchase'},
|
||||
staff_checked_in: {tone: 'neutral', toneLabel: 'Attendance'},
|
||||
store_opened: {tone: 'neutral', toneLabel: 'Store'},
|
||||
reward_expired: {tone: 'warning', toneLabel: 'Expiry'},
|
||||
};
|
||||
|
||||
function toEntries(events: ActivityEvent[]): ActivityEntry[] {
|
||||
return events.map((e) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
detail: e.detail,
|
||||
at: e.at,
|
||||
...KIND[e.kind],
|
||||
}));
|
||||
}
|
||||
|
||||
export function ActivityTimeline({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<ActivityEvent[]>;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title="Recent activity"
|
||||
subtitle="Live feed across the selected store and period"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={5} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="alert"
|
||||
title="No activity yet"
|
||||
description="Events appear here as customers redeem rewards and staff check in."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(events) => <ActivityFeed entries={toEntries(events)} />}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
68
src/features/dashboard/KpiRow.tsx
Normal file
68
src/features/dashboard/KpiRow.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {MetricCard} from '@/components/patterns/MetricCard';
|
||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import {useBreakpoint, isPanelInline} from '@/lib/breakpoints';
|
||||
import {useCopilot} from '@/features/copilot/CopilotProvider';
|
||||
import {formatCompact, formatInrCompact} from '@/lib/format';
|
||||
import type {Kpi, KpiId} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
|
||||
const KPI_ICON: Record<KpiId, IconType> = {
|
||||
visitors: ICONS.visitors,
|
||||
purchases: ICONS.purchases,
|
||||
revenue: ICONS.revenue,
|
||||
activeRewards: ICONS.activeRewards,
|
||||
};
|
||||
|
||||
/**
|
||||
* Four KPIs must lay out 4-up, 2×2, or stacked — never 3+1, which reads as a
|
||||
* broken grid. `repeat: 'fit'` cannot express that: at a ~800px workspace it
|
||||
* yields exactly three columns whatever minWidth you choose, and the fourth
|
||||
* card drops to an orphan row.
|
||||
*
|
||||
* So the count is computed rather than inferred. The two things that decide
|
||||
* the available width are the breakpoint and whether the Copilot panel is
|
||||
* taking 320–380px of it, and both are already known here.
|
||||
*/
|
||||
export function useMetricColumns(): number {
|
||||
const bp = useBreakpoint();
|
||||
const {isOpen} = useCopilot();
|
||||
|
||||
if (bp === 'mobile') return 1;
|
||||
if (bp === 'tablet') return 2;
|
||||
if (bp === 'laptop') return 2;
|
||||
return isPanelInline(bp) && isOpen ? 2 : 4;
|
||||
}
|
||||
|
||||
export function KpiRow({resource}: {resource: Resource<Kpi[]>}) {
|
||||
const columns = useMetricColumns();
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(kpis) => (
|
||||
<Grid columns={columns} gap={4}>
|
||||
{kpis.map((k) => (
|
||||
<MetricCard
|
||||
key={k.id}
|
||||
label={k.label}
|
||||
value={k.value}
|
||||
format={k.unit === 'inr' ? formatInrCompact : formatCompact}
|
||||
icon={KPI_ICON[k.id]}
|
||||
deltaPct={k.deltaPct}
|
||||
isRiseGood={k.isRiseGood}
|
||||
trend={k.trend}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
66
src/features/dashboard/PerformancePanel.tsx
Normal file
66
src/features/dashboard/PerformancePanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {useResource} from '@/lib/api/useResource';
|
||||
import {endpoints} from '@/lib/api/client';
|
||||
import type {Scope} from '@/lib/api/client';
|
||||
import type {Granularity, PeriodPoint} from '@/lib/api/contracts';
|
||||
import {formatInrCompact} from '@/lib/format';
|
||||
|
||||
/**
|
||||
* Weekly / monthly rollup.
|
||||
*
|
||||
* The granularity is part of the request rather than a client-side regroup:
|
||||
* the buckets have to come from the same generator the daily series does, or
|
||||
* the weekly totals quietly stop matching the daily chart above them.
|
||||
*/
|
||||
export function PerformancePanel({
|
||||
scope,
|
||||
granularity,
|
||||
onGranularityChange,
|
||||
}: {
|
||||
scope: Scope;
|
||||
granularity: Granularity;
|
||||
onGranularityChange: (g: Granularity) => void;
|
||||
}) {
|
||||
const resource = useResource<PeriodPoint[]>(
|
||||
endpoints.dashboardPerformance(scope, granularity),
|
||||
);
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title="Performance"
|
||||
subtitle={
|
||||
granularity === 'weekly'
|
||||
? 'Revenue by ISO week'
|
||||
: 'Revenue by calendar month'
|
||||
}
|
||||
resource={resource}
|
||||
actions={
|
||||
<SegmentedControl
|
||||
value={granularity}
|
||||
onChange={(v) => onGranularityChange(v as Granularity)}
|
||||
label="Performance granularity"
|
||||
size="sm"
|
||||
>
|
||||
<SegmentedControlItem value="weekly" label="Weekly" />
|
||||
<SegmentedControlItem value="monthly" label="Monthly" />
|
||||
</SegmentedControl>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={rows}
|
||||
xKey="label"
|
||||
yFormat={formatInrCompact}
|
||||
series={[{key: 'revenue', label: 'Revenue'}]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
41
src/features/dashboard/RewardUsageChart.tsx
Normal file
41
src/features/dashboard/RewardUsageChart.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {formatCompact} from '@/lib/format';
|
||||
import type {RewardUsagePoint} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
/**
|
||||
* Claimed vs used, per reward.
|
||||
*
|
||||
* The gap between the two bars is the whole point: a reward claimed 700 times
|
||||
* and redeemed 130 is an outstanding liability and a broken offer, not a
|
||||
* success. Plotting the usage *rate* alone would hide the volume; plotting
|
||||
* volume alone would hide the failure. So both bars, side by side.
|
||||
*/
|
||||
export function RewardUsageChart({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<RewardUsagePoint[]>;
|
||||
}) {
|
||||
return (
|
||||
<ChartCard
|
||||
title="Reward usage"
|
||||
subtitle="Claimed vs actually redeemed — the gap is unspent liability"
|
||||
resource={resource}
|
||||
>
|
||||
{(rows) => (
|
||||
<BarChartView
|
||||
data={rows}
|
||||
xKey="name"
|
||||
yFormat={formatCompact}
|
||||
series={[
|
||||
{key: 'claimed', label: 'Claimed'},
|
||||
{key: 'used', label: 'Redeemed'},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
78
src/features/dashboard/StoreComparison.tsx
Normal file
78
src/features/dashboard/StoreComparison.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Sparkline} from '@/components/charts/Sparkline';
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
||||
import {formatCompact, formatInrCompact, formatPct} from '@/lib/format';
|
||||
import type {StoreComparison as StoreComparisonRow} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
/**
|
||||
* Small multiples, not a five-series chart.
|
||||
*
|
||||
* This is the palette constraint doing its job: five stores on one axis would
|
||||
* need five colours, and the monochrome ramp only separates about three. One
|
||||
* card per store is also simply better for the question being asked — "which
|
||||
* store is the outlier?" is a comparison of shapes, not of overlapping lines.
|
||||
*/
|
||||
export function StoreComparisonPanel({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<StoreComparisonRow[]>;
|
||||
}) {
|
||||
return (
|
||||
<ChartCard
|
||||
title="Store comparison"
|
||||
subtitle="One card per store — five series on one axis would need five colours"
|
||||
resource={resource}
|
||||
height={220}
|
||||
>
|
||||
{(rows) => {
|
||||
const best = rows.reduce((a, b) => (b.visitors > a.visitors ? b : a));
|
||||
const worst = rows.reduce((a, b) => (b.visitors < a.visitors ? b : a));
|
||||
|
||||
return (
|
||||
<Grid columns={{minWidth: 200, repeat: 'fit'}} gap={3}>
|
||||
{rows.map((r) => (
|
||||
<Card key={r.storeId} variant="muted">
|
||||
<VStack gap={2}>
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<Text size="sm" weight="medium">
|
||||
{r.name}
|
||||
</Text>
|
||||
{r.storeId === best.storeId ? (
|
||||
<Badge variant="success" label="Top" />
|
||||
) : r.storeId === worst.storeId ? (
|
||||
<Badge variant="warning" label="Lowest" />
|
||||
) : null}
|
||||
</HStack>
|
||||
|
||||
{/* A visitor count is data, not a section title. */}
|
||||
<Text type="display-3">{formatCompact(r.visitors)}</Text>
|
||||
<Sparkline data={r.trend} dataKey="v" height={34} />
|
||||
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Revenue"
|
||||
value={formatInrCompact(r.revenueInr)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Conversion"
|
||||
value={formatPct(r.conversionPct)}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
</VStack>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
}}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
85
src/features/lyts/ExpiryAlerts.tsx
Normal file
85
src/features/lyts/ExpiryAlerts.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {formatCompact, formatLyt} from '@/lib/format';
|
||||
import {daysUntil} from './reward-status';
|
||||
import type {Reward} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
/**
|
||||
* Rewards lapsing within a fortnight.
|
||||
*
|
||||
* This panel exists because expiry is the one thing on this page with a
|
||||
* deadline. Everything else can be read whenever; an offer expiring in two
|
||||
* days either gets extended today or it doesn't.
|
||||
*
|
||||
* Sorted soonest-first, and the description leads with outstanding liability
|
||||
* rather than claim count — the actionable number is what customers are still
|
||||
* holding, not how popular the offer was.
|
||||
*/
|
||||
const HORIZON_DAYS = 14;
|
||||
|
||||
export function ExpiryAlerts({
|
||||
resource,
|
||||
nowMs,
|
||||
}: {
|
||||
resource: Resource<Reward[]>;
|
||||
nowMs: number;
|
||||
}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title="Expiry alerts"
|
||||
subtitle={`Rewards lapsing in the next ${HORIZON_DAYS} days`}
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={2} height={64} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="expiry"
|
||||
title="Nothing expiring"
|
||||
description="No active rewards lapse in the next two weeks."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rewards) => {
|
||||
const soon = rewards
|
||||
.map((r) => ({r, days: daysUntil(r.expiresAt, nowMs)}))
|
||||
.filter(
|
||||
(x): x is {r: Reward; days: number} =>
|
||||
x.days !== null && x.days >= 0 && x.days <= HORIZON_DAYS,
|
||||
)
|
||||
.sort((a, b) => a.days - b.days);
|
||||
|
||||
if (soon.length === 0) {
|
||||
return (
|
||||
<EmptyPanel
|
||||
icon="expiry"
|
||||
title="Nothing expiring"
|
||||
description="No active rewards lapse in the next two weeks."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={2}>
|
||||
{soon.map(({r, days}) => {
|
||||
const outstanding = r.claimed - r.used;
|
||||
return (
|
||||
<Banner
|
||||
key={r.id}
|
||||
// Under 3 days is an emergency; the rest is a heads-up.
|
||||
status={days <= 3 ? 'error' : 'warning'}
|
||||
title={`${r.name} expires ${days === 0 ? 'today' : days === 1 ? 'tomorrow' : `in ${days} days`}`}
|
||||
description={`${formatCompact(outstanding)} unredeemed claims · ${formatLyt(outstanding * r.costLyt)} outstanding`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
90
src/features/lyts/RewardCard.tsx
Normal file
90
src/features/lyts/RewardCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {SectionHeader} from '@/components/patterns/SectionHeader';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {HoverLift} from '@/components/motion/HoverLift';
|
||||
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import {formatCompact, formatLyt, formatPct} from '@/lib/format';
|
||||
import {REWARD_STATUS, daysUntil, usageRate} from './reward-status';
|
||||
import type {Reward} from '@/lib/api/contracts';
|
||||
|
||||
/**
|
||||
* One reward.
|
||||
*
|
||||
* The progress bar is claimed-vs-redeemed, and it is the point of the card:
|
||||
* the unfilled portion is outstanding liability — LYTs a customer holds and
|
||||
* can spend at any time. A reward claimed 800 times and redeemed 150 is not a
|
||||
* popular offer, it is ₹78,000 of unbudgeted exposure.
|
||||
*/
|
||||
export function RewardCard({reward, nowMs}: {reward: Reward; nowMs: number}) {
|
||||
const status = REWARD_STATUS[reward.status];
|
||||
const rate = usageRate(reward.claimed, reward.used);
|
||||
const days = daysUntil(reward.expiresAt, nowMs);
|
||||
const outstanding = reward.claimed - reward.used;
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={4} height="100%">
|
||||
<SectionHeader
|
||||
level={3}
|
||||
title={reward.name}
|
||||
subtitle={
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<Icon icon={ICONS.lyt} size="xsm" color="secondary" />
|
||||
<Text size="sm" color="secondary">
|
||||
{formatLyt(reward.costLyt)}
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
actions={<Badge variant={status.variant} label={status.label} />}
|
||||
/>
|
||||
|
||||
<VStack gap={1.5}>
|
||||
<ProgressBar
|
||||
value={rate}
|
||||
max={100}
|
||||
label={`Redeemed ${formatPct(rate, 0)} of claims`}
|
||||
hasValueLabel={false}
|
||||
/>
|
||||
<HStack hAlign="between">
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatCompact(reward.used)} redeemed
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatCompact(reward.claimed)} claimed
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Outstanding"
|
||||
value={formatLyt(outstanding * reward.costLyt)}
|
||||
/>
|
||||
<StatPair
|
||||
label={days === null ? 'Expires' : days < 0 ? 'Expired' : 'Expires in'}
|
||||
value={
|
||||
days === null
|
||||
? 'Never'
|
||||
: days < 0
|
||||
? `${Math.abs(days)}d ago`
|
||||
: `${days}d`
|
||||
}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
105
src/features/lyts/RewardGrid.tsx
Normal file
105
src/features/lyts/RewardGrid.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
|
||||
import {RewardCard} from './RewardCard';
|
||||
import type {Reward, RewardStatus} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
type Filter = 'all' | RewardStatus;
|
||||
|
||||
const REWARD_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
|
||||
|
||||
export function RewardGrid({
|
||||
resource,
|
||||
nowMs,
|
||||
}: {
|
||||
resource: Resource<Reward[]>;
|
||||
nowMs: number;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// Memoised on resource.data: `?? []` allocates a fresh array every render,
|
||||
// which would make it an unstable dependency and recompute both memos below
|
||||
// on every keystroke.
|
||||
const rewards = useMemo(() => resource.data ?? [], [resource.data]);
|
||||
|
||||
// Counts come from the unfiltered set so the chips still read "Expiring (2)"
|
||||
// while another filter is active.
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Filter, number> = {
|
||||
all: rewards.length,
|
||||
active: 0,
|
||||
paused: 0,
|
||||
expiring: 0,
|
||||
expired: 0,
|
||||
};
|
||||
for (const r of rewards) c[r.status] += 1;
|
||||
return c;
|
||||
}, [rewards]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return rewards.filter(
|
||||
(r) =>
|
||||
(filter === 'all' || r.status === filter) &&
|
||||
(q === '' || r.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [rewards, query, filter]);
|
||||
|
||||
return (
|
||||
<CollectionRegion label="Reward catalogue">
|
||||
<FilterBar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder="Search rewards…"
|
||||
filterValue={filter}
|
||||
onFilterChange={(v) => setFilter(v as Filter)}
|
||||
filterLabel="Filter rewards by status"
|
||||
options={[
|
||||
{value: 'all', label: 'All', hint: counts.all},
|
||||
{value: 'active', label: 'Active', hint: counts.active},
|
||||
{value: 'expiring', label: 'Expiring', hint: counts.expiring},
|
||||
{value: 'paused', label: 'Paused', hint: counts.paused},
|
||||
{value: 'expired', label: 'Expired', hint: counts.expired},
|
||||
]}
|
||||
resultCount={visible.length}
|
||||
resultNoun={visible.length === 1 ? 'reward' : 'rewards'}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={6} height={230} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="lyts"
|
||||
title="No rewards yet"
|
||||
description="Create a reward to let customers spend their LYTs in store."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
visible.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="lyts"
|
||||
title="No rewards match"
|
||||
description="Try a different status filter or clear the search."
|
||||
/>
|
||||
) : (
|
||||
<Grid columns={REWARD_GRID_COLUMNS} gap={4}>
|
||||
{visible.map((r) => (
|
||||
<RewardCard key={r.id} reward={r} nowMs={nowMs} />
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
</CollectionRegion>
|
||||
);
|
||||
}
|
||||
143
src/features/lyts/RewardPerformanceTable.tsx
Normal file
143
src/features/lyts/RewardPerformanceTable.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {HStack} from '@astryxdesign/core/Layout';
|
||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {MetricDelta} from '@/components/primitives/MetricDelta';
|
||||
import {formatCompact, formatLyt} from '@/lib/format';
|
||||
import {REWARD_STATUS, usageRate} from './reward-status';
|
||||
import type {Reward} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
interface Row extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
status: Reward['status'];
|
||||
costLyt: number;
|
||||
claimed: number;
|
||||
used: number;
|
||||
rate: number;
|
||||
outstanding: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole catalogue as rows.
|
||||
*
|
||||
* A table rather than more cards: this is the view for comparing eight
|
||||
* rewards on the same four numbers, and cards force the eye to travel in two
|
||||
* dimensions to do a one-dimensional comparison. Sorted by redemption rate so
|
||||
* the offers that aren't working surface at the bottom without hunting.
|
||||
*/
|
||||
export function RewardPerformanceTable({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<Reward[]>;
|
||||
}) {
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Reward',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Badge
|
||||
variant={REWARD_STATUS[row.status].variant}
|
||||
label={REWARD_STATUS[row.status].label}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'costLyt',
|
||||
header: 'Cost',
|
||||
width: pixel(96),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatLyt(row.costLyt)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'claimed',
|
||||
header: 'Claimed',
|
||||
width: pixel(96),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatCompact(row.claimed)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'used',
|
||||
header: 'Redeemed',
|
||||
width: pixel(104),
|
||||
align: 'end',
|
||||
renderCell: (row) => <Text size="sm">{formatCompact(row.used)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'rate',
|
||||
header: 'Rate',
|
||||
width: pixel(104),
|
||||
align: 'end',
|
||||
// Measured against a 50% house benchmark, so the arrow says "better or
|
||||
// worse than a reward should do" rather than "up or down since when?".
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="end">
|
||||
<MetricDelta value={row.rate - 50} isRiseGood size="xsm" />
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'outstanding',
|
||||
header: 'Outstanding',
|
||||
width: pixel(128),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{formatLyt(row.outstanding)}</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Reward performance"
|
||||
subtitle="Sorted by redemption rate — the weakest offers sit at the bottom"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={6} height={32} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="lyts"
|
||||
title="No rewards to compare"
|
||||
description="Performance appears once rewards have been claimed."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rewards) => {
|
||||
const rows: Row[] = rewards
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
costLyt: r.costLyt,
|
||||
claimed: r.claimed,
|
||||
used: r.used,
|
||||
rate: usageRate(r.claimed, r.used),
|
||||
outstanding: (r.claimed - r.used) * r.costLyt,
|
||||
}))
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
|
||||
return (
|
||||
<Table
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
density="compact"
|
||||
hasHover
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
29
src/features/lyts/reward-status.ts
Normal file
29
src/features/lyts/reward-status.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {RewardStatus} from '@/lib/api/contracts';
|
||||
|
||||
/**
|
||||
* Reward status → badge treatment.
|
||||
*
|
||||
* Only `expiring` and `expired` carry colour. An active reward is the normal
|
||||
* case and a paused one is a deliberate choice by the merchant — neither
|
||||
* needs attention. Colouring all four would leave nothing standing out on a
|
||||
* page whose entire job is surfacing the ones about to lapse.
|
||||
*/
|
||||
export const REWARD_STATUS: Record<
|
||||
RewardStatus,
|
||||
{label: string; variant: 'neutral' | 'warning' | 'error'}
|
||||
> = {
|
||||
active: {label: 'Active', variant: 'neutral'},
|
||||
paused: {label: 'Paused', variant: 'neutral'},
|
||||
expiring: {label: 'Expiring', variant: 'warning'},
|
||||
expired: {label: 'Expired', variant: 'error'},
|
||||
};
|
||||
|
||||
/** Whole days until expiry; negative once lapsed, null if it never expires. */
|
||||
export function daysUntil(expiresAt: string | null, nowMs: number): number | null {
|
||||
if (!expiresAt) return null;
|
||||
return Math.ceil((new Date(expiresAt).getTime() - nowMs) / 86400000);
|
||||
}
|
||||
|
||||
export function usageRate(claimed: number, used: number): number {
|
||||
return claimed === 0 ? 0 : Number(((used / claimed) * 100).toFixed(1));
|
||||
}
|
||||
120
src/features/settings/NotificationsForm.tsx
Normal file
120
src/features/settings/NotificationsForm.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Switch} from '@astryxdesign/core/Switch';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/components/patterns/PanelCard';
|
||||
import type {MerchantProfile} from '@/lib/api/contracts';
|
||||
|
||||
type NotificationKey = keyof MerchantProfile['notifications'];
|
||||
|
||||
/**
|
||||
* Each toggle names the alert AND what triggers it. "Low conversion" alone is
|
||||
* a setting a merchant has to guess at; "when a store drops below 15% for two
|
||||
* days" is one they can decide about.
|
||||
*/
|
||||
const ALERTS: {
|
||||
key: NotificationKey;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
key: 'expiryAlerts',
|
||||
label: 'Reward expiry',
|
||||
description:
|
||||
'When an active reward is within 3 days of lapsing, with the outstanding LYT liability.',
|
||||
},
|
||||
{
|
||||
key: 'dailySummary',
|
||||
label: 'Daily summary',
|
||||
description:
|
||||
'Footfall, revenue and redemption for the previous day, each morning at 09:00.',
|
||||
},
|
||||
{
|
||||
key: 'staffAbsence',
|
||||
label: 'Staff absence',
|
||||
description:
|
||||
'When a store opens with fewer than half its rostered team present.',
|
||||
},
|
||||
{
|
||||
key: 'lowConversion',
|
||||
label: 'Low conversion',
|
||||
description:
|
||||
'When a store stays below 15% conversion for two consecutive days.',
|
||||
},
|
||||
];
|
||||
|
||||
export function NotificationsForm({
|
||||
initialData,
|
||||
}: {
|
||||
initialData: MerchantProfile['notifications'];
|
||||
}) {
|
||||
const toast = useToast();
|
||||
const [value, setValue] = useState(initialData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const isDirty = JSON.stringify(value) !== JSON.stringify(initialData);
|
||||
|
||||
async function save() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch('/api/settings/profile', {
|
||||
method: 'PATCH',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({notifications: value}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Save failed (${res.status})`);
|
||||
toast({body: 'Notification preferences saved'});
|
||||
} catch (err) {
|
||||
toast({type: 'error', body: (err as Error).message});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Alerts"
|
||||
subtitle="What Loyaly tells you about, and when."
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{isDirty ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Unsaved changes
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save"
|
||||
isDisabled={!isDirty}
|
||||
isLoading={isSaving}
|
||||
onClick={save}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
{ALERTS.map((a, i) => (
|
||||
<VStack key={a.key} gap={0}>
|
||||
<HStack paddingBlock={3}>
|
||||
<Switch
|
||||
label={a.label}
|
||||
description={a.description}
|
||||
value={value[a.key]}
|
||||
onChange={(checked) =>
|
||||
setValue((v) => ({...v, [a.key]: checked}))
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
{i === ALERTS.length - 1 ? null : <Divider />}
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
178
src/features/settings/ProfileForm.tsx
Normal file
178
src/features/settings/ProfileForm.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {FormLayout} from '@astryxdesign/core/FormLayout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/components/patterns/PanelCard';
|
||||
import {endpointUrl} from '@/lib/api/client';
|
||||
import type {MerchantProfile} from '@/lib/api/contracts';
|
||||
|
||||
/**
|
||||
* Merchant profile.
|
||||
*
|
||||
* `initialData` arrives from a Server Component that read the record
|
||||
* DIRECTLY — no HTTP round trip — so the form paints filled on first render
|
||||
* rather than flashing empty inputs. This is the hybrid path the data layer
|
||||
* was designed for: dashboards fetch on the client because their scope
|
||||
* changes constantly, but a settings form has exactly one server-known state
|
||||
* at page load and should use it.
|
||||
*
|
||||
* Status is only ever set on submit, never at initial paint — Astryx's
|
||||
* FieldStatus breaks SSR hydration when it is server-rendered with a status
|
||||
* already applied (see AGENTS.md).
|
||||
*/
|
||||
export function ProfileForm({initialData}: {initialData: MerchantProfile}) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(initialData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof MerchantProfile, string>>>({});
|
||||
|
||||
const set = <K extends keyof MerchantProfile>(
|
||||
key: K,
|
||||
value: MerchantProfile[K],
|
||||
) => setForm((f) => ({...f, [key]: value}));
|
||||
|
||||
const isDirty = JSON.stringify(form) !== JSON.stringify(initialData);
|
||||
|
||||
async function save() {
|
||||
// Validation runs on submit so the first paint is never a wall of red.
|
||||
const next: typeof errors = {};
|
||||
if (!form.businessName.trim()) next.businessName = 'Business name is required';
|
||||
if (!/^\S+@\S+\.\S+$/.test(form.email)) next.email = 'Enter a valid email address';
|
||||
if (form.gstin && form.gstin.length !== 15) {
|
||||
next.gstin = 'GSTIN is 15 characters';
|
||||
}
|
||||
setErrors(next);
|
||||
if (Object.keys(next).length > 0) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
endpointUrl({path: '/api/settings/profile', params: {}}),
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify(form),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw new Error(`Save failed (${res.status})`);
|
||||
toast({body: 'Profile saved'});
|
||||
} catch (err) {
|
||||
toast({
|
||||
type: 'error',
|
||||
body: (err as Error).message || 'Could not save profile',
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Business profile"
|
||||
subtitle="Shown on receipts and used for LYT settlement."
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{isDirty ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Unsaved changes
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Reset"
|
||||
isDisabled={!isDirty || isSaving}
|
||||
onClick={() => {
|
||||
setForm(initialData);
|
||||
setErrors({});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save changes"
|
||||
isDisabled={!isDirty}
|
||||
isLoading={isSaving}
|
||||
onClick={save}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5}>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Business name"
|
||||
value={form.businessName}
|
||||
onChange={(v) => set('businessName', v)}
|
||||
isRequired
|
||||
status={
|
||||
errors.businessName
|
||||
? {type: 'error', message: errors.businessName}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact name"
|
||||
value={form.contactName}
|
||||
onChange={(v) => set('contactName', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(v) => set('email', v)}
|
||||
isRequired
|
||||
status={
|
||||
errors.email ? {type: 'error', message: errors.email} : undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={form.phone}
|
||||
onChange={(v) => set('phone', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="GSTIN"
|
||||
value={form.gstin}
|
||||
onChange={(v) => set('gstin', v.toUpperCase())}
|
||||
description="15-character GST registration number"
|
||||
status={
|
||||
errors.gstin ? {type: 'error', message: errors.gstin} : undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Timezone"
|
||||
value={form.timezone}
|
||||
onChange={(v) => set('timezone', v)}
|
||||
description="All reports are rendered in this zone"
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<NumberInput
|
||||
label="LYTs per ₹100 spent"
|
||||
value={form.lytsPerHundred}
|
||||
onChange={(v) => set('lytsPerHundred', v ?? 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
description="1 LYT = ₹1. At 5, a ₹1,000 basket earns 50 LYTs — ₹50 of future liability."
|
||||
width={320}
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
42
src/features/settings/settings-nav.ts
Normal file
42
src/features/settings/settings-nav.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import type {IconType} from '@astryxdesign/core/Icon';
|
||||
|
||||
export interface SettingsSection {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: IconType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings sub-navigation.
|
||||
*
|
||||
* Same shape as the primary nav-config for the same reason: adding a section
|
||||
* should be a one-line push here plus a route folder, with nothing in the
|
||||
* layout changing.
|
||||
*/
|
||||
export const SETTINGS_NAV: SettingsSection[] = [
|
||||
{
|
||||
label: 'Profile',
|
||||
href: '/settings',
|
||||
icon: ICONS.profile,
|
||||
description: 'Business details, contact and tax registration.',
|
||||
},
|
||||
{
|
||||
label: 'Notifications',
|
||||
href: '/settings/notifications',
|
||||
icon: ICONS.notifications,
|
||||
description: 'What Loyaly alerts you about, and when.',
|
||||
},
|
||||
{
|
||||
label: 'Billing',
|
||||
href: '/settings/billing',
|
||||
icon: ICONS.revenue,
|
||||
description: 'Plan, invoices and LYT settlement.',
|
||||
},
|
||||
];
|
||||
|
||||
/** Exact match only — /settings must not stay active on /settings/billing. */
|
||||
export function isSettingsActive(pathname: string, href: string): boolean {
|
||||
return pathname === href;
|
||||
}
|
||||
65
src/features/staff/AttendanceChart.tsx
Normal file
65
src/features/staff/AttendanceChart.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import {ChartCard} from '@/components/charts/ChartCard';
|
||||
import {BarChartView} from '@/components/charts/BarChartView';
|
||||
import {CHART} from '@/components/charts/palette';
|
||||
import {formatCompact, formatDayLabel} from '@/lib/format';
|
||||
import type {AttendancePoint} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
/**
|
||||
* Daily attendance, stacked.
|
||||
*
|
||||
* PRESENT IS GRAY. Colouring it green produced a wall of saturated bars that
|
||||
* dominated the page and buried the only thing worth looking at — the amber
|
||||
* and red slivers. Present is the baseline, not an event; the exceptions are
|
||||
* what a merchant scans for, and they are what gets colour.
|
||||
*
|
||||
* This keeps the app's rule intact (colour marks what needs action) while
|
||||
* still letting late and absent read instantly without a legend lookup.
|
||||
*
|
||||
* Stacked rather than grouped because the meaningful quantity is the roster
|
||||
* total and how it splits — grouped bars would make you add three numbers to
|
||||
* answer "how many were rostered on Friday".
|
||||
*/
|
||||
export function AttendanceChart({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<AttendancePoint[]>;
|
||||
}) {
|
||||
return (
|
||||
<ChartCard
|
||||
title="Attendance"
|
||||
subtitle="Daily roster split — present, late, absent"
|
||||
resource={resource}
|
||||
>
|
||||
{(d) => (
|
||||
<BarChartView
|
||||
data={d}
|
||||
xKey="t"
|
||||
xFormat={formatDayLabel}
|
||||
yFormat={formatCompact}
|
||||
isStacked
|
||||
// Encoding is forced solid: the colour already separates these three,
|
||||
// so the default hatch on the third series would be noise on top of
|
||||
// an already-unambiguous signal.
|
||||
series={[
|
||||
{key: 'present', label: 'Present', encoding: 'solid'},
|
||||
{
|
||||
key: 'late',
|
||||
label: 'Late',
|
||||
color: CHART.attention,
|
||||
encoding: 'solid',
|
||||
},
|
||||
{
|
||||
key: 'absent',
|
||||
label: 'Absent',
|
||||
color: CHART.negative,
|
||||
encoding: 'solid',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
}
|
||||
178
src/features/staff/Leaderboard.tsx
Normal file
178
src/features/staff/Leaderboard.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
'use client';
|
||||
|
||||
import {Table, proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {HStack, VStack} from '@astryxdesign/core/Layout';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {PanelCard} from '@/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import {storeName} from '@/lib/mock/stores';
|
||||
import {formatCompact, formatPct} from '@/lib/format';
|
||||
import type {Attendance, StaffMember} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
interface Row extends Record<string, unknown> {
|
||||
id: string;
|
||||
rank: number;
|
||||
name: string;
|
||||
role: string;
|
||||
storeId: string;
|
||||
attendance: Attendance;
|
||||
salesCount: number;
|
||||
rewardsIssued: number;
|
||||
performanceScore: number;
|
||||
punctualityPct: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole team, ranked.
|
||||
*
|
||||
* A table because this is a one-dimensional comparison across many rows —
|
||||
* cards would force the eye to travel in two dimensions to answer "who is
|
||||
* third". Capped at the top 10: a leaderboard that lists everyone is a
|
||||
* roster, and the roster already exists above.
|
||||
*/
|
||||
export function Leaderboard({
|
||||
resource,
|
||||
}: {
|
||||
resource: Resource<StaffMember[]>;
|
||||
}) {
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'rank',
|
||||
header: '#',
|
||||
width: pixel(48),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.rank}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Team member',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Avatar name={row.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.role} · {storeName(row.storeId)}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
header: 'Today',
|
||||
width: pixel(112),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={ATTENDANCE[row.attendance].badge}
|
||||
label={ATTENDANCE[row.attendance].label}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'salesCount',
|
||||
header: 'Sales',
|
||||
width: pixel(88),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{formatCompact(row.salesCount)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'rewardsIssued',
|
||||
header: 'Rewards',
|
||||
width: pixel(96),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{formatCompact(row.rewardsIssued)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'punctualityPct',
|
||||
header: 'Punctuality',
|
||||
width: pixel(112),
|
||||
align: 'end',
|
||||
renderCell: (row) => (
|
||||
<Text size="sm">{formatPct(row.punctualityPct, 0)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'performanceScore',
|
||||
header: 'Score',
|
||||
// 140px was too narrow for a bar plus a 2-digit number side by side —
|
||||
// the value wrapped under the bar and read as two separate figures.
|
||||
width: pixel(180),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center" wrap="nowrap">
|
||||
<ProgressBar
|
||||
value={row.performanceScore}
|
||||
max={100}
|
||||
label={`Score ${row.performanceScore} of 100`}
|
||||
isLabelHidden
|
||||
hasValueLabel={false}
|
||||
/>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.performanceScore}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Leaderboard"
|
||||
subtitle="Top 10 by performance score across the selected scope"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={6} height={40} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="leaderboard"
|
||||
title="No one to rank yet"
|
||||
description="The leaderboard fills in once staff record sales."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(staff) => {
|
||||
const rows: Row[] = [...staff]
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore)
|
||||
.slice(0, 10)
|
||||
.map((m, i) => ({
|
||||
id: m.id,
|
||||
rank: i + 1,
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
storeId: m.storeId,
|
||||
attendance: m.attendance,
|
||||
salesCount: m.salesCount,
|
||||
rewardsIssued: m.rewardsIssued,
|
||||
performanceScore: m.performanceScore,
|
||||
punctualityPct: m.punctualityPct,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
data={rows}
|
||||
columns={columns}
|
||||
idKey="id"
|
||||
density="compact"
|
||||
hasHover
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
84
src/features/staff/StaffCard.tsx
Normal file
84
src/features/staff/StaffCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {ProgressBar} from '@astryxdesign/core/ProgressBar';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {HoverLift} from '@/components/motion/HoverLift';
|
||||
import {SectionHeader} from '@/components/patterns/SectionHeader';
|
||||
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import {storeName} from '@/lib/mock/stores';
|
||||
import {formatCompact, formatPct} from '@/lib/format';
|
||||
import type {StaffMember} from '@/lib/api/contracts';
|
||||
|
||||
/**
|
||||
* One team member.
|
||||
*
|
||||
* The performance score gets a bar rather than a bare number because a score
|
||||
* out of 100 is meaningless without a sense of the scale — 68 reads very
|
||||
* differently as a filled bar than as a digit. Sales and rewards stay as
|
||||
* plain figures; they are counts, not proportions.
|
||||
*/
|
||||
export function StaffCard({member}: {member: StaffMember}) {
|
||||
const attendance = ATTENDANCE[member.attendance];
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={4} height="100%">
|
||||
<SectionHeader
|
||||
level={3}
|
||||
title={member.name}
|
||||
subtitle={
|
||||
<Text size="sm" color="secondary">
|
||||
{member.role} · {storeName(member.storeId)}
|
||||
</Text>
|
||||
}
|
||||
actions={
|
||||
<Badge variant={attendance.badge} label={attendance.label} />
|
||||
}
|
||||
/>
|
||||
|
||||
<HStack gap={3} vAlign="center">
|
||||
<Avatar name={member.name} size="md" tooltip={false} />
|
||||
<VStack gap={1} width="100%">
|
||||
<ProgressBar
|
||||
value={member.performanceScore}
|
||||
max={100}
|
||||
label={`Performance score ${member.performanceScore} of 100`}
|
||||
hasValueLabel={false}
|
||||
/>
|
||||
<HStack hAlign="between">
|
||||
<Text size="xsm" color="secondary">
|
||||
Performance
|
||||
</Text>
|
||||
<Text size="xsm" weight="medium">
|
||||
{member.performanceScore}/100
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatRow>
|
||||
<StatPair label="Sales" value={formatCompact(member.salesCount)} />
|
||||
<StatPair
|
||||
label="Rewards issued"
|
||||
value={formatCompact(member.rewardsIssued)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Punctuality"
|
||||
value={formatPct(member.punctualityPct, 0)}
|
||||
align="end"
|
||||
/>
|
||||
</StatRow>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
103
src/features/staff/StaffGrid.tsx
Normal file
103
src/features/staff/StaffGrid.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
|
||||
import {StaffCard} from './StaffCard';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import type {Attendance, StaffMember} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
type Filter = 'all' | Attendance;
|
||||
|
||||
const STAFF_GRID_COLUMNS = {minWidth: 300, max: 3, repeat: 'fit'} as const;
|
||||
|
||||
export function StaffGrid({resource}: {resource: Resource<StaffMember[]>}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// `?? []` allocates a fresh array every render, which would make it an
|
||||
// unstable dependency and recompute both memos on every keystroke.
|
||||
const staff = useMemo(() => resource.data ?? [], [resource.data]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Filter, number> = {
|
||||
all: staff.length,
|
||||
present: 0,
|
||||
late: 0,
|
||||
absent: 0,
|
||||
leave: 0,
|
||||
};
|
||||
for (const m of staff) c[m.attendance] += 1;
|
||||
return c;
|
||||
}, [staff]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return staff
|
||||
.filter(
|
||||
(m) =>
|
||||
(filter === 'all' || m.attendance === filter) &&
|
||||
(q === '' ||
|
||||
m.name.toLowerCase().includes(q) ||
|
||||
m.role.toLowerCase().includes(q)),
|
||||
)
|
||||
// Best performers first — the roster's default question is "who is
|
||||
// carrying this store", not "who was added first".
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore);
|
||||
}, [staff, query, filter]);
|
||||
|
||||
return (
|
||||
<CollectionRegion label="Team roster">
|
||||
<FilterBar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder="Search name or role…"
|
||||
filterValue={filter}
|
||||
onFilterChange={(v) => setFilter(v as Filter)}
|
||||
filterLabel="Filter staff by attendance"
|
||||
options={[
|
||||
{value: 'all', label: 'All', hint: counts.all},
|
||||
{value: 'present', label: ATTENDANCE.present.label, hint: counts.present},
|
||||
{value: 'late', label: ATTENDANCE.late.label, hint: counts.late},
|
||||
{value: 'absent', label: ATTENDANCE.absent.label, hint: counts.absent},
|
||||
{value: 'leave', label: ATTENDANCE.leave.label, hint: counts.leave},
|
||||
]}
|
||||
resultCount={visible.length}
|
||||
resultNoun={visible.length === 1 ? 'person' : 'people'}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={6} height={230} minWidth={300} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="No staff yet"
|
||||
description="Team members appear here once they are added to a store."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
visible.length === 0 ? (
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="Nobody matches"
|
||||
description="Try a different attendance filter or clear the search."
|
||||
/>
|
||||
) : (
|
||||
<Grid columns={STAFF_GRID_COLUMNS} gap={4}>
|
||||
{visible.map((m) => (
|
||||
<StaffCard key={m.id} member={m} />
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
</CollectionRegion>
|
||||
);
|
||||
}
|
||||
105
src/features/staff/StaffKpis.tsx
Normal file
105
src/features/staff/StaffKpis.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {SkeletonMetricGrid} from '@/components/patterns/LoadingState';
|
||||
import {HoverLift} from '@/components/motion/HoverLift';
|
||||
import {AnimatedNumber} from '@/components/motion/AnimatedNumber';
|
||||
import {useMetricColumns} from '@/features/dashboard/KpiRow';
|
||||
import {ATTENDANCE} from './attendance';
|
||||
import {formatPct} from '@/lib/format';
|
||||
import type {StaffSummary} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
|
||||
|
||||
/**
|
||||
* Attendance counts.
|
||||
*
|
||||
* Deliberately NOT MetricCard: these have no trend line and no
|
||||
* period-over-period delta — "present today" has nothing meaningful to
|
||||
* compare against last month. What they carry instead is a status dot and a
|
||||
* share-of-total, so they get their own small card rather than a MetricCard
|
||||
* with two empty slots.
|
||||
*/
|
||||
function CountCard({
|
||||
label,
|
||||
value,
|
||||
total,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
total: number;
|
||||
tone: StatusDotVariant;
|
||||
}) {
|
||||
const share = total === 0 ? 0 : (value / total) * 100;
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={3}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<StatusDot variant={tone} label={label} />
|
||||
<Text size="sm" color="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
</HStack>
|
||||
{/* Text, not Heading — a count is data, not a section title. */}
|
||||
<Text type="display-3">
|
||||
<AnimatedNumber
|
||||
value={value}
|
||||
format={(v) => String(Math.round(v))}
|
||||
/>
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{formatPct(share, 0)} of {total} rostered
|
||||
</Text>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
|
||||
export function StaffKpis({resource}: {resource: Resource<StaffSummary>}) {
|
||||
const columns = useMetricColumns();
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonMetricGrid columns={columns} />}
|
||||
>
|
||||
{(s) => (
|
||||
<Grid columns={columns} gap={4}>
|
||||
<CountCard
|
||||
label={ATTENDANCE.present.label}
|
||||
value={s.present}
|
||||
total={s.total}
|
||||
tone={ATTENDANCE.present.tone}
|
||||
/>
|
||||
<CountCard
|
||||
label={ATTENDANCE.late.label}
|
||||
value={s.late}
|
||||
total={s.total}
|
||||
tone={ATTENDANCE.late.tone}
|
||||
/>
|
||||
<CountCard
|
||||
label={ATTENDANCE.absent.label}
|
||||
value={s.absent}
|
||||
total={s.total}
|
||||
tone={ATTENDANCE.absent.tone}
|
||||
/>
|
||||
<CountCard
|
||||
label="Total staff"
|
||||
value={s.total}
|
||||
total={s.total}
|
||||
tone="neutral"
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
29
src/features/staff/attendance.ts
Normal file
29
src/features/staff/attendance.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type {Attendance} from '@/lib/api/contracts';
|
||||
import type {StatusDotVariant} from '@astryxdesign/core/StatusDot';
|
||||
|
||||
/**
|
||||
* Attendance is the one place in this app where semantic colour applies to
|
||||
* every value rather than to an exception.
|
||||
*
|
||||
* Elsewhere the rule is "most things gray, colour marks what needs action".
|
||||
* Here the whole vocabulary IS status: present/late/absent map cleanly onto
|
||||
* success/warning/error, and a merchant scanning a roster is reading exactly
|
||||
* that axis. Greying three of the four would make the set harder to scan, not
|
||||
* calmer. On leave stays neutral — it is planned, so it needs no attention.
|
||||
*/
|
||||
export const ATTENDANCE: Record<
|
||||
Attendance,
|
||||
{label: string; tone: StatusDotVariant; badge: 'success' | 'warning' | 'error' | 'neutral'}
|
||||
> = {
|
||||
present: {label: 'Present', tone: 'success', badge: 'success'},
|
||||
late: {label: 'Late', tone: 'warning', badge: 'warning'},
|
||||
absent: {label: 'Absent', tone: 'error', badge: 'error'},
|
||||
leave: {label: 'On leave', tone: 'neutral', badge: 'neutral'},
|
||||
};
|
||||
|
||||
export const ATTENDANCE_ORDER: Attendance[] = [
|
||||
'present',
|
||||
'late',
|
||||
'absent',
|
||||
'leave',
|
||||
];
|
||||
82
src/features/stores/StoreCard.tsx
Normal file
82
src/features/stores/StoreCard.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {SectionHeader} from '@/components/patterns/SectionHeader';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {HoverLift} from '@/components/motion/HoverLift';
|
||||
import {StatPair, StatRow} from '@/components/patterns/StatPair';
|
||||
import {ICONS} from '@/lib/icons';
|
||||
import {formatCompact, formatInrCompact, formatPct} from '@/lib/format';
|
||||
import type {Store, StoreStatus} from '@/lib/api/contracts';
|
||||
|
||||
/**
|
||||
* Status is the one thing on this card allowed colour, because it is the one
|
||||
* thing that can require action. A closed or under-maintenance store is why a
|
||||
* merchant opened this page; its visitor count is context.
|
||||
*/
|
||||
const STATUS: Record<
|
||||
StoreStatus,
|
||||
{label: string; variant: 'success' | 'warning' | 'error' | 'neutral'}
|
||||
> = {
|
||||
open: {label: 'Open', variant: 'success'},
|
||||
maintenance: {label: 'Maintenance', variant: 'warning'},
|
||||
closed: {label: 'Closed', variant: 'error'},
|
||||
};
|
||||
|
||||
export function StoreCard({store}: {store: Store}) {
|
||||
const status = STATUS[store.status];
|
||||
|
||||
return (
|
||||
<HoverLift>
|
||||
<Card>
|
||||
<VStack gap={4} height="100%">
|
||||
<SectionHeader
|
||||
level={3}
|
||||
title={store.name}
|
||||
subtitle={
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<Icon icon={ICONS.staff} size="xsm" color="secondary" />
|
||||
<Text size="sm" color="secondary">
|
||||
{store.staffCount} staff
|
||||
</Text>
|
||||
</HStack>
|
||||
}
|
||||
actions={<Badge variant={status.variant} label={status.label} />}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<StatRow>
|
||||
<StatPair label="Visitors" value={formatCompact(store.visitors)} />
|
||||
<StatPair
|
||||
label="Purchases"
|
||||
value={formatCompact(store.purchases)}
|
||||
/>
|
||||
</StatRow>
|
||||
<StatRow>
|
||||
<StatPair
|
||||
label="Revenue"
|
||||
value={formatInrCompact(store.revenueInr)}
|
||||
/>
|
||||
<StatPair
|
||||
label="Conversion"
|
||||
value={formatPct(store.conversionPct)}
|
||||
/>
|
||||
</StatRow>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Open dashboard"
|
||||
href={`/stores/${store.id}`}
|
||||
/>
|
||||
</VStack>
|
||||
</Card>
|
||||
</HoverLift>
|
||||
);
|
||||
}
|
||||
108
src/features/stores/StoreGrid.tsx
Normal file
108
src/features/stores/StoreGrid.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Grid} from '@astryxdesign/core/Grid';
|
||||
import {AsyncBoundary} from '@/components/data/AsyncBoundary';
|
||||
import {FilterBar} from '@/components/patterns/FilterBar';
|
||||
import {CollectionRegion} from '@/components/patterns/CollectionRegion';
|
||||
import {EmptyPanel} from '@/components/patterns/EmptyPanel';
|
||||
import {SkeletonCardGrid} from '@/components/patterns/LoadingState';
|
||||
import {StoreCard} from './StoreCard';
|
||||
import type {Store, StoreStatus} from '@/lib/api/contracts';
|
||||
import type {Resource} from '@/lib/api/useResource';
|
||||
|
||||
type Filter = 'all' | StoreStatus;
|
||||
|
||||
/**
|
||||
* Three-up on desktop, per spec.
|
||||
*
|
||||
* minWidth 270 is what actually yields three tracks once the 16px gaps are
|
||||
* subtracted from a ~880px workspace — 300 silently collapsed it to two.
|
||||
* Capped at 3 so a wide monitor doesn't stretch the roster into a thin strip,
|
||||
* and it steps down to two when the Copilot panel is taking 380px.
|
||||
*/
|
||||
const STORE_GRID_COLUMNS = {minWidth: 270, max: 3, repeat: 'fit'} as const;
|
||||
|
||||
export function StoreGrid({resource}: {resource: Resource<Store[]>}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
// Memoised on resource.data: `?? []` allocates a fresh array every render,
|
||||
// which would make it an unstable dependency and recompute both memos below
|
||||
// on every keystroke.
|
||||
const stores = useMemo(() => resource.data ?? [], [resource.data]);
|
||||
|
||||
// Counts come from the unfiltered set so the chips still read "Closed (1)"
|
||||
// while the closed filter is active — otherwise every chip but the selected
|
||||
// one shows zero, which is useless.
|
||||
const counts = useMemo(() => {
|
||||
const c: Record<Filter, number> = {
|
||||
all: stores.length,
|
||||
open: 0,
|
||||
maintenance: 0,
|
||||
closed: 0,
|
||||
};
|
||||
for (const s of stores) c[s.status] += 1;
|
||||
return c;
|
||||
}, [stores]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return stores.filter(
|
||||
(s) =>
|
||||
(filter === 'all' || s.status === filter) &&
|
||||
(q === '' || s.name.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [stores, query, filter]);
|
||||
|
||||
return (
|
||||
<CollectionRegion label="All stores">
|
||||
<FilterBar
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
placeholder="Search stores…"
|
||||
filterValue={filter}
|
||||
onFilterChange={(v) => setFilter(v as Filter)}
|
||||
filterLabel="Filter stores by status"
|
||||
options={[
|
||||
{value: 'all', label: 'All', hint: counts.all},
|
||||
{value: 'open', label: 'Open', hint: counts.open},
|
||||
{value: 'maintenance', label: 'Maintenance', hint: counts.maintenance},
|
||||
{value: 'closed', label: 'Closed', hint: counts.closed},
|
||||
]}
|
||||
resultCount={visible.length}
|
||||
resultNoun={visible.length === 1 ? 'store' : 'stores'}
|
||||
/>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={resource}
|
||||
loading={<SkeletonCardGrid count={6} height={240} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No stores yet"
|
||||
description="Stores appear here once they are added to your Loyaly account."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
visible.length === 0 ? (
|
||||
// A filter returning nothing is a different situation from having
|
||||
// no stores at all, and needs a different way out.
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No stores match"
|
||||
description="Try a different status filter or clear the search."
|
||||
/>
|
||||
) : (
|
||||
<Grid columns={STORE_GRID_COLUMNS} gap={4}>
|
||||
{visible.map((s) => (
|
||||
<StoreCard key={s.id} store={s} />
|
||||
))}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
</AsyncBoundary>
|
||||
</CollectionRegion>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user