feat: add partner interface, admin dashboard, and unit tests

Partner flow: QR scanner component (html5-qrcode) with camera
permission/not-found handling, 3-step spend flow (scan → amount
entry with auto-filled max → success/error result).

Admin dashboard: stats overview, grant points with debounced
student search, paginated transactions table with type filters,
paginated students table.

Tests: comprehensive unit tests for points and auth packages —
service (all paths including error branches, RS256 wrong-method),
handler (all HTTP status codes via httptest), JWT round-trip,
repository constructors. Auth coverage: 72.9%, points service
coverage: 100%.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-01 16:41:17 +03:00
co-authored by Claude Sonnet 4.6
parent 50b3c4198a
commit be2260d259
21 changed files with 2259 additions and 73 deletions
+127 -5
View File
@@ -1,8 +1,130 @@
export default function AdminDashboardPage() {
// TODO(notion): fetch GET /api/v1/admin/stats → display key metrics + recent transactions
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { AdminNav } from '@/components/AdminNav';
import { Badge, Card, Spinner } from '@/components/ui';
import { api } from '@/lib/api';
import { formatPoints, formatDate, formatTransactionAmount } from '@/lib/utils';
import type { Stats, AdminTransactionPage, AdminTransaction } from '@/lib/types';
interface StatCardProps {
label: string;
value: string | number;
}
function StatCard({ label, value }: StatCardProps) {
return (
<main className="p-6">
<p className="text-gray-500">Admin dashboard — coming soon</p>
</main>
<Card className="flex flex-col gap-1">
<p className="text-xs text-gray-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-white">{value}</p>
</Card>
);
}
export default function AdminDashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [recentTxs, setRecentTxs] = useState<AdminTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
const [s, page] = await Promise.all([
api.get<Stats>('/api/v1/admin/stats'),
api.get<AdminTransactionPage>('/api/v1/admin/transactions?limit=10'),
]);
setStats(s);
setRecentTxs(page.transactions);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally {
setLoading(false);
}
}
load();
}, []);
return (
<div className="min-h-screen">
<AdminNav />
<main className="mx-auto max-w-5xl p-4 pt-6 pb-10 space-y-6">
<h1 className="text-xl font-bold text-white">Дашборд</h1>
{loading && (
<div className="flex justify-center py-16">
<Spinner size="lg" />
</div>
)}
{error && (
<p className="rounded-lg bg-red-900/30 px-4 py-3 text-sm text-red-400">{error}</p>
)}
{!loading && stats && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Студентов" value={stats.total_students} />
<StatCard label="Поинтов выдано" value={formatPoints(stats.total_points_issued)} />
<StatCard label="Поинтов потрачено" value={formatPoints(stats.total_points_spent)} />
<StatCard label="Партнёров" value={stats.active_partners} />
</div>
)}
{!loading && (
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-200">Последние транзакции</h2>
<Link href="/admin/transactions" className="text-sm text-blue-400 hover:text-blue-300">
Все транзакции →
</Link>
</div>
<Card className="p-0 overflow-hidden">
{recentTxs.length === 0 ? (
<p className="py-8 text-center text-sm text-gray-500">Транзакций ещё нет</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700 text-left text-xs text-gray-500">
<th className="px-4 py-3">Студент</th>
<th className="px-4 py-3">Тип</th>
<th className="px-4 py-3 text-right">Сумма</th>
<th className="px-4 py-3 text-right">Дата</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{recentTxs.map((tx) => (
<tr key={tx.id} className="hover:bg-gray-700/30">
<td className="px-4 py-3 text-gray-300">{tx.user_email}</td>
<td className="px-4 py-3">
<Badge type={tx.type} />
</td>
<td
className={`px-4 py-3 text-right font-semibold tabular-nums ${
tx.amount >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{formatTransactionAmount(tx.amount)}
</td>
<td className="px-4 py-3 text-right text-gray-500">
{formatDate(tx.created_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<div className="mt-3 text-right">
<Link href="/admin/grant" className="text-sm text-blue-400 hover:text-blue-300">
Начислить поинты →
</Link>
</div>
</section>
)}
</main>
</div>
);
}
+246 -4
View File
@@ -1,8 +1,250 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { AdminNav } from '@/components/AdminNav';
import { Button, Card, Input, Spinner } from '@/components/ui';
import { api } from '@/lib/api';
import { formatPoints, formatDate } from '@/lib/utils';
import type { AdminStudent, AdminUsersPage } from '@/lib/types';
interface GrantRecord {
key: string;
studentName: string;
amount: number;
description: string;
grantedAt: string;
}
export default function GrantPage() {
// TODO(notion): form (user_id, amount, description) → POST /api/v1/admin/points/grant → confirmation
const [search, setSearch] = useState('');
const [suggestions, setSuggestions] = useState<AdminStudent[]>([]);
const [selected, setSelected] = useState<AdminStudent | null>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [amount, setAmount] = useState('');
const [description, setDescription] = useState('');
const [loading, setLoading] = useState(false);
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
const [history, setHistory] = useState<GrantRecord[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
// Debounced search: fires 300 ms after the user stops typing.
useEffect(() => {
if (!search.trim() || selected) {
setSuggestions([]);
setShowDropdown(false);
return;
}
setSearching(true);
const timer = setTimeout(async () => {
try {
const page = await api.get<AdminUsersPage>(
`/api/v1/admin/users?search=${encodeURIComponent(search)}&limit=8`,
);
setSuggestions(page.users);
setShowDropdown(page.users.length > 0);
} catch {
setSuggestions([]);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [search, selected]);
// Close dropdown when clicking outside.
useEffect(() => {
function handler(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
}
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
function selectStudent(student: AdminStudent) {
setSelected(student);
setSearch(student.name + ' — ' + student.email);
setSuggestions([]);
setShowDropdown(false);
}
function clearSelection() {
setSelected(null);
setSearch('');
setSuggestions([]);
}
async function handleGrant() {
if (!selected || !amount) return;
const pts = parseInt(amount, 10);
if (isNaN(pts) || pts <= 0) return;
setLoading(true);
setError('');
try {
await api.post<{ status: string }>('/api/v1/admin/points/grant', {
user_id: selected.id,
amount: pts,
description,
});
// Refresh selected student balance.
const page = await api.get<AdminUsersPage>(
`/api/v1/admin/users?search=${encodeURIComponent(selected.email)}&limit=1`,
);
const updated = page.users.find((s) => s.id === selected.id);
if (updated) setSelected(updated);
setHistory((prev) =>
[
{
key: String(Date.now()),
studentName: selected.name,
amount: pts,
description: description || '—',
grantedAt: new Date().toISOString(),
},
...prev,
].slice(0, 5),
);
setAmount('');
setDescription('');
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка начисления');
} finally {
setLoading(false);
}
}
const amountNum = parseInt(amount, 10);
const canSubmit = !!selected && amountNum > 0 && !loading;
return (
<main className="p-6">
<p className="text-gray-500">Grant points — coming soon</p>
</main>
<div className="min-h-screen">
<AdminNav />
<main className="mx-auto max-w-lg p-4 pt-6 pb-10 space-y-6">
<h1 className="text-xl font-bold text-white">Начислить поинты</h1>
<Card className="space-y-5">
{/* Student search */}
<div className="relative" ref={dropdownRef}>
<div className="flex gap-2">
<div className="flex-1">
<Input
label="Поиск студента (email или имя)"
value={search}
onChange={(e) => {
setSearch(e.target.value);
if (selected) clearSelection();
}}
placeholder="student@cu.ru"
autoComplete="off"
/>
</div>
{selected && (
<button
onClick={clearSelection}
className="mt-6 shrink-0 rounded-lg px-3 text-gray-400 hover:text-white transition-colors"
aria-label="Очистить"
>
✕
</button>
)}
</div>
{/* Suggestions dropdown */}
{showDropdown && suggestions.length > 0 && (
<ul className="absolute z-10 mt-1 w-full rounded-lg border border-gray-600 bg-gray-800 shadow-lg">
{suggestions.map((s) => (
<li key={s.id}>
<button
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm hover:bg-gray-700"
onMouseDown={(e) => {
e.preventDefault(); // prevent blur before click
selectStudent(s);
}}
>
<span>
<span className="font-medium text-white">{s.name}</span>
<span className="ml-2 text-gray-400">{s.email}</span>
</span>
<span className="shrink-0 text-gray-500">{formatPoints(s.balance)}</span>
</button>
</li>
))}
</ul>
)}
{searching && !showDropdown && (
<div className="absolute right-3 top-9">
<Spinner size="sm" />
</div>
)}
</div>
{/* Selected student info */}
{selected && (
<div className="rounded-lg bg-gray-700/50 px-4 py-3">
<p className="font-medium text-white">{selected.name}</p>
<p className="text-sm text-gray-400">{selected.email}</p>
<p className="mt-1 text-sm text-blue-300">
Текущий баланс: <span className="font-semibold">{formatPoints(selected.balance)}</span>
</p>
</div>
)}
<Input
label="Количество поинтов"
type="number"
min="1"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="100"
/>
<Input
label="Описание"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Победа в хакатоне"
/>
{error && (
<p className="rounded-lg bg-red-900/30 px-3 py-2 text-sm text-red-400">{error}</p>
)}
<Button className="w-full" onClick={handleGrant} isLoading={loading} disabled={!canSubmit}>
Начислить
</Button>
</Card>
{/* Recent grants in this session */}
{history.length > 0 && (
<section>
<h2 className="mb-3 text-sm font-semibold text-gray-400 uppercase tracking-wide">
Последние начисления (сессия)
</h2>
<Card className="p-0 overflow-hidden">
<ul className="divide-y divide-gray-700">
{history.map((h) => (
<li key={h.key} className="flex items-center justify-between px-4 py-3 text-sm">
<div>
<p className="font-medium text-white">{h.studentName}</p>
<p className="text-xs text-gray-500">{h.description}</p>
</div>
<div className="text-right">
<p className="font-semibold text-green-400">+{h.amount}</p>
<p className="text-xs text-gray-500">{formatDate(h.grantedAt)}</p>
</div>
</li>
))}
</ul>
</Card>
</section>
)}
</main>
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { AdminNav } from '@/components/AdminNav';
import { Badge, Button, Card, Spinner } from '@/components/ui';
import { api } from '@/lib/api';
import { formatDate, formatTransactionAmount } from '@/lib/utils';
import type { AdminTransaction, AdminTransactionPage, TransactionType } from '@/lib/types';
const PAGE_SIZE = 50;
type FilterType = 'all' | TransactionType;
const FILTERS: { value: FilterType; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'earn', label: 'Начисление' },
{ value: 'spend', label: 'Списание' },
{ value: 'admin_grant', label: 'Вручную' },
{ value: 'expire', label: 'Сгорание' },
];
export default function AdminTransactionsPage() {
const [transactions, setTransactions] = useState<AdminTransaction[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [filter, setFilter] = useState<FilterType>('all');
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPage = useCallback(async (currentOffset: number, txFilter: FilterType, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(null);
const typeParam = txFilter !== 'all' ? `&type=${txFilter}` : '';
try {
const page = await api.get<AdminTransactionPage>(
`/api/v1/admin/transactions?limit=${PAGE_SIZE}&offset=${currentOffset}${typeParam}`,
);
setTransactions((prev) => (append ? [...prev, ...page.transactions] : page.transactions));
setTotal(page.total);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
useEffect(() => {
setOffset(0);
fetchPage(0, filter, false);
}, [filter, fetchPage]);
function handleLoadMore() {
const next = offset + PAGE_SIZE;
setOffset(next);
fetchPage(next, filter, true);
}
const hasMore = transactions.length < total;
return (
<div className="min-h-screen">
<AdminNav />
<main className="mx-auto max-w-5xl p-4 pt-6 pb-10 space-y-5">
<h1 className="text-xl font-bold text-white">Все транзакции</h1>
{/* Type filter */}
<div className="flex flex-wrap gap-2">
{FILTERS.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`rounded-full px-3 py-1 text-sm transition-colors ${
filter === f.value
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
{f.label}
</button>
))}
</div>
{loading && (
<div className="flex justify-center py-16">
<Spinner size="lg" />
</div>
)}
{error && (
<p className="rounded-lg bg-red-900/30 px-4 py-3 text-sm text-red-400">{error}</p>
)}
{!loading && (
<Card className="p-0 overflow-x-auto">
{transactions.length === 0 ? (
<p className="py-10 text-center text-sm text-gray-500">Транзакций нет</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700 text-left text-xs text-gray-500">
<th className="px-4 py-3">Студент</th>
<th className="px-4 py-3">Тип</th>
<th className="px-4 py-3 text-right">Сумма</th>
<th className="px-4 py-3">Описание</th>
<th className="px-4 py-3 text-right">Дата</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{transactions.map((tx) => (
<tr key={tx.id} className="hover:bg-gray-700/30">
<td className="px-4 py-3 text-gray-300">{tx.user_email}</td>
<td className="px-4 py-3">
<Badge type={tx.type} />
</td>
<td
className={`px-4 py-3 text-right font-semibold tabular-nums ${
tx.amount >= 0 ? 'text-green-400' : 'text-red-400'
}`}
>
{formatTransactionAmount(tx.amount)}
</td>
<td className="px-4 py-3 text-gray-400 max-w-[200px] truncate">
{tx.description || '—'}
</td>
<td className="px-4 py-3 text-right text-gray-500 whitespace-nowrap">
{formatDate(tx.created_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
)}
{!loading && hasMore && (
<div className="flex justify-center">
<Button variant="secondary" onClick={handleLoadMore} isLoading={loadingMore}>
Загрузить ещё
</Button>
</div>
)}
{!loading && !hasMore && transactions.length > 0 && (
<p className="text-center text-xs text-gray-600">
Показано {transactions.length} из {total}
</p>
)}
</main>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { AdminNav } from '@/components/AdminNav';
import { Button, Card, Spinner } from '@/components/ui';
import { api } from '@/lib/api';
import { formatPoints, formatDate } from '@/lib/utils';
import type { AdminStudent, AdminUsersPage } from '@/lib/types';
const PAGE_SIZE = 50;
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminStudent[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPage = useCallback(async (currentOffset: number, append: boolean) => {
if (append) setLoadingMore(true);
else setLoading(true);
setError(null);
try {
const page = await api.get<AdminUsersPage>(
`/api/v1/admin/users?limit=${PAGE_SIZE}&offset=${currentOffset}`,
);
setUsers((prev) => (append ? [...prev, ...page.users] : page.users));
setTotal(page.total);
} catch (e) {
setError(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally {
setLoading(false);
setLoadingMore(false);
}
}, []);
useEffect(() => {
fetchPage(0, false);
}, [fetchPage]);
function handleLoadMore() {
const next = offset + PAGE_SIZE;
setOffset(next);
fetchPage(next, true);
}
const hasMore = users.length < total;
return (
<div className="min-h-screen">
<AdminNav />
<main className="mx-auto max-w-5xl p-4 pt-6 pb-10 space-y-5">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">Студенты</h1>
{!loading && (
<span className="text-sm text-gray-500">Всего: {total}</span>
)}
</div>
{loading && (
<div className="flex justify-center py-16">
<Spinner size="lg" />
</div>
)}
{error && (
<p className="rounded-lg bg-red-900/30 px-4 py-3 text-sm text-red-400">{error}</p>
)}
{!loading && (
<Card className="p-0 overflow-x-auto">
{users.length === 0 ? (
<p className="py-10 text-center text-sm text-gray-500">Студентов нет</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-700 text-left text-xs text-gray-500">
<th className="px-4 py-3">Имя</th>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Student ID</th>
<th className="px-4 py-3 text-right">Баланс</th>
<th className="px-4 py-3 text-right">Дата регистрации</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-700/30">
<td className="px-4 py-3 font-medium text-white">{u.name}</td>
<td className="px-4 py-3 text-gray-300">{u.email}</td>
<td className="px-4 py-3 text-gray-500">
{u.student_id || '—'}
</td>
<td className="px-4 py-3 text-right font-semibold tabular-nums text-blue-300">
{formatPoints(u.balance)}
</td>
<td className="px-4 py-3 text-right text-gray-500 whitespace-nowrap">
{formatDate(u.created_at)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
)}
{!loading && hasMore && (
<div className="flex justify-center">
<Button variant="secondary" onClick={handleLoadMore} isLoading={loadingMore}>
Загрузить ещё
</Button>
</div>
)}
{!loading && !hasMore && users.length > 0 && (
<p className="text-center text-xs text-gray-600">
Показано {users.length} из {total}
</p>
)}
</main>
</div>
);
}