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:
co-authored by
Claude Sonnet 4.6
parent
50b3c4198a
commit
be2260d259
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user