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