update ui and remove mock data

This commit is contained in:
2026-09-09 19:50:52 +05:30
parent 5ce580dced
commit bfe4d3d2f2
172 changed files with 4280 additions and 8775 deletions

View File

@@ -1,65 +0,0 @@
'use client';
import {ChartCard} from '@/shared/components/charts/ChartCard';
import {BarChartView} from '@/shared/components/charts/BarChartView';
import {CHART} from '@/shared/components/charts/palette';
import {formatCompact, formatDayLabel} from '@/shared/utils/format';
import type {AttendancePoint} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/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>
);
}

View File

@@ -1,183 +0,0 @@
'use client';
import {proportional, pixel} from '@astryxdesign/core/Table';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
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 '@/shared/components/patterns/PanelCard';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
import {formatCompact, formatPct} from '@/shared/utils/format';
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/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[]>;
}) {
// Resolved from the workspace store directory, not from a fixture — see
// useStoreDirectory.
const storeName = useStoreName();
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 (
<ResponsiveTable
data={rows}
columns={columns}
idKey="id"
primaryKey="name"
density="compact"
/>
);
}}
</PanelCard>
);
}

View File

@@ -1,87 +0,0 @@
'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 '@/shared/components/motion/HoverLift';
import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {useStoreName} from '@/features/stores/hooks/useStoreDirectory';
import {formatCompact, formatPct} from '@/shared/utils/format';
import type {StaffMember} from '@/features/staff/types/staff';
/**
* 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];
// Resolved from the workspace store directory, not from a fixture — see
// useStoreDirectory.
const storeName = useStoreName();
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>
);
}

View File

@@ -1,90 +0,0 @@
'use client';
import {useMemo, useState} from 'react';
import {Grid} from '@astryxdesign/core/Grid';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {FilterBar} from '@/shared/components/patterns/FilterBar';
import {CollectionRegion} from '@/shared/components/patterns/CollectionRegion';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {StaffCard} from './StaffCard';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {
countByAttendance,
selectStaff,
} from '@/features/staff/services/staffService';
import type {StaffFilter} from '@/features/staff/services/staffService';
import type {StaffMember} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/useResource';
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<StaffFilter>('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]);
// Both derivations live in staffService: what counts as a match and what
// order the roster is in are product rules, and a redesign of this grid must
// not be able to change them by accident. The memos stay here because
// memoisation is a React concern, not a domain one.
const counts = useMemo(() => countByAttendance(staff), [staff]);
const visible = useMemo(
() => selectStaff(staff, {filter, search: query}),
[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 StaffFilter)}
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>
);
}

View File

@@ -1,105 +0,0 @@
'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 '@/shared/components/data/AsyncBoundary';
import {SkeletonMetricGrid} from '@/shared/components/patterns/LoadingState';
import {HoverLift} from '@/shared/components/motion/HoverLift';
import {AnimatedNumber} from '@/shared/components/motion/AnimatedNumber';
import {useMetricColumns} from '@/features/dashboard/components/KpiRow';
import {ATTENDANCE} from '@/features/staff/utils/attendance';
import {formatPct} from '@/shared/utils/format';
import type {StaffSummary} from '@/features/staff/types/staff';
import type {Resource} from '@/shared/hooks/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>
);
}

View File

@@ -1,23 +0,0 @@
'use client';
import {staffRepository} from '@/features/staff/repositories/staffRepository';
import {useResource} from '@/shared/hooks/useResource';
import {useScope} from '@/shared/hooks/useScope';
/** Staff data access. See features/dashboard/hooks/useDashboard for the pattern. */
export function useStaffList() {
return useResource(staffRepository.list(useScope()));
}
export function useStaffSummary() {
return useResource(staffRepository.summary(useScope()), {
// A summary of zero people is still a summary; emptiness is the total
// being zero, not the object being absent.
isEmpty: (s) => s.total === 0,
});
}
export function useStaffAttendance() {
return useResource(staffRepository.attendance(useScope()));
}

View File

@@ -1,139 +0,0 @@
import type {Attendance, AttendancePoint, StaffMember, StaffSummary} from '@/features/staff/types/staff';
import type {RangeKey} from '@/shared/types/api';
import {createRng} from '@/shared/mock/rng';
import {STORE_SEED} from '@/features/stores/mock/stores.mock';
import {RANGE_DAYS} from '@/features/dashboard/mock/dashboard.mock';
const FIRST = [
'Anita', 'Vikram', 'Priya', 'Rahul', 'Deepa', 'Suresh', 'Meera', 'Arjun',
'Kavya', 'Nikhil', 'Sneha', 'Rohit', 'Divya', 'Karthik', 'Ananya', 'Manish',
'Lakshmi', 'Sanjay', 'Pooja', 'Ravi', 'Nisha', 'Ajay', 'Shreya', 'Vinod',
];
const LAST = [
'R', 'S', 'M', 'K', 'N', 'T', 'P', 'V', 'B', 'G', 'J', 'D',
];
const ROLES = [
'Store manager',
'Shift lead',
'Sales associate',
'Sales associate',
'Cashier',
];
/**
* The roster is sized from each store's own staffCount, so the Staff module
* and a store's card cannot disagree about how many people work there — the
* store card literally says "14 staff" and this must produce 14 rows.
*/
/**
* No `endMs` parameter, unlike the other builders: a roster is not a time
* series. Who works at a store depends on the store and the period length,
* not on which day you ask.
*/
export function buildStaff(storeId: string, range: RangeKey): StaffMember[] {
const stores =
storeId === 'all'
? STORE_SEED
: STORE_SEED.filter((s) => s.id === storeId);
const out: StaffMember[] = [];
let nameIdx = 0;
for (const store of stores) {
for (let i = 0; i < store.staffCount; i++) {
const rng = createRng('staff', store.id, i, range);
const first = FIRST[nameIdx % FIRST.length];
const last = LAST[(nameIdx * 7 + i) % LAST.length];
nameIdx += 1;
// Attendance is weighted, not uniform: a roster where a fifth of the
// team is absent every day would be a business in crisis, not a normal
// Tuesday. Most people are present.
const roll = rng.next();
const attendance: Attendance =
store.status === 'closed'
? 'absent'
: roll < 0.72
? 'present'
: roll < 0.85
? 'late'
: roll < 0.94
? 'absent'
: 'leave';
// Sales track the store's own traffic, so a flagship associate
// outsells a quiet-branch associate — which is what makes the
// leaderboard mean anything.
const salesCount = Math.round(
rng.int(18, 64) * store.scale * (RANGE_DAYS[range] / 30),
);
const punctualityPct = Number(rng.float(72, 99).toFixed(1));
out.push({
id: `${store.id}-${i}`,
name: `${first} ${last}`,
avatarUrl: null,
storeId: store.id,
role: i === 0 ? ROLES[0] : ROLES[1 + (i % (ROLES.length - 1))],
attendance,
salesCount,
rewardsIssued: Math.round(salesCount * rng.float(0.3, 0.7)),
// Weighted toward sales, then punctuality — a scorecard that is
// mostly attendance rewards showing up over selling.
performanceScore: Math.round(
Math.min(
100,
(salesCount / (store.scale * 2)) * 0.7 + punctualityPct * 0.3,
),
),
punctualityPct,
});
}
}
return out;
}
export function buildStaffSummary(
storeId: string,
range: RangeKey,
): StaffSummary {
const staff = buildStaff(storeId, range);
const count = (a: Attendance) =>
staff.filter((s) => s.attendance === a).length;
return {
present: count('present'),
absent: count('absent'),
late: count('late'),
onLeave: count('leave'),
total: staff.length,
};
}
/** Daily attendance split for the stacked chart. */
export function buildAttendance(
storeId: string,
range: RangeKey,
endMs: number,
): AttendancePoint[] {
const days = RANGE_DAYS[range];
const total = buildStaff(storeId, range).length;
const rng = createRng('attendance', storeId, range);
return Array.from({length: days}, (_, i) => {
const date = new Date(endMs - (days - 1 - i) * 86400000);
const dow = (date.getUTCDay() + 6) % 7;
// Weekends run leaner rosters, and Mondays carry more late arrivals.
const rostered = Math.round(total * (dow >= 5 ? 0.72 : 0.94));
const late = Math.round(rostered * (dow === 0 ? 0.14 : 0.07) * rng.float(0.6, 1.4));
const absent = Math.round(rostered * 0.06 * rng.float(0.4, 1.6));
return {
t: date.toISOString().slice(0, 10),
present: Math.max(0, rostered - late - absent),
late,
absent,
};
});
}

View File

@@ -1,19 +0,0 @@
import {scopedEndpoint} from '@/shared/services/httpClient';
import type {Endpoint, Scope} from '@/shared/services/httpClient';
import type {
AttendancePoint,
StaffMember,
StaffSummary,
} from '@/features/staff/types/staff';
/** Staff endpoints. See dashboardRepository for the layer's contract. */
export const staffRepository = {
list: (scope: Scope): Endpoint<StaffMember[]> =>
scopedEndpoint('/api/staff', scope),
summary: (scope: Scope): Endpoint<StaffSummary> =>
scopedEndpoint('/api/staff/summary', scope),
attendance: (scope: Scope): Endpoint<AttendancePoint[]> =>
scopedEndpoint('/api/staff/attendance', scope),
};

View File

@@ -1,60 +0,0 @@
import type {Attendance, StaffMember} from '@/features/staff/types/staff';
/**
* Domain rules for the staff module.
*
* These were inline in StaffGrid's useMemo. They are rules, not rendering:
* "the roster is ordered by who is carrying the store" is a product decision
* that a redesign of the grid must not be able to change by accident, and a
* second surface (the leaderboard, an export, a mobile view) has to be able to
* reuse it without copying a filter chain out of a component.
*
* Framework-free on purpose — no React, no Astryx — so it is unit-testable and
* survives any change to how staff are displayed.
*/
export type StaffFilter = Attendance | 'all';
export interface StaffQuery {
filter: StaffFilter;
/** Free text, matched against name and role. */
search: string;
}
/**
* Filter, then rank.
*
* Best performers first: the roster's default question is "who is carrying
* this store", not "who was added first". Ordering by score rather than name
* is the single decision that makes the grid answer it without a sort control.
*/
export function selectStaff(
staff: StaffMember[],
{filter, search}: StaffQuery,
): StaffMember[] {
const q = search.trim().toLowerCase();
return staff
.filter(
(m) =>
(filter === 'all' || m.attendance === filter) &&
(q === '' ||
m.name.toLowerCase().includes(q) ||
m.role.toLowerCase().includes(q)),
)
.sort((a, b) => b.performanceScore - a.performanceScore);
}
/** How many people are in each attendance state. Drives the filter counts. */
export function countByAttendance(
staff: StaffMember[],
): Record<StaffFilter, number> {
const counts: Record<StaffFilter, number> = {
all: staff.length,
present: 0,
late: 0,
absent: 0,
leave: 0,
};
for (const member of staff) counts[member.attendance] += 1;
return counts;
}