Files
CU_Points/frontend/app/(partner)/scan/page.tsx
T
emilandClaude Sonnet 4.6 be2260d259 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>
2026-05-01 16:41:17 +03:00

204 lines
7.1 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { QRScanner } from '@/components/QRScanner';
import { Button, Card, Input } from '@/components/ui';
import { api } from '@/lib/api';
import { formatPoints } from '@/lib/utils';
import { useAuthStore } from '@/lib/store';
import type { SpendResult } from '@/lib/types';
type Step = 'scan' | 'form' | 'result';
// Default cap: partner can spend at most 50% of the purchase total in points.
// The backend validates against the actual partner's max_spend_pct.
const DEFAULT_MAX_SPEND_PCT = 0.5;
export default function ScanPage() {
const { user, logout } = useAuthStore();
const [step, setStep] = useState<Step>('scan');
const [qrToken, setQrToken] = useState('');
const [purchaseTotal, setPurchaseTotal] = useState('');
const [pointsToSpend, setPointsToSpend] = useState('');
const [result, setResult] = useState<SpendResult | null>(null);
const [errorMsg, setErrorMsg] = useState('');
const [loading, setLoading] = useState(false);
const handleScan = useCallback((token: string) => {
setQrToken(token);
setStep('form');
}, []);
function handlePurchaseTotalChange(val: string) {
setPurchaseTotal(val);
const total = parseFloat(val);
if (!isNaN(total) && total > 0) {
setPointsToSpend(String(Math.floor(total * DEFAULT_MAX_SPEND_PCT)));
} else {
setPointsToSpend('');
}
}
function handlePointsChange(val: string) {
const total = parseFloat(purchaseTotal);
const max = Math.floor(total * DEFAULT_MAX_SPEND_PCT);
const entered = parseInt(val, 10);
// Prevent partner from setting points above the allowed cap.
if (!isNaN(entered) && !isNaN(max) && entered > max) {
setPointsToSpend(String(max));
} else {
setPointsToSpend(val);
}
}
const totalNum = parseFloat(purchaseTotal) || 0;
const pointsNum = parseInt(pointsToSpend, 10) || 0;
const remainder = Math.max(0, totalNum - pointsNum);
async function handleSubmit() {
if (pointsNum <= 0) return;
setLoading(true);
setErrorMsg('');
try {
const data = await api.post<SpendResult>('/api/v1/partner/spend', {
qr_token: qrToken,
amount: pointsNum,
});
setResult(data);
setStep('result');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Ошибка';
if (msg.includes('insufficient')) {
setErrorMsg('Недостаточно поинтов на балансе студента.');
} else if (msg.includes('already been used')) {
setErrorMsg('Этот QR-код уже был использован. Попросите студента создать новый.');
} else if (msg.includes('invalid or expired') || msg.includes('expired')) {
setErrorMsg('QR-код недействителен или истёк. Попросите студента создать новый QR.');
} else {
setErrorMsg(msg);
}
setStep('result');
} finally {
setLoading(false);
}
}
function reset() {
setStep('scan');
setQrToken('');
setPurchaseTotal('');
setPointsToSpend('');
setResult(null);
setErrorMsg('');
}
return (
<div className="flex min-h-screen flex-col">
{/* Partner header */}
<header className="border-b border-gray-700 bg-gray-900 px-4 py-3">
<div className="mx-auto flex max-w-md items-center justify-between">
<span className="text-sm font-semibold text-white">
CU Points — {user?.name ?? 'Партнёр'}
</span>
<button
onClick={logout}
className="text-sm text-gray-400 hover:text-white transition-colors"
>
Выйти
</button>
</div>
</header>
<main className="mx-auto w-full max-w-md flex-1 p-4 pt-6 pb-10">
<h1 className="mb-6 text-xl font-bold text-white">Оплата поинтами</h1>
{/* ── Step 1: QR scan ─────────────────────────────── */}
{step === 'scan' && (
<div className="space-y-3">
<p className="text-sm text-gray-400">Наведи камеру на QR студента</p>
<QRScanner onScan={handleScan} />
</div>
)}
{/* ── Step 2: Amount entry ─────────────────────────── */}
{step === 'form' && (
<Card className="space-y-5">
<div className="flex items-center gap-2">
<span className="text-green-400 text-lg font-bold">✓</span>
<p className="font-medium text-green-400">Студент отсканирован</p>
</div>
<Input
label="Сумма покупки (₽)"
type="number"
min="1"
step="1"
value={purchaseTotal}
onChange={(e) => handlePurchaseTotalChange(e.target.value)}
placeholder="0"
autoFocus
/>
<Input
label="Списать поинтов"
type="number"
min="0"
max={Math.floor(totalNum * DEFAULT_MAX_SPEND_PCT)}
value={pointsToSpend}
onChange={(e) => handlePointsChange(e.target.value)}
placeholder="0"
/>
{totalNum > 0 && (
<p className="text-sm text-gray-400">
Остаток к оплате:{' '}
<span className="font-semibold text-gray-200">{remainder.toFixed(0)} ₽</span>
</p>
)}
<Button
className="w-full"
onClick={handleSubmit}
isLoading={loading}
disabled={pointsNum <= 0 || totalNum <= 0}
>
Списать поинты
</Button>
<Button variant="ghost" className="w-full" onClick={reset}>
Отмена
</Button>
</Card>
)}
{/* ── Step 3: Result ───────────────────────────────── */}
{step === 'result' && (
<Card className="space-y-4 text-center">
{result ? (
<>
<p className="text-5xl">✓</p>
<p className="text-xl font-semibold text-green-400">
Списано {result.spent} поинтов
</p>
<p className="text-sm text-gray-400">
Новый баланс студента:{' '}
<span className="font-medium text-gray-200">
{formatPoints(result.new_balance)}
</span>
</p>
</>
) : (
<>
<p className="text-5xl">✗</p>
<p className="text-sm text-red-400">{errorMsg || 'Неизвестная ошибка'}</p>
</>
)}
<Button className="w-full mt-2" onClick={reset}>
Новая операция
</Button>
</Card>
)}
</main>
</div>
);
}