Панель расхода с фильтрами
Сделай панель расхода по проектам. Сверху чипы-фильтры по статусу, которые можно снимать. Под ними на каждый проект строка с названием, круговым индикатором заполненности квоты в процентах и меткой статуса. Список постраничный, по пять строк. Если после фильтрации ничего не осталось, вместо списка показывается пустое состояние с кнопкой сброса фильтров.
usage-dashboard__none__claude-code__claude-opus-5__1 · 23.09.2026, 19:56:02 мск
Первый вызов модели получил 8 634 токена входа, включая системный промпт Claude Code. Контекст до задачи — оценка по длине текста, для русского текста она занижает число токенов в полтора-два раза.
Компилятор
- Строка 5 · TS2724
'"@jinx-ui/react"' has no exported member named 'Badge'. Did you mean 'JxBadge'? - Строка 6 · TS2724
'"@jinx-ui/react"' has no exported member named 'Button'. Did you mean 'JxButton'? - Строка 7 · TS2305
Module '"@jinx-ui/react"' has no exported member 'Card'. - Строка 8 · TS2305
Module '"@jinx-ui/react"' has no exported member 'Chip'. - Строка 9 · TS2724
'"@jinx-ui/react"' has no exported member named 'EmptyState'. Did you mean 'JxEmptyState'? - Строка 10 · TS2724
'"@jinx-ui/react"' has no exported member named 'Pagination'. Did you mean 'JxPagination'? - Строка 11 · TS2305
Module '"@jinx-ui/react"' has no exported member 'Stack'. - Строка 12 · TS2305
Module '"@jinx-ui/react"' has no exported member 'Text'.
Линтер и покрытие
- inline-styleСтрока 126Инлайновые стили вместо классов и токенов библиотеки
Ни одного: код написан без библиотеки, поэтому прогон не засчитан
Ожидались JxChip, JxProgressCircle, JxBadge, JxPagination, JxEmptyState · покрытие 0%
Только формулировка задачи
Вход 2 · выход 3 464 · из кэша 8 233 · в кэш 399 · 37 с
Рендер
Код компонента
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
// NB: имена компонентов @jinx-ui/react (Card, Stack, Chip, Badge, Pagination, EmptyState)
// взяты по договорённости — сверь с актуальным экспортом пакета перед мержем.
import { useMemo, useState } from 'react';
import {
Badge,
Button,
Card,
Chip,
EmptyState,
Pagination,
Stack,
Text,
} from '@jinx-ui/react';
type ProjectStatus = 'active' | 'paused' | 'exceeded';
type Project = {
id: string;
name: string;
used: number;
quota: number;
status: ProjectStatus;
};
const PAGE_SIZE = 5;
const STATUS_META: Record<
ProjectStatus,
{ label: string; tone: 'success' | 'neutral' | 'danger'; token: string }
> = {
active: { label: 'Активен', tone: 'success', token: 'var(--jx-color-success)' },
paused: { label: 'На паузе', tone: 'neutral', token: 'var(--jx-color-text-muted)' },
exceeded: { label: 'Превышен', tone: 'danger', token: 'var(--jx-color-danger)' },
};
const STATUS_ORDER: ProjectStatus[] = ['active', 'paused', 'exceeded'];
const PROJECTS: Project[] = [
{ id: 'p-01', name: 'Каталог товаров', used: 812, quota: 1000, status: 'active' },
{ id: 'p-02', name: 'Платёжный шлюз', used: 1340, quota: 1200, status: 'exceeded' },
{ id: 'p-03', name: 'Рекомендации', used: 220, quota: 900, status: 'paused' },
{ id: 'p-04', name: 'Поиск по сайту', used: 640, quota: 800, status: 'active' },
{ id: 'p-05', name: 'Аналитика событий', used: 1810, quota: 1500, status: 'exceeded' },
{ id: 'p-06', name: 'Уведомления', used: 95, quota: 500, status: 'paused' },
{ id: 'p-07', name: 'Личный кабинет', used: 430, quota: 1000, status: 'active' },
{ id: 'p-08', name: 'Импорт остатков', used: 760, quota: 750, status: 'exceeded' },
{ id: 'p-09', name: 'Медиа-хранилище', used: 310, quota: 2000, status: 'paused' },
{ id: 'p-10', name: 'Служба доставки', used: 980, quota: 1100, status: 'active' },
{ id: 'p-11', name: 'Отчёты для бухгалтерии', used: 150, quota: 400, status: 'active' },
{ id: 'p-12', name: 'Чат поддержки', used: 520, quota: 600, status: 'active' },
];
const RADIUS = 18;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
export default function ProjectSpendPanel() {
const [activeStatuses, setActiveStatuses] = useState<ProjectStatus[]>(STATUS_ORDER);
const [page, setPage] = useState(1);
const filtered = useMemo(
() => PROJECTS.filter((project) => activeStatuses.includes(project.status)),
[activeStatuses],
);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, pageCount);
const rows = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const toggleStatus = (status: ProjectStatus) => {
setActiveStatuses((prev) =>
prev.includes(status) ? prev.filter((item) => item !== status) : [...prev, status],
);
setPage(1);
};
const resetFilters = () => {
setActiveStatuses(STATUS_ORDER);
setPage(1);
};
return (
<Card className="jx-p-4">
<Stack direction="vertical" gap="md">
<Stack direction="horizontal" gap="sm" wrap>
{STATUS_ORDER.map((status) => {
const selected = activeStatuses.includes(status);
return (
<Chip
key={status}
selected={selected}
aria-pressed={selected}
onClick={() => toggleStatus(status)}
>
{STATUS_META[status].label}
</Chip>
);
})}
</Stack>
{rows.length === 0 ? (
<EmptyState
title="Нет проектов под выбранные фильтры"
description="Снятые статусы скрыли все строки списка."
action={
<Button variant="primary" onClick={resetFilters}>
Сбросить фильтры
</Button>
}
/>
) : (
<Stack direction="vertical" gap="sm">
{rows.map((project) => {
const percent = Math.round((project.used / project.quota) * 100);
const meta = STATUS_META[project.status];
const dashOffset =
CIRCUMFERENCE - (Math.min(percent, 100) / 100) * CIRCUMFERENCE;
return (
<Stack
key={project.id}
direction="horizontal"
gap="md"
align="center"
justify="space-between"
className="jx-p-3"
style={{
borderBottom: '1px solid var(--jx-color-border)',
}}
>
<Stack direction="horizontal" gap="md" align="center">
<svg
width={44}
height={44}
viewBox="0 0 44 44"
role="img"
aria-label={`Квота использована на ${percent}%`}
>
<circle
cx={22}
cy={22}
r={RADIUS}
fill="none"
stroke="var(--jx-color-border)"
strokeWidth={4}
/>
<circle
cx={22}
cy={22}
r={RADIUS}
fill="none"
stroke={meta.token}
strokeWidth={4}
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
transform="rotate(-90 22 22)"
/>
</svg>
<Stack direction="vertical" gap="xs">
<Text weight="medium">{project.name}</Text>
<Text size="sm" tone="muted">
{percent}% · {project.used} из {project.quota}
</Text>
</Stack>
</Stack>
<Badge tone={meta.tone}>{meta.label}</Badge>
</Stack>
);
})}
<Pagination page={safePage} pageCount={pageCount} onChange={setPage} />
</Stack>
)}
</Stack>
</Card>
);
}