Панель расхода с фильтрами
Сделай панель расхода по проектам. Сверху чипы-фильтры по статусу, которые можно снимать. Под ними на каждый проект строка с названием, круговым индикатором заполненности квоты в процентах и меткой статуса. Список постраничный, по пять строк. Если после фильтрации ничего не осталось, вместо списка показывается пустое состояние с кнопкой сброса фильтров.
usage-dashboard__mcp__claude-code__claude-opus-5__1 · 24.09.2026, 13:32:02 мск
Первый вызов модели получил 12 521 токен входа, включая системный промпт Claude Code; вызовов модели 2, каждый отправлял разговор заново, и самый большой получил 17 892 токена. Контекст до задачи — оценка по длине текста, для русского текста она занижает число токенов в полтора-два раза.
Компилятор
Ошибок нет: все компоненты и пропсы существуют.
Линтер и покрытие
- inline-styleСтроки 79, 93, 95, 104, 112, 154, 166, 181, 183, 194Инлайновые стили вместо классов и токенов библиотеки
JxBadge, JxButton, JxChip, JxDivider, JxEmptyState, JxPagination, JxProgressCircle
Ожидались JxChip, JxProgressCircle, JxBadge, JxPagination, JxEmptyState · покрытие 100%
- get_rules({"task":"ui"}) → ~1247 токенов
- get_component_api({"name":"JxChip"}) → ~360 токенов
- get_component_api({"name":"JxProgressCircle"}) → ~217 токенов
- get_component_api({"name":"JxBadge"}) → ~274 токенов
- get_component_api({"name":"JxPagination"}) → ~450 токенов
- get_component_api({"name":"JxEmptyState"}) → ~274 токенов
- get_component_api({"name":"JxButton","detail":"signature"}) → ~45 токенов
Инструменты (search_components,get_component_api,get_component_examples,list_design_tokens,get_docs,get_rules, ~1213)
Вход 4 · выход 3 739 · из кэша 24 534 · в кэш 5 875 · 43 с
Рендер
Код компонента
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 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212
import { useMemo, useState } from 'react';
import {
JxBadge,
JxButton,
JxChip,
JxDivider,
JxEmptyState,
JxPagination,
JxProgressCircle,
} from '@jinx-ui/react';
type SpendStatus = 'active' | 'at-risk' | 'over' | 'paused';
type Project = {
id: string;
name: string;
quotaUsed: number;
status: SpendStatus;
};
const STATUS_META: Record<
SpendStatus,
{ label: string; tone: 'success' | 'warning' | 'danger' | 'default' }
> = {
active: { label: 'В норме', tone: 'success' },
'at-risk': { label: 'Близко к лимиту', tone: 'warning' },
over: { label: 'Превышен', tone: 'danger' },
paused: { label: 'Приостановлен', tone: 'default' },
};
const STATUS_ORDER: SpendStatus[] = ['active', 'at-risk', 'over', 'paused'];
const PROJECTS: Project[] = [
{ id: 'p-01', name: 'Витрина каталога', quotaUsed: 42, status: 'active' },
{ id: 'p-02', name: 'Платёжный шлюз', quotaUsed: 88, status: 'at-risk' },
{ id: 'p-03', name: 'Аналитика событий и пользовательских сессий', quotaUsed: 117, status: 'over' },
{ id: 'p-04', name: 'Мобильный клиент', quotaUsed: 26, status: 'active' },
{ id: 'p-05', name: 'Поисковый индекс', quotaUsed: 73, status: 'at-risk' },
{ id: 'p-06', name: 'Отчёты для бухгалтерии', quotaUsed: 9, status: 'paused' },
{ id: 'p-07', name: 'CDN и медиа', quotaUsed: 61, status: 'active' },
{ id: 'p-08', name: 'Песочница ML', quotaUsed: 104, status: 'over' },
{ id: 'p-09', name: 'Внутренний портал', quotaUsed: 35, status: 'active' },
{ id: 'p-10', name: 'Рассылки', quotaUsed: 91, status: 'at-risk' },
{ id: 'p-11', name: 'Легаси биллинг', quotaUsed: 4, status: 'paused' },
{ id: 'p-12', name: 'Геосервис', quotaUsed: 54, status: 'active' },
];
const PAGE_SIZE = 5;
export default function ProjectSpendPanel() {
const [statuses, setStatuses] = useState<SpendStatus[]>(STATUS_ORDER);
const [page, setPage] = useState(1);
const filtered = useMemo(
() => PROJECTS.filter((project) => statuses.includes(project.status)),
[statuses],
);
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, pageCount);
const visible = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const toggleStatus = (status: SpendStatus) => {
setPage(1);
setStatuses((current) =>
current.includes(status)
? current.filter((item) => item !== status)
: STATUS_ORDER.filter((item) => item === status || current.includes(item)),
);
};
const resetFilters = () => {
setStatuses(STATUS_ORDER);
setPage(1);
};
return (
<section
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '20px',
maxWidth: '680px',
background: 'var(--jx-surface)',
border: '1px solid var(--jx-border)',
borderRadius: 'var(--jx-r-lg)',
boxShadow: 'var(--jx-shadow-sm)',
fontFamily: 'var(--jx-font-sans)',
color: 'var(--jx-text)',
}}
>
<header style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<h2
style={{
margin: 0,
fontFamily: 'var(--jx-font-display)',
fontSize: '18px',
color: 'var(--jx-text)',
}}
>
Расход по проектам
</h2>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--jx-text-2)' }}>
Заполненность месячной квоты. Показано {filtered.length} из {PROJECTS.length} проектов.
</p>
</header>
<div
role="group"
aria-label="Фильтры по статусу"
style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}
>
{STATUS_ORDER.map((status) => {
const selected = statuses.includes(status);
return (
<JxChip
key={status}
role="button"
tabIndex={0}
aria-pressed={selected}
active={selected}
removable={selected}
onRemove={() => toggleStatus(status)}
onClick={() => toggleStatus(status)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
toggleStatus(status);
}
}}
>
{STATUS_META[status].label}
</JxChip>
);
})}
</div>
<JxDivider />
{visible.length === 0 ? (
<JxEmptyState
title="Ни один проект не подходит под фильтры"
message="Сняты все статусы расхода. Верните фильтры, чтобы снова увидеть проекты."
action={
<JxButton variant="primary" onClick={resetFilters}>
Сбросить фильтры
</JxButton>
}
/>
) : (
<>
<ul
style={{
listStyle: 'none',
margin: 0,
padding: 0,
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{visible.map((project) => (
<li
key={project.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
padding: '12px 14px',
background: 'var(--jx-surface-2)',
border: '1px solid var(--jx-border-2)',
borderRadius: 'var(--jx-r)',
}}
>
<JxProgressCircle
value={project.quotaUsed}
showValue
aria-label={`Квота проекта «${project.name}» заполнена на ${project.quotaUsed}%`}
/>
<span style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span
style={{
fontSize: '14px',
color: 'var(--jx-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={project.name}
>
{project.name}
</span>
<span style={{ fontSize: '12px', color: 'var(--jx-text-2)', fontFamily: 'var(--jx-font-mono)' }}>
{project.quotaUsed}% квоты
</span>
</span>
<JxBadge tone={STATUS_META[project.status].tone} dot>
{STATUS_META[project.status].label}
</JxBadge>
</li>
))}
</ul>
{pageCount > 1 && (
<JxPagination total={pageCount} page={safePage} onPageChange={setPage} />
)}
</>
)}
</section>
);
}