Files
postcast-tool/components/AdminPanel.js
T
Ник (Claude) a07cc224a9 feat: AdminLogs — error log viewer
AdminLogs.js:
  Топ-5 частых ошибок с прогресс-баром
  Фильтр по источнику (все/генерация/AI/публикация)
  Список с раскрываемыми карточками:
    - Левая граница цветом по типу (timeout/auth/model/other)
    - Краткое и полное описание ошибки
    - Контекстные подсказки (ссылка на настройки, объяснение причины)
  Классификация: Таймаут/Rate limit/Модель/Пустой ответ/Сеть/Авторизация
AdminPanel: раздел Логи ошибок с AlertTriangle иконкой
API route: /api/admin/logs
2026-06-13 10:24:12 +03:00

623 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState } from 'react';
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle } from 'lucide-react';
import Link from 'next/link';
import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers';
import AdminPromos from './admin/AdminPromos';
import AdminQueue from './admin/AdminQueue';
import AdminLogs from './admin/AdminLogs';
// ──────────────────────────────────────────────────────────────
// Sidebar navigation
// ──────────────────────────────────────────────────────────────
const SECTIONS = [
{ id: 'dashboard', label: 'Сводка', icon: BarChart3, desc: 'Пользователи, посты, финансы' },
{ id: 'settings', label: 'AI-провайдеры', icon: Settings2, desc: 'Ключи aiprimetech и routerai' },
{ id: 'engine', label: 'Движок', icon: Zap, desc: 'URL, Telegram, авто-черновики' },
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
];
export default function AdminPanel({ initialSection = 'settings' }) {
const [section, setSection] = useState(initialSection);
return (
<div className="max-w-6xl mx-auto p-4 sm:p-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-6 text-sm text-gray-500">
<Link href="/" className="hover:text-gray-200 flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" /> Главная
</Link>
<ChevronRight className="w-3.5 h-3.5" />
<span className="text-gray-200">Администрирование</span>
<ChevronRight className="w-3.5 h-3.5" />
<span className="text-accent">{SECTIONS.find(s => s.id === section)?.label}</span>
</div>
<div className="flex gap-6">
{/* Sidebar */}
<aside className="w-52 shrink-0">
<div className="card p-2 space-y-0.5 sticky top-6">
<p className="text-xs text-gray-500 uppercase tracking-wide px-3 py-2 font-medium">Разделы</p>
{SECTIONS.map(({ id, label, icon: Icon, desc }) => (
<button key={id} onClick={() => setSection(id)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors flex items-center gap-3 ${
section === id
? 'bg-accent/10 text-accent'
: 'hover:bg-surface2 text-gray-300'
}`}>
<Icon className="w-4 h-4 shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium truncate">{label}</div>
<div className="text-xs text-gray-500 truncate">{desc}</div>
</div>
</button>
))}
</div>
</aside>
{/* Content */}
<div className="flex-1 min-w-0">
{section === 'dashboard' && <DashboardSection />}
{section === 'settings' && <SettingsSection categories={['ai_providers', 'photo_search']} />}
{section === 'engine' && <SettingsSection categories={['engine']} />}
{section === 'payments' && <SettingsSection categories={['payments']} />}
{section === 'spending' && <SpendingSection />}
{section === 'queue' && <AdminQueue />}
{section === 'logs' && <AdminLogs />}
{section === 'plans' && <PlansSection />}
{section === 'promos' && <AdminPromos />}
{section === 'billing' && <AdminUsers />}
</div>
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Settings section (API keys)
// ──────────────────────────────────────────────────────────────
const CATEGORY_META = {
ai_providers: {
title: 'AI-провайдеры',
hint: 'Ключи и URL для текстовой и картиночной генерации. Меняются на лету.',
},
photo_search: {
title: 'Поиск фото',
hint: 'Yandex Search API: ключ и folder.',
},
payments: {
title: 'ЮKassa',
hint: 'Shop ID и Secret Key из личного кабинета. Webhook: https://engine.zeropost.ru/api/billing/webhook',
},
};
function SettingsSection({ categories }) {
const [data, setData] = useState({});
const [loaded, setLoaded] = useState(false);
const [loading, setLoading] = useState(false);
async function load() {
setLoading(true);
const all = {};
for (const cat of categories) {
try {
const rows = await fetch(`/api/admin/settings?category=${cat}`).then(r => r.json());
all[cat] = Array.isArray(rows) ? rows : [];
} catch { all[cat] = []; }
}
setData(all);
setLoaded(true);
setLoading(false);
}
if (!loaded && !loading) { load(); }
return (
<div className="space-y-6">
{categories.map(cat => {
const meta = CATEGORY_META[cat] || { title: cat, hint: '' };
const rows = data[cat] || [];
return (
<section key={cat} className="card p-5">
<div className="mb-4">
<h2 className="font-semibold">{meta.title}</h2>
{meta.hint && <p className="text-xs text-gray-500 mt-1">{meta.hint}</p>}
</div>
{loading && !rows.length
? <div className="py-4 text-center"><Loader2 className="w-4 h-4 animate-spin mx-auto" /></div>
: rows.map(row => (
<SettingRow key={row.key} row={row} onSaved={load} />
))
}
</section>
);
})}
</div>
);
}
function SettingRow({ row, onSaved }) {
const [val, setVal] = useState(row.value || '');
const [show, setShow] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState(null); // 'ok' | 'error'
const [errMsg, setErrMsg] = useState('');
const dirty = val !== (row.value || '');
async function save() {
setSaving(true); setStatus(null);
try {
const res = await fetch(`/api/admin/settings/${encodeURIComponent(row.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: val }),
}).then(r => r.json());
if (res.error) { setStatus('error'); setErrMsg(res.error); }
else { setStatus('ok'); onSaved(); setTimeout(() => setStatus(null), 2000); }
} catch (e) { setStatus('error'); setErrMsg(e.message); }
setSaving(false);
}
const isSecret = row.is_secret;
const inputType = isSecret && !show ? 'password' : 'text';
return (
<div className="mb-4">
<div className="flex items-center justify-between mb-1">
<label className="label text-xs">{row.key}</label>
<span className="text-xs text-gray-600">{row.category}</span>
</div>
{row.description && <p className="text-xs text-gray-500 mb-1.5">{row.description}</p>}
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={inputType}
value={val}
onChange={e => setVal(e.target.value)}
className="input w-full pr-8 font-mono text-sm"
placeholder={isSecret ? '••••••••' : 'Введите значение...'}
/>
{isSecret && (
<button onClick={() => setShow(s => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300">
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
)}
</div>
{dirty && (
<button onClick={save} disabled={saving}
className="btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
{saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
Сохранить
</button>
)}
{status === 'ok' && <Check className="w-5 h-5 text-green-400 self-center shrink-0" />}
{status === 'error' && <AlertCircle className="w-5 h-5 text-red-400 self-center shrink-0" title={errMsg} />}
</div>
<div className="text-xs text-gray-600 mt-1">
обновлено: {row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'}
</div>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Spending section
// ──────────────────────────────────────────────────────────────
const PERIODS = [
{ v: 'today', label: 'Сегодня' },
{ v: 'week', label: '7 дней' },
{ v: 'month', label: '30 дней' },
{ v: 'alltime', label: 'Всё время' },
];
const TYPE_LABELS = {
'chat': '💬 Текст',
'image': '🖼 Изображение',
'image_via_responses': '🖼 Изображение',
'article': '📝 Статья',
'topic': '🔍 Топики',
};
function fmt(n) { return Number(n || 0).toFixed(2); }
function fmtI(n) { return Number(n || 0).toLocaleString('ru-RU'); }
function SpendingSection() {
const [period, setPeriod] = useState('month');
const [data, setData] = useState(null);
const [byProv, setByProv] = useState(null);
const [loading, setLoading] = useState(false);
async function load(p) {
setLoading(true);
try {
const [r1, r2] = await Promise.all([
fetch(`/api/usage/summary?range=${p}&group_by=request_type`).then(r => r.json()),
fetch(`/api/usage/summary?range=${p}&group_by=provider`).then(r => r.json()),
]);
setData(r1); setByProv(r2);
} catch {}
setLoading(false);
}
if (!data && !loading) load(period);
const totals = data?.totals || {};
const aiprimetech = byProv?.breakdown?.find(b => b.key === 'aiprimetech');
const routerai = byProv?.breakdown?.find(b => b.key === 'routerai');
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Расходы на AI</h2>
<div className="flex gap-1">
{PERIODS.map(p => (
<button key={p.v} onClick={() => { setPeriod(p.v); load(p.v); }}
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${period === p.v ? 'bg-accent text-white' : 'btn-ghost'}`}>
{p.label}
</button>
))}
<button onClick={() => load(period)} className="btn-ghost p-1.5 ml-1">
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
</div>
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && data && (<>
{/* Итого */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Итого, ₽', value: `${fmt(totals.cost_rub)}`, accent: true },
{ label: 'Запросов', value: fmtI(totals.calls) },
{ label: 'Токенов', value: fmtI((totals.prompt_tokens||0)+(totals.completion_tokens||0)) },
{ label: 'Картинок', value: fmtI(totals.image_count) },
].map(s => (
<div key={s.label} className={`card p-3 ${s.accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-xl font-bold ${s.accent ? 'text-accent' : ''}`}>{s.value}</div>
<div className="text-xs text-gray-500 mt-0.5">{s.label}</div>
</div>
))}
</div>
{/* По провайдерам */}
<div className="grid grid-cols-2 gap-3">
{[
{ key: 'aiprimetech', label: 'aiprimetech.io', icon: '💬', desc: 'Текст', data: aiprimetech },
{ key: 'routerai', label: 'routerai.ru', icon: '🖼', desc: 'Картинки', data: routerai },
].map(p => (
<div key={p.key} className="card p-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-lg">{p.icon}</span>
<div>
<div className="font-medium text-sm">{p.label}</div>
<div className="text-xs text-gray-500">{p.desc}</div>
</div>
<div className="ml-auto text-accent font-bold"> {fmt(p.data?.cost_rub)}</div>
</div>
<div className="grid grid-cols-3 gap-1 text-xs text-gray-400">
<div><div className="font-medium text-gray-200">{fmtI(p.data?.calls)}</div>запросов</div>
<div><div className="font-medium text-red-400">{p.data?.failed||0}</div>ошибок</div>
<div><div className="font-medium text-gray-200">{fmtI(p.data?.image_count || (p.data?.prompt_tokens||0)+(p.data?.completion_tokens||0))}</div>{p.key==='routerai'?'картинок':'токенов'}</div>
</div>
</div>
))}
</div>
{/* Таблица по операциям */}
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Операция</th>
<th className="px-4 py-2.5 text-right">Запросов</th>
<th className="px-4 py-2.5 text-right">Ошибок</th>
<th className="px-4 py-2.5 text-right">Объём</th>
<th className="px-4 py-2.5 text-right">Стоимость</th>
</tr>
</thead>
<tbody>
{(data.breakdown || []).map((row, i) => (
<tr key={i} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">{TYPE_LABELS[row.key] || row.key}</td>
<td className="px-4 py-2.5 text-right text-gray-300">{fmtI(row.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400">{row.failed || 0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs">
{row.image_count > 0 ? `${row.image_count} шт.` : fmtI((row.prompt_tokens||0)+(row.completion_tokens||0))}
</td>
<td className="px-4 py-2.5 text-right font-medium"> {fmt(row.cost_rub)}</td>
</tr>
))}
</tbody>
<tfoot className="bg-surface2 border-t border-border">
<tr>
<td className="px-4 py-2.5 font-semibold">Итого</td>
<td className="px-4 py-2.5 text-right font-semibold">{fmtI(totals.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400 font-semibold">{totals.failed||0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs"></td>
<td className="px-4 py-2.5 text-right font-bold text-accent"> {fmt(totals.cost_rub)}</td>
</tr>
</tfoot>
</table>
</div>
</>)}
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Plans & Credits section
// ──────────────────────────────────────────────────────────────
function PlansSection() {
const [plans, setPlans] = useState([]);
const [costs, setCosts] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState({});
const [msg, setMsg] = useState('');
async function load() {
setLoading(true);
try {
const res = await fetch('/api/billing/plans').then(r => r.json());
setPlans(res.plans || []);
setCosts(res.costs || []);
} catch {}
setLoading(false);
}
async function savePlan(plan) {
setSaving(s => ({ ...s, [plan.id]: true }));
try {
await fetch(`/api/admin/plans/${plan.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price_rub: plan.price_rub, credits_month: plan.credits_month, channels_max: plan.channels_max }),
});
setMsg('Сохранено ✓');
setTimeout(() => setMsg(''), 2000);
} catch {}
setSaving(s => ({ ...s, [plan.id]: false }));
}
async function saveCost(cost) {
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: true }));
try {
await fetch(`/api/admin/credit-costs/${cost.operation}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credits: cost.credits }),
});
setMsg('Сохранено ✓');
setTimeout(() => setMsg(''), 2000);
} catch {}
setSaving(s => ({ ...s, [`cost_${cost.operation}`]: false }));
}
if (loading && !plans.length) { load(); }
const PLAN_LABELS = { free: 'Free', starter: 'Starter', pro: 'Pro', business: 'Business' };
return (
<div className="space-y-6">
{msg && <div className="text-sm text-green-400 flex items-center gap-1"><Check className="w-4 h-4" />{msg}</div>}
{/* Тарифные планы */}
<section className="card p-5">
<h2 className="font-semibold mb-1">Тарифные планы</h2>
<p className="text-xs text-gray-500 mb-4">Цены и лимиты. -1 = безлимит.</p>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : (
<div className="space-y-3">
{plans.map(plan => {
const [p, setP] = [plan, (updates) => setPlans(pp => pp.map(x => x.id === plan.id ? { ...x, ...updates } : x))];
return (
<div key={p.id} className="flex items-center gap-3 p-3 rounded-lg bg-surface2">
<div className="w-20 font-medium text-sm">{PLAN_LABELS[p.code] || p.code}</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500">/мес:</span>
<input type="number" value={p.price_rub} onChange={e => setP({ price_rub: +e.target.value })}
className="input w-20 text-sm py-1" />
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500">кредитов:</span>
<input type="number" value={p.credits_month} onChange={e => setP({ credits_month: +e.target.value })}
className="input w-20 text-sm py-1" />
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500">каналов:</span>
<input type="number" value={p.channels_max} onChange={e => setP({ channels_max: +e.target.value })}
className="input w-16 text-sm py-1" />
</div>
<button onClick={() => savePlan(p)} disabled={saving[p.id]}
className="btn-primary text-xs px-2.5 py-1.5 ml-auto flex items-center gap-1">
{saving[p.id] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Сохранить
</button>
</div>
);
})}
</div>
)}
</section>
{/* Стоимость операций */}
<section className="card p-5">
<h2 className="font-semibold mb-1">Стоимость операций (кредиты)</h2>
<p className="text-xs text-gray-500 mb-4">Сколько кредитов списывается за каждую операцию.</p>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : (
<div className="space-y-2">
{costs.map(cost => {
const [c, setC] = [cost, (updates) => setCosts(cc => cc.map(x => x.operation === cost.operation ? { ...x, ...updates } : x))];
const icons = { image: '🖼', text_post: '✍️', article: '📝', autopublish: '📤' };
return (
<div key={c.operation} className="flex items-center gap-3 p-3 rounded-lg bg-surface2">
<span className="text-lg w-7 text-center">{icons[c.operation] || '⚙️'}</span>
<div className="flex-1 text-sm">{c.description || c.operation}</div>
<div className="flex items-center gap-2">
<input type="number" value={c.credits} onChange={e => setC({ credits: +e.target.value })}
className="input w-16 text-sm py-1 text-center" min={0} />
<span className="text-xs text-gray-500">кр</span>
</div>
<button onClick={() => saveCost(c)} disabled={saving[`cost_${c.operation}`]}
className="btn-primary text-xs px-2.5 py-1.5 flex items-center gap-1">
{saving[`cost_${c.operation}`] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
Сохранить
</button>
</div>
);
})}
</div>
)}
</section>
</div>
);
}
// ──────────────────────────────────────────────────────────────
// Dashboard section
// ──────────────────────────────────────────────────────────────
function DashboardSection() {
const [data, setData] = useState(null);
const [loading, setLoading]= useState(true);
async function load() {
setLoading(true);
try {
const res = await fetch('/api/admin/dashboard').then(r => r.json());
setData(res);
} catch {}
setLoading(false);
}
if (!data && !loading) load();
if (!data && loading) { load(); }
const PLATFORM_ICONS = { telegram: '✈️', vk: '🔵', max: '🟣' };
function Stat({ label, value, sub, accent }) {
return (
<div className={`card p-4 ${accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-2xl font-bold ${accent ? 'text-accent' : ''}`}>{value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
{sub && <div className="text-xs text-gray-500 mt-1">{sub}</div>}
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-lg">Сводка</h2>
<button onClick={load} className="btn-ghost p-2"><RefreshCw className="w-4 h-4" /></button>
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" /></div>}
{data && (<>
{/* Пользователи */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Пользователи</p>
<div className="grid grid-cols-3 gap-3">
<Stat label="Всего пользователей" value={data.users.total} accent />
<Stat label="Новых за 7 дней" value={data.users.new_7d} />
<Stat label="Новых за 30 дней" value={data.users.new_30d} />
</div>
</div>
{/* Каналы */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Каналы</p>
<div className="grid grid-cols-4 gap-3">
<Stat label="Всего каналов"
value={data.channels.reduce((s, c) => s + c.cnt, 0)} />
{data.channels.map(c => (
<Stat key={c.platform}
label={`${PLATFORM_ICONS[c.platform] || '📢'} ${c.platform}`}
value={c.cnt} />
))}
</div>
</div>
{/* Посты */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Публикации</p>
<div className="grid grid-cols-3 gap-3">
<Stat label="Всего опубликовано" value={data.posts.total} />
<Stat label="За последние 24 часа" value={data.posts.today} />
<Stat label="За 7 дней" value={data.posts.week} />
</div>
</div>
{/* Финансы */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Финансы</p>
<div className="grid grid-cols-2 gap-3">
<div className="card p-4 border-green-500/30 bg-green-500/5">
<div className="text-2xl font-bold text-green-400">{data.revenue.month_rub.toLocaleString('ru-RU')}</div>
<div className="text-xs text-gray-400 mt-0.5">Выручка за 30 дней</div>
<div className="text-xs text-gray-500 mt-1">{data.revenue.paid_count} платежей итого {data.revenue.total_rub.toLocaleString('ru-RU')}</div>
</div>
<div className="card p-4 border-red-500/20 bg-red-500/5">
<div className="text-2xl font-bold text-red-400">{data.ai.cost_rub.toFixed(2)}</div>
<div className="text-xs text-gray-400 mt-0.5">Расходы на AI за 30 дней</div>
<div className="text-xs text-gray-500 mt-1">
{data.ai.calls} запросов {data.ai.errors} ошибок
</div>
</div>
</div>
</div>
{/* Черновики */}
{data.drafts.pending > 0 && (
<div className="card p-4 border-accent/30 bg-accent/5 flex items-center justify-between">
<div>
<div className="font-medium text-sm"> {data.drafts.pending} черновиков ждут одобрения</div>
<div className="text-xs text-gray-400 mt-0.5">Просмотрите и запланируйте публикацию</div>
</div>
<a href="/drafts" className="btn-primary text-sm px-3 py-1.5">Смотреть </a>
</div>
)}
{/* Регистрации 14 дней */}
{data.registrations_14d.length > 0 && (
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Регистрации последние 14 дней</p>
<div className="card p-4">
<div className="flex items-end gap-1 h-16">
{(() => {
const max = Math.max(...data.registrations_14d.map(r => r.cnt), 1);
// Заполняем пустые дни
const days = [];
for (let i = 13; i >= 0; i--) {
const d = new Date(); d.setDate(d.getDate() - i);
const key = d.toISOString().split('T')[0];
const found = data.registrations_14d.find(r => r.day === key);
days.push({ day: key, cnt: found?.cnt || 0 });
}
return days.map((r, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="w-full bg-accent/20 rounded-sm transition-all"
style={{ height: `${Math.max(2, (r.cnt / max) * 56)}px` }}
title={`${r.day}: ${r.cnt}`} />
{i % 7 === 6 && <div className="text-xs text-gray-600 whitespace-nowrap">
{new Date(r.day).toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' })}
</div>}
</div>
));
})()}
</div>
</div>
</div>
)}
</>)}
</div>
);
}