180 lines
4.9 KiB
TypeScript
180 lines
4.9 KiB
TypeScript
'use client';
|
|
|
|
import {proportional, pixel} from '@astryxdesign/core/Table';
|
|
import {ResponsiveTable} from '@/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 '@/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 (
|
|
<ResponsiveTable
|
|
data={rows}
|
|
columns={columns}
|
|
idKey="id"
|
|
primaryKey="name"
|
|
density="compact"
|
|
/>
|
|
);
|
|
}}
|
|
</PanelCard>
|
|
);
|
|
}
|