first commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user