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
+44
View File
@@ -0,0 +1,44 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/lib/store';
const LINKS = [
{ href: '/admin/dashboard', label: 'Дашборд' },
{ href: '/admin/grant', label: 'Начислить' },
{ href: '/admin/transactions', label: 'Транзакции' },
{ href: '/admin/users', label: 'Студенты' },
] as const;
export function AdminNav() {
const pathname = usePathname();
const { logout } = useAuthStore();
return (
<nav className="border-b border-gray-700 bg-gray-900">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-1 px-4 py-3">
<span className="mr-4 text-sm font-semibold text-white">CU Points Admin</span>
{LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className={`rounded-lg px-3 py-1.5 text-sm transition-colors ${
pathname === link.href
? 'bg-blue-600 text-white'
: 'text-gray-400 hover:bg-gray-700 hover:text-white'
}`}
>
{link.label}
</Link>
))}
<button
onClick={logout}
className="ml-auto rounded-lg px-3 py-1.5 text-sm text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
>
Выйти
</button>
</div>
</nav>
);
}
+89
View File
@@ -0,0 +1,89 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Spinner } from '@/components/ui';
interface QRScannerProps {
onScan: (token: string) => void;
}
export function QRScanner({ onScan }: QRScannerProps) {
const [cameraError, setCameraError] = useState<string | null>(null);
const [starting, setStarting] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
// Keep a stable ref to onScan so restarting the scanner when the parent
// re-renders (e.g. state changes in the parent) is not needed.
const onScanRef = useRef(onScan);
onScanRef.current = onScan;
const stopRef = useRef<(() => Promise<void>) | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Use a unique id per mount — prevents html5-qrcode conflicts on StrictMode
// double-invoke (the second run would find the first run's leftover DOM).
const id = `qr-${Date.now()}`;
container.id = id;
let cancelled = false;
import('html5-qrcode').then(({ Html5Qrcode }) => {
if (cancelled) return;
const scanner = new Html5Qrcode(id);
stopRef.current = () => scanner.stop();
scanner
.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 220, height: 220 } },
(text) => {
if (!cancelled) onScanRef.current(text);
scanner.stop().catch(() => {});
},
undefined,
)
.then(() => {
if (!cancelled) setStarting(false);
})
.catch((err: unknown) => {
if (cancelled) return;
setStarting(false);
const msg = String(err).toLowerCase();
if (msg.includes('permission') || msg.includes('notallowed')) {
setCameraError('Нет доступа к камере. Разрешите доступ в настройках браузера.');
} else if (msg.includes('notfound') || msg.includes('no camera') || msg.includes('no cameras')) {
setCameraError('Камера не найдена на этом устройстве.');
} else {
setCameraError('Не удалось запустить камеру.');
}
});
});
return () => {
cancelled = true;
stopRef.current?.().catch(() => {});
};
}, []); // intentionally empty — scanner starts once on mount
if (cameraError) {
return (
<div className="flex min-h-[240px] items-center justify-center rounded-2xl bg-gray-800 p-6 text-center ring-1 ring-gray-700">
<p className="text-sm text-red-400">{cameraError}</p>
</div>
);
}
return (
<div className="relative min-h-[240px] overflow-hidden rounded-2xl bg-black ring-1 ring-gray-700">
{starting && (
<div className="absolute inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
{/* html5-qrcode injects <video> and overlay elements here */}
<div ref={containerRef} className="w-full" />
</div>
);
}