Initial commit: digital reception monorepo (M1-M11 + demo extensions)

This commit is contained in:
2026-05-25 12:59:54 +05:00
commit b9f88194d9
182 changed files with 20578 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
NEXT_PUBLIC_APP_NAME="Цифровая рецепция"
API_BASE_URL=http://localhost:4000
API_BASE_URL_INTERNAL=http://localhost:4000
+60
View File
@@ -0,0 +1,60 @@
# web-admin
Next.js 15 App Router + shadcn/ui (стиль `new-york`, base `neutral`) + Recharts + TanStack Query.
Порт: **3001** (3000 занят `hr_v2_next`).
## Запуск
```bash
pnpm --filter=@reception/web-admin dev
# → http://localhost:3001
```
Логин: dev-юзеры из `packages/db/prisma/seed.ts`:
- `senior@local / senior123` — Старший администратор (видит Enrollment, Пациенты)
- `manager@local / manager123` — Управляющий (Дашборд, Пациенты)
- `admin@local / admin123` — Админ системы (Аудит, Пациенты)
- `security@local / security123` — Безопасность (Инциденты)
## Структура
```
src/
app/
layout.tsx # root layout с ThemeProvider + Toaster
page.tsx # redirect по роли
login/page.tsx # вход
(authed)/
layout.tsx # серверный гард + AppShell
dashboard/page.tsx # Управляющий
enrollment/page.tsx # Старший администратор (M10)
patients/ # M11
incidents/page.tsx # Безопасность (заглушка)
audit/page.tsx # Админ системы (M11)
components/
app-shell.tsx # сайдбар + топбар (фильтр nav по роли)
login-form.tsx # форма входа
theme-provider.tsx # next-themes
ui/ # shadcn компоненты
lib/
auth.ts # серверные actions: login/logout/getCurrentUser
api.ts # серверный fetch с проксированием cookies
utils.ts # cn, formatDateTime, formatDuration
middleware.ts # редирект на /login если нет access_token
```
## Эндпоинты apps/api, используемые в web-admin
| Метод | Путь | Где |
|---|---|---|
| `POST` | `/auth/login` | login form (server action) |
| `POST` | `/auth/logout` | header logout (server action) |
| `GET` | `/auth/me` | `getCurrentUser()` |
| `GET` | `/tracks?status=UNMATCHED` | /enrollment (M10) |
| `GET` | `/polimed/appointments?date=...` | /enrollment (M10) |
| `POST` | `/enrollment` | /enrollment submit (M10) |
| `GET` | `/patients` | /patients (M11) |
| `GET` | `/patients/:id/visits` | /patients/:id (M11) |
| `POST` | `/consents/:patientId/revoke` | /patients revoke (M11) |
| `GET` | `/audit/biometry` | /audit (M11) |
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: ['lucide-react'],
},
// Подавляем prisma/db warnings (мы используем @reception/db только для типов).
transpilePackages: ['@reception/db'],
};
export default nextConfig;
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@reception/web-admin",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start --port 3001",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.1",
"@reception/db": "workspace:*",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.460.0",
"next": "^15.0.4",
"next-themes": "^0.4.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.53.2",
"recharts": "^2.13.3",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@reception/eslint-config": "workspace:*",
"@reception/tsconfig": "workspace:*",
"@types/node": "^22.9.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",
"autoprefixer": "^10.4.20",
"eslint": "^9.14.0",
"eslint-config-next": "^15.0.4",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3"
}
}
+3
View File
@@ -0,0 +1,3 @@
export default {
plugins: { tailwindcss: {}, autoprefixer: {} },
};
@@ -0,0 +1,86 @@
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { apiFetchSafe } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
interface AuditEntry {
id: string;
action: string;
requestPath: string | null;
occurredAt: string;
actorUserId: string | null;
subjectPatientId: string | null;
actor: { email: string; fullName: string; role: string } | null;
}
const ACTION_LABELS: Record<string, string> = {
enroll: 'Enrollment',
consent_revoke: 'Отзыв согласия',
consent_revocation_completed: 'Удаление эмбеддингов',
recognition_probe: 'Проверка узнавания',
view_patient_visits: 'Просмотр визитов',
view_track: 'Просмотр трека',
};
export default async function AuditPage() {
const res = await apiFetchSafe<AuditEntry[]>('/audit/biometry?limit=200');
const entries = 'data' in res ? res.data : [];
const error = 'error' in res ? res.error.message : null;
return (
<Card>
<CardHeader>
<CardTitle>Аудит биометрии</CardTitle>
<CardDescription>
Журнал доступа к биометрическим ПДн. Каждый просмотр / изменение фиксируется автоматически.
</CardDescription>
</CardHeader>
<CardContent>
{error ? (
<p className="text-sm text-destructive">Ошибка: {error}</p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-foreground">Записей нет.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Время</TableHead>
<TableHead>Действие</TableHead>
<TableHead>Сотрудник</TableHead>
<TableHead>Пациент</TableHead>
<TableHead>Путь</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((e) => (
<TableRow key={e.id}>
<TableCell className="text-xs">{formatDateTime(e.occurredAt)}</TableCell>
<TableCell>
<Badge variant="secondary">{ACTION_LABELS[e.action] ?? e.action}</Badge>
</TableCell>
<TableCell className="text-xs">
{e.actor ? (
<>
<span className="font-medium">{e.actor.fullName}</span>
<span className="text-muted-foreground"> · {e.actor.role}</span>
</>
) : (
<span className="text-muted-foreground italic">внутренний</span>
)}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{e.subjectPatientId?.slice(0, 8) ?? '—'}
</TableCell>
<TableCell className="font-mono text-[11px] text-muted-foreground">
{e.requestPath ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,321 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { CameraOff, CheckCircle2, Loader2, RefreshCw, UserCheck, UserX } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
const CAPTURE_INTERVAL_MS = 2000;
const API_BASE = '/api/capture';
type CaptureResult = {
embedding: { id: string; quality: number } | null;
faceDetected: boolean;
recognized: {
patientId: string;
fullName: string | null;
confidence: number | null;
} | null;
};
interface Stats {
framesProcessed: number;
facesDetected: number;
embeddingsSaved: number;
lastRecognized: { fullName: string | null; patientId: string } | null;
}
interface CameraOption {
id: string;
name: string;
zoneCode: string;
zoneName: string;
}
const ZONE_DESCRIPTIONS: Record<string, string> = {
A: 'Вход — событие arrived',
B: 'Коридор — событие waiting',
C: 'Рецепция — обслуживание (service_started/ended)',
D: 'Перед кабинетом — ожидание врача',
E: 'В кабинете — обслуживание врачом',
};
export function CaptureClient() {
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const trackIdRef = useRef<string | null>(null);
const cameraNameRef = useRef<string | null>(null);
const busyRef = useRef(false);
const [cameraError, setCameraError] = useState<string | null>(null);
const [cameras, setCameras] = useState<CameraOption[]>([]);
const [selectedCamera, setSelectedCamera] = useState<string>('cam-entrance');
const [trackId, setTrackId] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const [stats, setStats] = useState<Stats>({
framesProcessed: 0,
facesDetected: 0,
embeddingsSaved: 0,
lastRecognized: null,
});
// Подгружаем список камер на маунте.
useEffect(() => {
fetch('/api/capture/cameras')
.then((r) => r.json())
.then((list: CameraOption[]) => {
setCameras(list);
if (list.length > 0 && !list.find((c) => c.name === selectedCamera)) {
setSelectedCamera(list[0]!.name);
}
})
.catch((err) => console.error('Не удалось загрузить камеры:', err));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Запуск веб-камеры ноутбука.
useEffect(() => {
let cancelled = false;
navigator.mediaDevices
.getUserMedia({ video: { width: 640, height: 480, facingMode: 'user' } })
.then((stream) => {
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) videoRef.current.srcObject = stream;
})
.catch((err) => setCameraError(String(err)));
return () => {
cancelled = true;
streamRef.current?.getTracks().forEach((t) => t.stop());
};
}, []);
const startSession = useCallback(async () => {
setRunning(false);
setStats({ framesProcessed: 0, facesDetected: 0, embeddingsSaved: 0, lastRecognized: null });
try {
const res = await fetch(`${API_BASE}/start-track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cameraName: selectedCamera }),
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`HTTP ${res.status}: ${txt}`);
}
const data = (await res.json()) as { trackId: string };
trackIdRef.current = data.trackId;
cameraNameRef.current = selectedCamera;
setTrackId(data.trackId);
setRunning(true);
toast.success(`Сессия начата на ${selectedCamera}`, {
description: `Track ${data.trackId.slice(0, 8)}`,
});
} catch (err) {
toast.error('Не удалось создать трек', { description: String(err) });
}
}, [selectedCamera]);
const captureFrame = useCallback((): string | null => {
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas || video.readyState < 2) return null;
canvas.width = video.videoWidth || 640;
canvas.height = video.videoHeight || 480;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0);
return canvas.toDataURL('image/jpeg', 0.85);
}, []);
useEffect(() => {
if (!running) return;
const interval = setInterval(async () => {
if (busyRef.current || !trackIdRef.current || !cameraNameRef.current) return;
const frame = captureFrame();
if (!frame) return;
busyRef.current = true;
try {
const res = await fetch(`${API_BASE}/frame`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
trackId: trackIdRef.current,
cameraName: cameraNameRef.current,
frame,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as CaptureResult;
setStats((s) => ({
framesProcessed: s.framesProcessed + 1,
facesDetected: s.facesDetected + (data.faceDetected ? 1 : 0),
embeddingsSaved: s.embeddingsSaved + (data.embedding ? 1 : 0),
lastRecognized: data.recognized
? { fullName: data.recognized.fullName, patientId: data.recognized.patientId }
: s.lastRecognized,
}));
} catch (err) {
console.error(err);
} finally {
busyRef.current = false;
}
}, CAPTURE_INTERVAL_MS);
return () => clearInterval(interval);
}, [running, captureFrame]);
// Группируем камеры по зонам для удобства селектора.
const camerasByZone = cameras.reduce<Record<string, CameraOption[]>>((acc, c) => {
if (!acc[c.zoneCode]) acc[c.zoneCode] = [];
acc[c.zoneCode]!.push(c);
return acc;
}, {});
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Эмуляция перехода по зонам</CardTitle>
<CardDescription>
Выбери зону/камеру, нажми «Начать» кадры с веб-камеры пишутся в трек на этой камере. Чтобы
эмулировать движение (ABCDE), повторяй «Новая сессия» с разными камерами face-service
склеит треки cross-camera по эмбеддингу в окне 5 мин.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
{Object.keys(camerasByZone).sort().map((zoneCode) => (
<div key={zoneCode}>
<div className="mb-1 text-xs font-medium text-muted-foreground">
Зона {zoneCode} {ZONE_DESCRIPTIONS[zoneCode] ?? ''}
</div>
<div className="flex flex-wrap gap-1.5">
{camerasByZone[zoneCode]!.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setSelectedCamera(c.name)}
className={cn(
'rounded-md border px-2.5 py-1 text-xs font-mono transition-colors',
selectedCamera === c.name
? 'border-primary bg-primary text-primary-foreground'
: 'border-input hover:bg-accent',
)}
>
{c.name}
</button>
))}
</div>
</div>
))}
</div>
{cameraError ? (
<div className="flex h-64 flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-muted text-sm text-muted-foreground">
<CameraOff className="size-8" />
Нет доступа к камере ноутбука: {cameraError}
</div>
) : (
<div className="relative overflow-hidden rounded-md border bg-muted">
<video
ref={videoRef}
autoPlay
playsInline
muted
className="aspect-[4/3] w-full -scale-x-100 object-cover"
/>
{stats.lastRecognized && (
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-green-600/90 px-4 py-2 text-white">
<UserCheck className="size-5" />
<span className="font-semibold">{stats.lastRecognized.fullName ?? 'Удалено'}</span>
</div>
)}
{running && !stats.lastRecognized && stats.framesProcessed > 2 && (
<div className="absolute inset-x-0 bottom-0 flex items-center gap-2 bg-amber-600/90 px-4 py-2 text-white">
<UserX className="size-5" />
<span>Не узнан</span>
</div>
)}
</div>
)}
<canvas ref={canvasRef} className="hidden" />
<Button onClick={startSession} disabled={!!cameraError || !selectedCamera} className="w-full">
<RefreshCw className="size-4" />
{trackId ? `Новая сессия (${selectedCamera})` : `Начать (${selectedCamera})`}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Статус сессии</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{!trackId && (
<p className="text-muted-foreground">
Нажми «Начать» создастся новый трек на выбранной камере.
</p>
)}
{trackId && (
<>
<div className="flex items-center gap-2">
<Badge variant={running ? 'default' : 'outline'}>{running ? 'Идёт захват' : 'Пауза'}</Badge>
<span className="font-mono text-xs text-muted-foreground">
{cameraNameRef.current} · {trackId.slice(0, 8)}
</span>
</div>
<dl className="grid grid-cols-2 gap-2">
<dt className="text-muted-foreground">Кадров отправлено</dt>
<dd className="font-medium">{stats.framesProcessed}</dd>
<dt className="text-muted-foreground">Лиц обнаружено</dt>
<dd className="font-medium">{stats.facesDetected}</dd>
<dt className="text-muted-foreground">Эмбеддингов сохранено</dt>
<dd className="font-medium">{stats.embeddingsSaved}</dd>
</dl>
{stats.lastRecognized ? (
<div className="rounded-md border border-green-500/30 bg-green-500/10 p-3 text-sm">
<div className="flex items-center gap-2 font-medium text-green-700 dark:text-green-400">
<CheckCircle2 className="size-4" />
Узнан: {stats.lastRecognized.fullName ?? 'Удалено'}
</div>
<p className="mt-1 text-xs text-muted-foreground">
patient_id: <code>{stats.lastRecognized.patientId.slice(0, 12)}</code>
</p>
<Button asChild variant="link" size="sm" className="mt-1 h-auto p-0">
<Link href={`/patients/${stats.lastRecognized.patientId}`}>
Открыть карточку с journey
</Link>
</Button>
</div>
) : stats.embeddingsSaved >= 3 ? (
<div className="rounded-md border bg-muted p-3 text-sm">
<p className="font-medium">Лицо не распознано</p>
<p className="mt-1 text-xs text-muted-foreground">
Эмбеддинги сохранены ({stats.embeddingsSaved} шт). Если новый пациент
открой <Link href="/enrollment" className="underline">/enrollment</Link> и
привяжи трек к Полимед.
</p>
</div>
) : (
<p className="text-xs text-muted-foreground">
Жди, пока соберётся 3 эмбеддинга. Смотри прямо в камеру.
</p>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,32 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { CaptureClient } from './capture-client';
export default function CapturePage() {
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Камера face-service</CardTitle>
<CardDescription>
Демо «живого» захвата лица с ноутбука: создаём трек на камере <code>cam-entrance</code>,
каждые 2 секунды шлём кадр в face-service, сохраняем эмбеддинг и проверяем узнан ли
уже зарегистрированный пациент.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-1">
<p>
Сценарии: (1) <strong>Регистрация нового</strong> пройди ~10 сек перед камерой, потом открой
<code> /enrollment</code> и привяжи трек к пациенту Полимед. (2) <strong>Повторный визит</strong>{' '}
открой эту страницу снова система должна узнать тебя.
</p>
<p>
Если кадр без лица или лицо слишком в профиль face-service вернёт <code>null</code>{' '}
(лицо не обнаружено).
</p>
</CardContent>
</Card>
<CaptureClient />
</div>
);
}
@@ -0,0 +1,162 @@
import { AlertTriangle, Info } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetchSafe } from '@/lib/api';
import { formatDuration } from '@/lib/utils';
import { VisitsByHourChart } from './visits-by-hour-chart';
import { ZoneActivityChart } from './zone-activity-chart';
interface DashboardOverview {
date: string;
hasRealData: boolean;
cards: Array<{
label: string;
value: number;
unit?: string;
hint?: string;
synthetic?: boolean;
}>;
visitsByHour: Array<{ hour: number; visits: number }>;
zoneActivity: Array<{ code: string; events: number; tracks: number }>;
avgTimeInZoneSec: Array<{ code: string; seconds: number }>;
}
function todayIso() {
return new Date().toISOString().slice(0, 10);
}
const ZONE_LABELS: Record<string, string> = {
A: 'A · Вход',
B: 'B · Коридор',
C: 'C · Рецепция',
D: 'D · Перед врачом',
E: 'E · У врача',
};
export default async function DashboardPage() {
const date = todayIso();
const res = await apiFetchSafe<DashboardOverview>(`/dashboard/overview?date=${date}`);
if ('error' in res) {
return (
<Card>
<CardHeader>
<CardTitle>Ошибка загрузки</CardTitle>
<CardDescription>{res.error.message}</CardDescription>
</CardHeader>
</Card>
);
}
const data = res.data;
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>KPI рецепции</CardTitle>
<CardDescription>
{new Date(data.date).toLocaleDateString('ru-RU', { dateStyle: 'long' })}.{' '}
{data.hasRealData
? 'Метрики на основе фактических визитов.'
: 'Данные пока разрежены — на проде заполнится автоматически после старта video-ingest (Ф0).'}
</CardDescription>
</CardHeader>
</Card>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{data.cards.map((c) => (
<Card key={c.label}>
<CardHeader className="pb-2">
<CardDescription className="text-xs">{c.label}</CardDescription>
<CardTitle className="text-3xl">
{c.unit === 'сек' ? formatDuration(c.value) : c.value.toLocaleString('ru-RU')}
{c.unit && c.unit !== 'сек' && (
<span className="ml-1 text-sm font-normal text-muted-foreground">{c.unit}</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-0 text-[11px] text-muted-foreground">
<div className="flex items-center justify-between gap-2">
<span>{c.hint}</span>
{c.synthetic && (
<Badge variant="outline" className="gap-1 text-[10px]">
<AlertTriangle className="size-3" />
нет данных
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Визиты по часам</CardTitle>
<CardDescription>Сколько пациентов приходило в каждый час сегодня.</CardDescription>
</CardHeader>
<CardContent>
{data.visitsByHour.length === 0 ? (
<Empty>Сегодня визитов ещё не было.</Empty>
) : (
<VisitsByHourChart data={data.visitsByHour} />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Активность по зонам</CardTitle>
<CardDescription>
События и уникальные треки в каждой зоне за день.
</CardDescription>
</CardHeader>
<CardContent>
{data.zoneActivity.every((z) => z.events === 0) ? (
<Empty>За день нет событий по зонам.</Empty>
) : (
<ZoneActivityChart
data={data.zoneActivity.map((z) => ({
...z,
label: ZONE_LABELS[z.code] ?? z.code,
}))}
/>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Среднее время в зоне</CardTitle>
<CardDescription>
На основе разницы между первым и последним событием трека в зоне за день.
</CardDescription>
</CardHeader>
<CardContent>
{data.avgTimeInZoneSec.length === 0 ? (
<Empty>Нет треков с двумя и более событиями в одной зоне.</Empty>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{data.avgTimeInZoneSec.map((z) => (
<div key={z.code} className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">{ZONE_LABELS[z.code] ?? z.code}</div>
<div className="mt-1 text-2xl font-semibold">{formatDuration(z.seconds)}</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
function Empty({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-40 items-center justify-center gap-2 rounded-md border border-dashed text-sm text-muted-foreground">
<Info className="size-4" />
{children}
</div>
);
}
@@ -0,0 +1,32 @@
'use client';
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
export function VisitsByHourChart({ data }: { data: Array<{ hour: number; visits: number }> }) {
// Заполним пропущенные часы нулями, чтобы шкала была равномерной.
const filled = Array.from({ length: 24 }, (_, h) => {
const found = data.find((d) => d.hour === h);
return { hour: `${String(h).padStart(2, '0')}:00`, visits: found?.visits ?? 0 };
});
return (
<div className="h-64 w-full">
<ResponsiveContainer>
<BarChart data={filled}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="hour" stroke="hsl(var(--muted-foreground))" fontSize={11} interval={1} />
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={11} allowDecimals={false} />
<Tooltip
contentStyle={{
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '12px',
}}
/>
<Bar dataKey="visits" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,32 @@
'use client';
import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
interface Props {
data: Array<{ label: string; events: number; tracks: number }>;
}
export function ZoneActivityChart({ data }: Props) {
return (
<div className="h-64 w-full">
<ResponsiveContainer>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="label" stroke="hsl(var(--muted-foreground))" fontSize={11} />
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={11} allowDecimals={false} />
<Tooltip
contentStyle={{
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '12px',
}}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="events" name="События" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
<Bar dataKey="tracks" name="Уникальных треков" fill="hsl(var(--muted-foreground))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,71 @@
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react';
import { cn } from '@/lib/utils';
interface Consistency {
count: number;
pairs: number;
minDistance: number | null;
maxDistance: number | null;
avgDistance: number | null;
status: 'definitely_same' | 'likely_same' | 'suspicious';
isCoherent: boolean;
}
const VARIANTS = {
definitely_same: {
container: 'border-green-500/30 bg-green-500/10',
text: 'text-green-700 dark:text-green-400',
icon: CheckCircle2,
title: (n: number) => `Все ${n} ракурса — один человек`,
hint:
'Эмбеддинги тесно сгруппированы (max < 0.40). Можно смело связывать с пациентом.',
},
likely_same: {
container: 'border-blue-500/30 bg-blue-500/10',
text: 'text-blue-700 dark:text-blue-400',
icon: Info,
title: (n: number) => `${n} ракурса одного человека (с вариацией)`,
hint:
'Лица одного человека, но с заметной разницей ракурса / освещения / выражения (max 0.40–0.55). Это нормально для серии 5–10 кадров.',
},
suspicious: {
container: 'border-amber-500/40 bg-amber-500/10',
text: 'text-amber-700 dark:text-amber-400',
icon: AlertTriangle,
title: () => 'Возможно, в треке разные люди',
hint:
'Max попарной дистанции > 0.55 — для одного человека это много. Просмотри все кадры и убедись, что это один человек, прежде чем связывать.',
},
} as const;
export function ConsistencyPanel({ consistency }: { consistency: Consistency }) {
const v = VARIANTS[consistency.status];
const Icon = v.icon;
return (
<div className={cn('rounded-md border p-3 text-sm', v.container)}>
<div className={cn('flex items-center gap-2 font-medium', v.text)}>
<Icon className="size-4 shrink-0" />
{v.title(consistency.count)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{v.hint}</p>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
<Stat label="min" value={consistency.minDistance} />
<Stat label="avg" value={consistency.avgDistance} />
<Stat label="max" value={consistency.maxDistance} />
</div>
<p className="mt-1 text-[11px] text-muted-foreground">
{consistency.pairs} пар, метрика cos-дистанция эмбеддингов InsightFace buffalo_l.
</p>
</div>
);
}
function Stat({ label, value }: { label: string; value: number | null }) {
return (
<div className="rounded bg-background/60 px-2 py-1">
<div className="text-[10px] uppercase text-muted-foreground">{label}</div>
<div className="font-mono">{value != null ? value.toFixed(3) : '—'}</div>
</div>
);
}
@@ -0,0 +1,99 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetchSafe } from '@/lib/api';
import { formatDateTime } from '@/lib/utils';
import { TrackEnrollClient } from './track-enroll-client';
import { ConsistencyPanel } from './consistency-panel';
import type { PolimedAppointment } from '../types';
interface TrackDetail {
id: string;
status: string;
firstSeenAt: string;
lastSeenAt: string;
patient: { id: string; fullName: string | null } | null;
events: Array<{
type: string;
cameraId: string;
cameraName: string;
zoneCode: string;
occurredAt: string;
evidenceKey: string | null;
evidenceUrl: string | null;
faceBbox: { box: number[]; imgW: number; imgH: number } | null;
}>;
consistency: {
count: number;
pairs: number;
minDistance: number | null;
maxDistance: number | null;
avgDistance: number | null;
status: 'definitely_same' | 'likely_same' | 'suspicious';
isCoherent: boolean;
};
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
export default async function TrackDetailPage({ params }: { params: Promise<{ trackId: string }> }) {
const { trackId } = await params;
const [trackRes, appointmentsRes] = await Promise.all([
apiFetchSafe<TrackDetail>(`/tracks/${trackId}`),
apiFetchSafe<PolimedAppointment[]>(`/polimed/appointments?date=${todayIso()}`),
]);
if ('error' in trackRes) {
if (trackRes.error.status === 404) notFound();
return (
<Card>
<CardHeader>
<CardTitle>Ошибка</CardTitle>
<CardDescription>{trackRes.error.message}</CardDescription>
</CardHeader>
</Card>
);
}
const track = trackRes.data;
const appointments = 'data' in appointmentsRes ? appointmentsRes.data : [];
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/enrollment">
<ArrowLeft className="size-4" />
К списку треков
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>Трек {track.id.slice(0, 8)}</CardTitle>
<CardDescription className="flex flex-wrap items-center gap-2">
<Badge variant={track.status === 'UNMATCHED' ? 'outline' : 'default'}>{track.status}</Badge>
<span>
{formatDateTime(track.firstSeenAt)} {formatDateTime(track.lastSeenAt)}
</span>
<span>· {track.events.length} событий · {track.consistency.count} эмбеддингов</span>
{track.patient && (
<Badge variant="secondary">Уже привязан: {track.patient.fullName}</Badge>
)}
</CardDescription>
</CardHeader>
{track.consistency.count >= 2 && (
<CardContent>
<ConsistencyPanel consistency={track.consistency} />
</CardContent>
)}
</Card>
<TrackEnrollClient track={track} appointments={appointments} disabled={!!track.patient} />
</div>
);
}
@@ -0,0 +1,276 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { CheckCircle2, Loader2, Search } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn, formatDateTime } from '@/lib/utils';
import { enrollAction } from '../actions';
import type { PolimedAppointment } from '../types';
interface TrackDetail {
id: string;
events: Array<{
type: string;
cameraName: string;
zoneCode: string;
occurredAt: string;
evidenceKey: string | null;
evidenceUrl: string | null;
faceBbox: { box: number[]; imgW: number; imgH: number } | null;
}>;
}
interface Props {
track: TrackDetail;
appointments: PolimedAppointment[];
disabled?: boolean;
}
export function TrackEnrollClient({ track, appointments, disabled = false }: Props) {
const router = useRouter();
const [search, setSearch] = useState('');
const [selectedAppointmentId, setSelectedAppointmentId] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [paperRef, setPaperRef] = useState('');
const [consentChecked, setConsentChecked] = useState(false);
const [previewIdx, setPreviewIdx] = useState(0);
const [pending, startTransition] = useTransition();
const frames = track.events.filter((e) => e.evidenceUrl);
const filteredAppointments = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return appointments;
return appointments.filter(
(a) => a.patientFullName.toLowerCase().includes(q) || a.doctorFullName.toLowerCase().includes(q),
);
}, [appointments, search]);
const selectedAppointment = appointments.find((a) => a.id === selectedAppointmentId) ?? null;
function openConfirm() {
if (!selectedAppointment) return toast.error('Выберите запись из журнала Полимед');
setPaperRef('');
setConsentChecked(false);
setDialogOpen(true);
}
function submit() {
if (!selectedAppointment) return;
if (!consentChecked) return toast.error('Подтвердите получение бумажного согласия');
if (!paperRef.trim()) return toast.error('Укажите номер бумажного носителя');
startTransition(async () => {
const res = await enrollAction({
trackId: track.id,
polimedPatientId: selectedAppointment.patientId,
polimedAppointmentId: selectedAppointment.id,
paperConsentRef: paperRef.trim(),
});
if (res.ok) {
toast.success('Связано', {
description: `Пациент ${selectedAppointment.patientFullName} → новый Visit`,
});
setDialogOpen(false);
router.push('/enrollment');
} else {
toast.error('Ошибка enrollment', { description: res.error });
}
});
}
const activePreview = frames[previewIdx] ?? frames[0];
return (
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Серия кадров ({frames.length})</CardTitle>
<CardDescription>
Просмотри лицо со всех ракурсов. Если это не один человек нажми «К списку треков» и выбери другой.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{frames.length === 0 ? (
<div className="flex h-40 items-center justify-center rounded-md border border-dashed bg-muted text-sm text-muted-foreground">
У этого трека нет сохранённых кадров (старая сессия до фикса MinIO).
</div>
) : (
<>
<div className="relative aspect-[4/3] overflow-hidden rounded-md border bg-muted">
{activePreview?.evidenceUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={activePreview.evidenceUrl}
alt={`кадр ${previewIdx + 1}`}
className="size-full object-contain"
/>
)}
{activePreview?.faceBbox && (
<FaceBboxOverlay bbox={activePreview.faceBbox} />
)}
<div className="absolute bottom-2 left-2 flex items-center gap-1.5 rounded bg-black/70 px-2 py-1 text-xs text-white">
<Badge variant="secondary" className="text-[10px]">
{activePreview?.type}
</Badge>
<span>{activePreview ? formatDateTime(activePreview.occurredAt) : ''}</span>
<span className="text-white/70">
· {activePreview?.cameraName} (зона {activePreview?.zoneCode})
</span>
</div>
</div>
<div className="grid grid-cols-6 gap-2 sm:grid-cols-8">
{frames.map((f, idx) => (
<button
key={idx}
type="button"
onClick={() => setPreviewIdx(idx)}
className={cn(
'relative overflow-hidden rounded border-2 transition-colors',
idx === previewIdx ? 'border-primary' : 'border-transparent hover:border-muted-foreground',
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={f.evidenceUrl!} alt={`миниатюра ${idx + 1}`} className="aspect-square object-cover" />
{f.faceBbox && <FaceBboxOverlay bbox={f.faceBbox} thumbnail />}
</button>
))}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Записи на приём из Полимед</CardTitle>
<CardDescription>{filteredAppointments.length} на сегодня</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="relative">
<Search className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по ФИО / врачу"
className="pl-8"
/>
</div>
<div className="max-h-[420px] space-y-2 overflow-y-auto">
{filteredAppointments.map((ap) => (
<button
key={ap.id}
type="button"
onClick={() => setSelectedAppointmentId(ap.id)}
className={cn(
'flex w-full flex-col rounded-md border p-3 text-left text-sm transition-colors hover:bg-accent',
selectedAppointmentId === ap.id && 'border-primary bg-accent',
)}
>
<div className="font-medium">{ap.patientFullName}</div>
<div className="text-xs text-muted-foreground">
{ap.doctorFullName} · {ap.specialty} · {formatDateTime(ap.scheduledFor)}
</div>
</button>
))}
</div>
<Button onClick={openConfirm} disabled={disabled || !selectedAppointment} className="w-full">
<CheckCircle2 className="size-4" />
Связать
</Button>
</CardContent>
</Card>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Подтверждение enrollment</DialogTitle>
<DialogDescription>
{selectedAppointment && (
<>
Пациент: <strong>{selectedAppointment.patientFullName}</strong>
<br />
Запись: {selectedAppointment.specialty}, {formatDateTime(selectedAppointment.scheduledFor)}
</>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="space-y-2">
<Label htmlFor="paperRef">Номер бумажного носителя</Label>
<Input
id="paperRef"
value={paperRef}
onChange={(e) => setPaperRef(e.target.value)}
placeholder="например: договор № 123/2026"
/>
</div>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={consentChecked}
onChange={(e) => setConsentChecked(e.target.checked)}
className="mt-1 size-4 rounded border-input"
/>
<span>
Бумажное согласие на обработку биометрических ПДн получено. Я понимаю, что эмбеддинги
лица будут привязаны к пациенту.
</span>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Отмена
</Button>
<Button onClick={submit} disabled={pending || !consentChecked || !paperRef.trim()}>
{pending && <Loader2 className="size-4 animate-spin" />}
Подтвердить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function FaceBboxOverlay({
bbox,
thumbnail = false,
}: {
bbox: { box: number[]; imgW: number; imgH: number };
thumbnail?: boolean;
}) {
const [x1, y1, x2, y2] = bbox.box;
const stroke = thumbnail ? 4 : 3;
return (
<svg
className="pointer-events-none absolute inset-0 size-full"
viewBox={`0 0 ${bbox.imgW} ${bbox.imgH}`}
preserveAspectRatio="xMidYMid meet"
>
<rect
x={x1}
y={y1}
width={x2 - x1}
height={y2 - y1}
fill="none"
stroke="#22c55e"
strokeWidth={stroke}
rx={4}
/>
</svg>
);
}
@@ -0,0 +1,28 @@
'use server';
import { revalidatePath } from 'next/cache';
import { apiFetch, ApiError } from '@/lib/api';
export interface EnrollmentInput {
trackId: string;
polimedPatientId: string;
polimedAppointmentId: string;
paperConsentRef: string;
}
export async function enrollAction(
input: EnrollmentInput,
): Promise<{ ok: true; patientId: string; visitId: string } | { ok: false; error: string }> {
try {
const res = await apiFetch<{ patientId: string; visitId: string }>('/enrollment', {
method: 'POST',
body: JSON.stringify(input),
});
revalidatePath('/enrollment');
revalidatePath('/patients');
return { ok: true, ...res };
} catch (err) {
const message = err instanceof ApiError ? err.body : String(err);
return { ok: false, error: message };
}
}
@@ -0,0 +1,122 @@
'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { ChevronRight, Clock, MapPin, Search } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { formatDateTime } from '@/lib/utils';
import type { PolimedAppointment, UnmatchedTrack } from './types';
interface Props {
tracks: UnmatchedTrack[];
appointments: PolimedAppointment[];
}
export function EnrollmentClient({ tracks, appointments }: Props) {
const [search, setSearch] = useState('');
const filteredAppointments = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return appointments;
return appointments.filter(
(a) => a.patientFullName.toLowerCase().includes(q) || a.doctorFullName.toLowerCase().includes(q),
);
}, [appointments, search]);
return (
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Unmatched-треки</CardTitle>
<CardDescription>
{tracks.length} ожидают enrollment. Кликни по карточке, чтобы посмотреть всю серию кадров.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{tracks.length === 0 && (
<p className="text-sm text-muted-foreground">
Нет треков для enrollment. Зайди на <Link href="/capture" className="underline">/capture</Link>{' '}
или запусти{' '}
<code className="rounded bg-muted px-1 py-0.5">pnpm fixtures:run --scenario=new-patient</code>.
</p>
)}
{tracks.map((track) => (
<Link
key={track.id}
href={`/enrollment/${track.id}`}
className="group flex w-full items-start gap-3 rounded-md border p-3 text-left text-sm transition-colors hover:bg-accent"
>
<div className="size-16 shrink-0 overflow-hidden rounded bg-muted">
{track.thumbnailUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={track.thumbnailUrl} alt={track.id} className="size-full object-cover" />
) : (
<div className="flex size-full items-center justify-center text-[10px] text-muted-foreground">
нет кадра
</div>
)}
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3" />
{formatDateTime(track.firstSeenAt)} {formatDateTime(track.lastSeenAt)}
</div>
<div className="flex flex-wrap items-center gap-1 text-xs">
<MapPin className="size-3 text-muted-foreground" />
{track.events.length > 0 ? (
Array.from(new Set(track.events.map((e) => e.type))).map((t) => (
<Badge key={t} variant="secondary" className="text-[10px]">
{t}
</Badge>
))
) : (
<span className="text-muted-foreground">нет событий</span>
)}
</div>
<div className="text-[11px] text-muted-foreground">
Эмбеддингов: {track.embeddingsCount} · кадров: {track.events.filter((e) => e.evidenceKey).length}
</div>
</div>
<ChevronRight className="size-4 self-center text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</Link>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Записи на приём из Полимед</CardTitle>
<CardDescription>
{filteredAppointments.length} на сегодня. Связывание происходит в карточке трека.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="relative">
<Search className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Поиск по ФИО / врачу"
className="pl-8"
/>
</div>
<div className="max-h-[480px] space-y-2 overflow-y-auto">
{filteredAppointments.map((ap) => (
<div
key={ap.id}
className="rounded-md border p-3 text-sm"
>
<div className="font-medium">{ap.patientFullName}</div>
<div className="text-xs text-muted-foreground">
{ap.doctorFullName} · {ap.specialty} · {formatDateTime(ap.scheduledFor)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,52 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetchSafe } from '@/lib/api';
import { EnrollmentClient } from './enrollment-client';
import type { PolimedAppointment, UnmatchedTrack } from './types';
function todayIso(): string {
const d = new Date();
return d.toISOString().slice(0, 10);
}
export default async function EnrollmentPage() {
const [tracksRes, appointmentsRes] = await Promise.all([
apiFetchSafe<UnmatchedTrack[]>('/tracks?status=UNMATCHED'),
apiFetchSafe<PolimedAppointment[]>(`/polimed/appointments?date=${todayIso()}`),
]);
const tracks = 'data' in tracksRes ? tracksRes.data : [];
const appointments = 'data' in appointmentsRes ? appointmentsRes.data : [];
const tracksError = 'error' in tracksRes ? tracksRes.error.message : null;
const appointmentsError = 'error' in appointmentsRes ? appointmentsRes.error.message : null;
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Ручной enrollment</CardTitle>
<CardDescription>
Сопоставьте unmatched-трек с записью на приём из Полимед. Обязательное условие бумажное
согласие на обработку биометрических ПДн.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div className="flex flex-wrap gap-x-6 gap-y-1">
<span>
Unmatched-треков сегодня: <span className="font-semibold text-foreground">{tracks.length}</span>
</span>
<span>
Записей в Полимед: <span className="font-semibold text-foreground">{appointments.length}</span>
</span>
</div>
{(tracksError || appointmentsError) && (
<p className="text-destructive">
Ошибка загрузки: {tracksError ?? ''} {appointmentsError ?? ''}
</p>
)}
</CardContent>
</Card>
<EnrollmentClient tracks={tracks} appointments={appointments} />
</div>
);
}
@@ -0,0 +1,27 @@
export interface UnmatchedTrack {
id: string;
status: string;
firstSeenAt: string;
lastSeenAt: string;
patientId: string | null;
embeddingsCount: number;
zonesPath: string[];
events: Array<{
type: string;
cameraId: string;
zoneId: string;
occurredAt: string;
evidenceKey: string | null;
}>;
thumbnailUrl: string | null;
}
export interface PolimedAppointment {
id: string;
patientId: string;
patientFullName: string;
doctorFullName: string;
specialty: string;
scheduledFor: string;
status: string;
}
@@ -0,0 +1,15 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export default function IncidentsPage() {
return (
<Card>
<CardHeader>
<CardTitle>Инциденты</CardTitle>
<CardDescription>Лента «неадекватного поведения» Фаза 2 (behavior-service + max-bot).</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">В Фазе 1 не реализовано.</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
import { AppShell } from '@/components/app-shell';
import { getCurrentUser, logoutAction } from '@/lib/auth';
export default async function AuthedLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser();
if (!user) redirect('/login');
return (
<AppShell user={user} logoutAction={logoutAction}>
{children}
</AppShell>
);
}
@@ -0,0 +1,116 @@
import { AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { cn, formatDateTime, formatDuration } from '@/lib/utils';
interface Segment {
zoneCode: string;
cameraName: string;
startedAt: string;
endedAt: string;
eventTypes: string[];
durationSec: number;
}
export interface Journey {
segments: Segment[];
timeInZoneSec: Record<string, number>;
lostInTransit: boolean;
totalEvents: number;
firstSeenAt: string | null;
lastSeenAt: string | null;
}
const ZONE_META: Record<string, { label: string; color: string }> = {
A: { label: 'Вход', color: 'bg-sky-500' },
B: { label: 'Коридор', color: 'bg-indigo-500' },
C: { label: 'Рецепция', color: 'bg-amber-500' },
D: { label: 'Перед врачом', color: 'bg-violet-500' },
E: { label: 'У врача', color: 'bg-emerald-500' },
};
export function JourneyTimeline({ journey }: { journey: Journey }) {
if (journey.segments.length === 0) {
return <p className="text-sm text-muted-foreground">У пациента ещё нет событий по зонам.</p>;
}
const totalSec = Math.max(
1,
journey.segments.reduce((sum, s) => sum + Math.max(s.durationSec, 1), 0),
);
return (
<div className="space-y-4">
{journey.lostInTransit && (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<AlertTriangle className="size-4 shrink-0 text-amber-700 dark:text-amber-400" />
<div>
<div className="font-medium text-amber-700 dark:text-amber-400">Возможно, потерян</div>
<p className="text-xs text-muted-foreground">
Последнее событие в зоне D (перед кабинетом врача) более 15 минут назад. Пациент не зашёл к
врачу и не вернулся на рецепцию стоит проверить вживую.
</p>
</div>
</div>
)}
<div className="flex h-10 w-full overflow-hidden rounded-md border">
{journey.segments.map((s, idx) => {
const meta = ZONE_META[s.zoneCode] ?? { label: s.zoneCode, color: 'bg-muted' };
const flex = Math.max(s.durationSec, 1) / totalSec;
return (
<div
key={idx}
className={cn('group relative flex items-center justify-center text-[10px] font-medium text-white', meta.color)}
style={{ flex }}
title={`${meta.label} (${s.cameraName}): ${formatDuration(s.durationSec)}`}
>
<span className="px-1 truncate">{s.zoneCode}</span>
</div>
);
})}
</div>
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
{(['A', 'B', 'C', 'D', 'E'] as const).map((code) => {
const meta = ZONE_META[code];
const sec = journey.timeInZoneSec[code] ?? 0;
return (
<div key={code} className="rounded-md border p-2">
<div className="flex items-center gap-2">
<span className={cn('inline-block size-2 rounded-full', meta?.color)} />
<span className="text-xs text-muted-foreground">{meta?.label}</span>
</div>
<div className="mt-1 font-semibold">{sec > 0 ? formatDuration(sec) : '—'}</div>
</div>
);
})}
</div>
<div className="space-y-1.5">
{journey.segments.map((s, idx) => {
const meta = ZONE_META[s.zoneCode] ?? { label: s.zoneCode, color: 'bg-muted' };
return (
<div key={idx} className="flex items-center gap-3 rounded-md border p-2 text-sm">
<Badge className={cn('text-white', meta.color)}>{s.zoneCode}</Badge>
<div className="flex-1">
<div className="font-medium">{meta.label}</div>
<div className="text-xs text-muted-foreground">
{s.cameraName} · {formatDateTime(s.startedAt)}
{s.endedAt !== s.startedAt && `${formatDateTime(s.endedAt)}`}
</div>
</div>
<div className="font-mono text-xs">{formatDuration(s.durationSec)}</div>
<div className="flex flex-wrap gap-1">
{Array.from(new Set(s.eventTypes)).map((t) => (
<Badge key={t} variant="secondary" className="text-[10px]">
{t}
</Badge>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,177 @@
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { FaceAvatar } from '@/components/face-avatar';
import { apiFetchSafe } from '@/lib/api';
import { formatDateTime, formatDuration } from '@/lib/utils';
import { WaitChart } from './wait-chart';
import { JourneyTimeline, type Journey } from './journey-timeline';
interface VisitsData {
patient: {
id: string;
fullName: string | null;
polimedPatientId: string | null;
consentReceivedAt: string | null;
consentRevokedAt: string | null;
pendingDeletionAt: string | null;
avatarUrl: string | null;
avatarBbox: { box: number[]; imgW: number; imgH: number } | null;
};
journey: Journey;
visits: Array<{
id: string;
arrivedAt: string;
serviceStartedAt: string | null;
serviceEndedAt: string | null;
leftWithoutService: boolean;
polimedAppointmentId: string | null;
waitingSec: number | null;
serviceSec: number | null;
}>;
}
export default async function PatientDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const res = await apiFetchSafe<VisitsData>(`/patients/${id}/visits`);
if ('error' in res) {
return (
<Card>
<CardHeader>
<CardTitle>Ошибка</CardTitle>
<CardDescription>{res.error.message}</CardDescription>
</CardHeader>
</Card>
);
}
const { patient, visits, journey } = res.data;
const isAnonymized = !patient.fullName;
const isPending = !!patient.pendingDeletionAt;
const chartData = visits
.filter((v) => v.waitingSec != null)
.slice()
.reverse()
.map((v) => ({
date: new Date(v.arrivedAt).toLocaleDateString('ru-RU'),
waitingSec: v.waitingSec ?? 0,
serviceSec: v.serviceSec ?? 0,
}));
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/patients">
<ArrowLeft className="size-4" />
Назад к списку
</Link>
</Button>
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<FaceAvatar
url={patient.avatarUrl}
bbox={patient.avatarBbox}
name={patient.fullName}
size={72}
/>
<div className="flex-1 space-y-1">
<CardTitle>
{isAnonymized ? (
<span className="italic text-muted-foreground">Удалено</span>
) : (
patient.fullName
)}
</CardTitle>
<CardDescription className="flex flex-wrap gap-2">
{patient.polimedPatientId && (
<Badge variant="outline">Polimed: {patient.polimedPatientId}</Badge>
)}
{isPending ? (
<Badge variant="destructive">
Удаление до {formatDateTime(patient.pendingDeletionAt)}
</Badge>
) : patient.consentRevokedAt ? (
<Badge variant="outline">Согласие отозвано</Badge>
) : (
<Badge>Согласие с {formatDateTime(patient.consentReceivedAt)}</Badge>
)}
</CardDescription>
</div>
</div>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Маршрут пациента по зонам</CardTitle>
<CardDescription>
Все события всех треков пациента, сгруппированные по непрерывным пребываниям в одной зоне.
Считаем: ожидание врача = время в D, у врача = E, обслуживание на рецепции = C.
</CardDescription>
</CardHeader>
<CardContent>
<JourneyTimeline journey={journey} />
</CardContent>
</Card>
{chartData.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Время ожидания по визитам</CardTitle>
</CardHeader>
<CardContent>
<WaitChart data={chartData} />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">История визитов ({visits.length})</CardTitle>
</CardHeader>
<CardContent>
{visits.length === 0 ? (
<p className="text-sm text-muted-foreground">Визитов пока нет.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Приход</TableHead>
<TableHead>Начало обслуживания</TableHead>
<TableHead>Конец обслуживания</TableHead>
<TableHead>Ожидание</TableHead>
<TableHead>Обслуживание</TableHead>
<TableHead>Polimed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visits.map((v) => (
<TableRow key={v.id}>
<TableCell>{formatDateTime(v.arrivedAt)}</TableCell>
<TableCell>{formatDateTime(v.serviceStartedAt)}</TableCell>
<TableCell>{formatDateTime(v.serviceEndedAt)}</TableCell>
<TableCell>{formatDuration(v.waitingSec)}</TableCell>
<TableCell>{formatDuration(v.serviceSec)}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{v.leftWithoutService ? (
<Badge variant="destructive">Ушёл без обслуживания</Badge>
) : (
v.polimedAppointmentId ?? '—'
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,32 @@
'use client';
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
interface Point {
date: string;
waitingSec: number;
serviceSec: number;
}
export function WaitChart({ data }: { data: Point[] }) {
return (
<div className="h-64 w-full">
<ResponsiveContainer>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis dataKey="date" stroke="hsl(var(--muted-foreground))" fontSize={12} />
<YAxis stroke="hsl(var(--muted-foreground))" fontSize={12} label={{ value: 'сек', angle: -90, position: 'insideLeft' }} />
<Tooltip
contentStyle={{
background: 'hsl(var(--background))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Line type="monotone" dataKey="waitingSec" name="Ожидание" stroke="hsl(var(--primary))" strokeWidth={2} />
<Line type="monotone" dataKey="serviceSec" name="Обслуживание" stroke="hsl(var(--muted-foreground))" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,20 @@
'use server';
import { revalidatePath } from 'next/cache';
import { apiFetch, ApiError } from '@/lib/api';
export async function revokeConsentAction(
patientId: string,
): Promise<{ ok: true; scheduledFor: string } | { ok: false; error: string }> {
try {
const res = await apiFetch<{ scheduledFor: string }>(`/consents/${patientId}/revoke`, {
method: 'POST',
});
revalidatePath('/patients');
revalidatePath(`/patients/${patientId}`);
return { ok: true, scheduledFor: res.scheduledFor };
} catch (err) {
const message = err instanceof ApiError ? err.body : String(err);
return { ok: false, error: message };
}
}
@@ -0,0 +1,41 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetchSafe } from '@/lib/api';
import { PatientsTable } from './patients-table';
interface PatientRow {
id: string;
fullName: string | null;
polimedPatientId: string | null;
consentReceivedAt: string | null;
consentRevokedAt: string | null;
pendingDeletionAt: string | null;
visitsCount: number;
avatarUrl: string | null;
avatarBbox: { box: number[]; imgW: number; imgH: number } | null;
}
export default async function PatientsPage() {
const res = await apiFetchSafe<PatientRow[]>('/patients');
const patients = 'data' in res ? res.data : [];
const error = 'error' in res ? res.error.message : null;
return (
<Card>
<CardHeader>
<CardTitle>Пациенты</CardTitle>
<CardDescription>
Зарегистрированные пациенты с согласием на обработку биометрии. Отзыв согласия удаление эмбеддингов в течение 24 ч.
</CardDescription>
</CardHeader>
<CardContent>
{error ? (
<p className="text-sm text-destructive">Ошибка: {error}</p>
) : patients.length === 0 ? (
<p className="text-sm text-muted-foreground">Пока нет зарегистрированных пациентов.</p>
) : (
<PatientsTable patients={patients} />
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,133 @@
'use client';
import Link from 'next/link';
import { useState, useTransition } from 'react';
import { AlertTriangle, Loader2, ShieldX } from 'lucide-react';
import { toast } from 'sonner';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { FaceAvatar } from '@/components/face-avatar';
import { formatDateTime } from '@/lib/utils';
import { revokeConsentAction } from './actions';
interface Patient {
id: string;
fullName: string | null;
polimedPatientId: string | null;
consentReceivedAt: string | null;
consentRevokedAt: string | null;
pendingDeletionAt: string | null;
visitsCount: number;
avatarUrl: string | null;
avatarBbox: { box: number[]; imgW: number; imgH: number } | null;
}
export function PatientsTable({ patients }: { patients: Patient[] }) {
const [confirmPatient, setConfirmPatient] = useState<Patient | null>(null);
const [pending, startTransition] = useTransition();
function doRevoke() {
if (!confirmPatient) return;
startTransition(async () => {
const res = await revokeConsentAction(confirmPatient.id);
if (res.ok) {
toast.success('Согласие отозвано', {
description: `Эмбеддинги будут удалены до ${formatDateTime(res.scheduledFor)}`,
});
setConfirmPatient(null);
} else {
toast.error('Ошибка отзыва', { description: res.error });
}
});
}
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-14" />
<TableHead>ФИО</TableHead>
<TableHead>Polimed ID</TableHead>
<TableHead>Статус согласия</TableHead>
<TableHead className="text-right">Визитов</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{patients.map((p) => {
const isPending = !!p.pendingDeletionAt;
const isRevoked = !!p.consentRevokedAt && !isPending;
return (
<TableRow key={p.id}>
<TableCell>
<Link href={`/patients/${p.id}`}>
<FaceAvatar url={p.avatarUrl} bbox={p.avatarBbox} name={p.fullName} size={40} />
</Link>
</TableCell>
<TableCell className="font-medium">
<Link href={`/patients/${p.id}`} className="hover:underline">
{p.fullName ?? <span className="text-muted-foreground italic">Удалено</span>}
</Link>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{p.polimedPatientId ?? '—'}</TableCell>
<TableCell>
{isPending ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="size-3" />
Удаление до {formatDateTime(p.pendingDeletionAt)}
</Badge>
) : isRevoked ? (
<Badge variant="outline">Согласие отозвано</Badge>
) : (
<Badge>Действует с {formatDateTime(p.consentReceivedAt)}</Badge>
)}
</TableCell>
<TableCell className="text-right">{p.visitsCount}</TableCell>
<TableCell className="text-right">
{!isPending && !isRevoked && (
<Button variant="ghost" size="sm" onClick={() => setConfirmPatient(p)}>
<ShieldX className="size-4" />
Отозвать
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<Dialog open={!!confirmPatient} onOpenChange={(o) => !o && setConfirmPatient(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Отзыв согласия</DialogTitle>
<DialogDescription>
Эмбеддинги пациента <strong>{confirmPatient?.fullName ?? '—'}</strong> будут физически
удалены из БД в течение <strong>24 часов</strong>. История визитов сохранится, но в обезличенном
виде. Действие необратимо.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmPatient(null)}>
Отмена
</Button>
<Button variant="destructive" onClick={doRevoke} disabled={pending}>
{pending && <Loader2 className="size-4 animate-spin" />}
Отозвать согласие
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { apiFetchSafe } from '@/lib/api';
export async function GET() {
const res = await apiFetchSafe<unknown>('/cameras');
if ('error' in res) return NextResponse.json({ error: res.error.message }, { status: res.error.status });
return NextResponse.json(res.data);
}
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { apiFetch, ApiError } from '@/lib/api';
interface FrameRequest {
trackId: string;
cameraName?: string;
frame: string;
}
export async function POST(req: Request) {
try {
const body = (await req.json()) as FrameRequest;
if (!body.trackId || !body.frame) {
return NextResponse.json({ error: 'trackId+frame required' }, { status: 400 });
}
const res = await apiFetch('/ingest/capture-frame', {
method: 'POST',
body: JSON.stringify({
trackId: body.trackId,
cameraName: body.cameraName ?? 'cam-entrance',
frame: body.frame,
}),
});
return NextResponse.json(res);
} catch (err) {
const status = err instanceof ApiError ? err.status : 500;
const message = err instanceof ApiError ? err.body : String(err);
return NextResponse.json({ error: message }, { status });
}
}
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { apiFetch, ApiError } from '@/lib/api';
export async function POST(req: Request) {
try {
const body = (await req.json().catch(() => ({}))) as { cameraName?: string };
const cameraName = body.cameraName ?? 'cam-entrance';
const res = await apiFetch<{ trackId: string; cameraId: string; zoneId: string }>(
'/ingest/tracks',
{
method: 'POST',
body: JSON.stringify({
cameraName,
firstSeenAt: new Date().toISOString(),
}),
},
);
return NextResponse.json(res);
} catch (err) {
const status = err instanceof ApiError ? err.status : 500;
const message = err instanceof ApiError ? err.body : String(err);
return NextResponse.json({ error: message }, { status });
}
}
+59
View File
@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from 'next';
import { Toaster } from '@/components/ui/sonner';
import { ThemeProvider } from '@/components/theme-provider';
import './globals.css';
export const metadata: Metadata = {
title: 'Цифровая рецепция',
description: 'Web-admin Digital Reception',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
import { LoginForm } from '@/components/login-form';
import { getCurrentUser } from '@/lib/auth';
export default async function LoginPage() {
const user = await getCurrentUser();
if (user) redirect('/');
return (
<main className="flex min-h-screen items-center justify-center bg-muted px-4">
<LoginForm />
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from 'next/navigation';
import { getCurrentUser } from '@/lib/auth';
export default async function RootPage() {
const user = await getCurrentUser();
if (!user) redirect('/login');
switch (user.role) {
case 'MANAGER':
redirect('/dashboard');
case 'SENIOR_ADMIN':
redirect('/enrollment');
case 'SECURITY':
redirect('/incidents');
case 'SYSADMIN':
redirect('/audit');
default:
redirect('/dashboard');
}
}
+114
View File
@@ -0,0 +1,114 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
Activity,
Camera,
ClipboardCheck,
FileSearch,
LayoutDashboard,
LogOut,
Shield,
Users,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import type { Role } from '@/lib/auth';
type NavItem = {
href: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
roles: Role[];
};
const NAV: NavItem[] = [
{ href: '/dashboard', label: 'Дашборд', icon: LayoutDashboard, roles: ['MANAGER', 'SYSADMIN', 'SENIOR_ADMIN'] },
{ href: '/capture', label: 'Камера (демо)', icon: Camera, roles: ['SENIOR_ADMIN', 'SYSADMIN'] },
{ href: '/enrollment', label: 'Enrollment', icon: ClipboardCheck, roles: ['SENIOR_ADMIN'] },
{ href: '/patients', label: 'Пациенты', icon: Users, roles: ['SENIOR_ADMIN', 'MANAGER', 'SYSADMIN'] },
{ href: '/incidents', label: 'Инциденты', icon: Shield, roles: ['SECURITY'] },
{ href: '/audit', label: 'Аудит', icon: FileSearch, roles: ['SYSADMIN'] },
];
export function AppShell({
user,
children,
logoutAction,
}: {
user: { email: string; role: Role; id: string };
children: React.ReactNode;
logoutAction: () => Promise<void>;
}) {
const pathname = usePathname();
const items = NAV.filter((i) => i.roles.includes(user.role));
const roleLabel = ROLE_LABEL[user.role];
return (
<div className="flex min-h-screen">
<aside className="hidden w-64 shrink-0 flex-col border-r bg-background lg:flex">
<div className="flex h-16 items-center gap-2 border-b px-6">
<Activity className="size-5 text-primary" />
<span className="text-sm font-semibold">Цифровая рецепция</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{items.map((it) => {
const Icon = it.icon;
const active = pathname?.startsWith(it.href);
return (
<Link
key={it.href}
href={it.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
active ? 'bg-secondary text-secondary-foreground' : 'hover:bg-accent',
)}
>
<Icon className="size-4" />
{it.label}
</Link>
);
})}
</nav>
<div className="border-t p-4 text-xs text-muted-foreground">
<div className="font-medium text-foreground">{user.email}</div>
<div>{roleLabel}</div>
</div>
</aside>
<div className="flex flex-1 flex-col">
<header className="flex h-16 items-center justify-between gap-4 border-b px-6">
<div>
<h1 className="text-sm font-medium">{ROUTE_TITLE[pathname ?? ''] ?? 'Цифровая рецепция'}</h1>
<p className="text-xs text-muted-foreground">{roleLabel}</p>
</div>
<form action={logoutAction}>
<Button type="submit" variant="ghost" size="sm">
<LogOut className="size-4" />
<span className="hidden sm:inline">Выйти</span>
</Button>
</form>
</header>
<main className="flex-1 overflow-y-auto bg-muted/30 p-6">{children}</main>
</div>
</div>
);
}
const ROLE_LABEL: Record<Role, string> = {
MANAGER: 'Управляющий',
SENIOR_ADMIN: 'Старший администратор',
SECURITY: 'Безопасность',
SYSADMIN: 'Админ системы',
};
const ROUTE_TITLE: Record<string, string> = {
'/dashboard': 'Дашборд',
'/capture': 'Камера (демо)',
'/enrollment': 'Enrollment',
'/patients': 'Пациенты',
'/incidents': 'Инциденты',
'/audit': 'Аудит биометрии',
};
@@ -0,0 +1,95 @@
'use client';
import { cn } from '@/lib/utils';
interface FaceBboxValue {
box: number[];
imgW: number;
imgH: number;
}
interface FaceAvatarProps {
url: string | null;
bbox?: FaceBboxValue | null;
size?: number;
/** ФИО — для инициалов в fallback */
name?: string | null;
className?: string;
}
/** Аватар-кружок из кадра трека, центрированный по bbox распознанного лица. */
export function FaceAvatar({ url, bbox, size = 40, name, className }: FaceAvatarProps) {
if (!url) return <InitialsAvatar name={name} size={size} className={className} />;
if (!bbox) {
return (
<div
className={cn('relative overflow-hidden rounded-full bg-muted', className)}
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={name ?? 'аватар'} className="size-full object-cover" />
</div>
);
}
const [x1, y1, x2, y2] = bbox.box;
const faceW = x2 - x1;
const faceH = y2 - y1;
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
// Берём квадрат вокруг центра лица с запасом 60% — захватим лоб и подбородок.
const cropSize = Math.max(faceW, faceH) * 1.6;
const cropX = cx - cropSize / 2;
const cropY = cy - cropSize / 2;
const scale = size / cropSize;
return (
<div
className={cn('relative overflow-hidden rounded-full bg-muted', className)}
style={{ width: size, height: size }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={name ?? 'аватар'}
style={{
position: 'absolute',
left: -cropX * scale,
top: -cropY * scale,
width: bbox.imgW * scale,
height: bbox.imgH * scale,
maxWidth: 'none',
}}
/>
</div>
);
}
function InitialsAvatar({
name,
size,
className,
}: {
name: string | null | undefined;
size: number;
className?: string;
}) {
const initials = (name ?? '?')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase())
.join('');
return (
<div
className={cn(
'flex items-center justify-center rounded-full bg-muted text-muted-foreground font-medium',
className,
)}
style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}
>
{initials || '?'}
</div>
);
}
@@ -0,0 +1,62 @@
'use client';
import { useState, useTransition } from 'react';
import { LogIn, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { loginAction } from '@/lib/auth';
export function LoginForm() {
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
function onSubmit(formData: FormData) {
setError(null);
startTransition(async () => {
const result = await loginAction(formData);
if (result?.error) setError(result.error);
});
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-2xl">
<LogIn className="size-6 text-primary" />
Цифровая рецепция
</CardTitle>
<CardDescription>Вход для сотрудников клиники</CardDescription>
</CardHeader>
<CardContent>
<form action={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" autoComplete="username" required defaultValue="senior@local" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
defaultValue="senior123"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={pending} className="w-full">
{pending && <Loader2 className="size-4 animate-spin" />}
Войти
</Button>
</form>
<p className="mt-4 text-xs text-muted-foreground">
Dev-аккаунты: <code>senior@local / senior123</code>, <code>manager@local / manager123</code>,{' '}
<code>admin@local / admin123</code>
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,7 @@
'use client';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import type * as React from 'react';
export function ThemeProvider({ children, ...props }: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
@@ -0,0 +1,28 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
},
},
defaultVariants: { variant: 'default' },
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
@@ -0,0 +1,43 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size }), className)} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+48
View File
@@ -0,0 +1,48 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
),
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-2xl font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };
@@ -0,0 +1,88 @@
'use client';
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Закрыть</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,19 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = 'Input';
export { Input };
@@ -0,0 +1,19 @@
'use client';
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,27 @@
'use client';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
};
export { Toaster };
@@ -0,0 +1,60 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
</div>
),
);
Table.displayName = 'Table';
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
);
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
),
);
TableBody.displayName = 'TableBody';
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className,
)}
{...props}
/>
),
);
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
),
);
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
),
);
TableCell.displayName = 'TableCell';
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell };
+58
View File
@@ -0,0 +1,58 @@
/**
* Server-side API клиент. Использует cookies из request для проксирования
* запросов в apps/api. Все вызовы серверные (RSC / server actions).
*/
import { cookies } from 'next/headers';
const API_BASE = process.env.API_BASE_URL_INTERNAL ?? process.env.API_BASE_URL ?? 'http://localhost:4000';
export class ApiError extends Error {
constructor(public status: number, public body: string) {
super(`API ${status}: ${body}`);
}
}
export async function apiFetch<T = unknown>(
path: string,
init: RequestInit & { skipCookies?: boolean } = {},
): Promise<T> {
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json');
if (!init.skipCookies) {
const cookieStore = await cookies();
const all = cookieStore.getAll();
if (all.length > 0) {
headers.set('Cookie', all.map((c) => `${c.name}=${c.value}`).join('; '));
}
}
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers,
cache: 'no-store',
credentials: 'include',
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new ApiError(res.status, body);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
/** apiFetch без кидания ошибок — возвращает {data} или {error}. */
export async function apiFetchSafe<T>(
path: string,
init: RequestInit = {},
): Promise<{ data: T } | { error: { status: number; message: string } }> {
try {
const data = await apiFetch<T>(path, init);
return { data };
} catch (err) {
if (err instanceof ApiError) return { error: { status: err.status, message: err.body } };
return { error: { status: 500, message: String(err) } };
}
}
+75
View File
@@ -0,0 +1,75 @@
'use server';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
const API_BASE = process.env.API_BASE_URL_INTERNAL ?? process.env.API_BASE_URL ?? 'http://localhost:4000';
export type Role = 'MANAGER' | 'SENIOR_ADMIN' | 'SECURITY' | 'SYSADMIN';
export interface SessionUser {
id: string;
email: string;
role: Role;
}
/** Логин: вызывает apps/api /auth/login, проксирует set-cookie клиенту. */
export async function loginAction(formData: FormData): Promise<{ error?: string }> {
const email = String(formData.get('email') ?? '').trim();
const password = String(formData.get('password') ?? '');
if (!email || !password) return { error: 'Заполните email и пароль' };
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
return { error: res.status === 401 ? 'Неверный email или пароль' : `Ошибка сервера ${res.status}` };
}
const setCookies = res.headers.getSetCookie();
const store = await cookies();
for (const sc of setCookies) {
const [pair] = sc.split(';');
if (!pair) continue;
const [name, ...rest] = pair.split('=');
const value = rest.join('=');
if (!name) continue;
store.set(name, value, {
httpOnly: true,
sameSite: 'lax',
secure: false,
path: '/',
});
}
redirect('/');
}
export async function logoutAction(): Promise<void> {
const store = await cookies();
const all = store.getAll();
await fetch(`${API_BASE}/auth/logout`, {
method: 'POST',
headers: {
Cookie: all.map((c) => `${c.name}=${c.value}`).join('; '),
},
}).catch(() => null);
store.delete('access_token');
store.delete('refresh_token');
redirect('/login');
}
export async function getCurrentUser(): Promise<SessionUser | null> {
const store = await cookies();
const all = store.getAll();
if (all.length === 0) return null;
const res = await fetch(`${API_BASE}/auth/me`, {
cache: 'no-store',
headers: { Cookie: all.map((c) => `${c.name}=${c.value}`).join('; ') },
});
if (!res.ok) return null;
return (await res.json()) as SessionUser;
}
+20
View File
@@ -0,0 +1,20 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDateTime(value: Date | string | null | undefined): string {
if (!value) return '—';
const d = typeof value === 'string' ? new Date(value) : value;
return d.toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' });
}
export function formatDuration(seconds: number | null | undefined): string {
if (seconds == null) return '—';
if (seconds < 60) return `${seconds}с`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s ? `${m}м ${s}с` : `${m}м`;
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse, type NextRequest } from 'next/server';
const PUBLIC_PATHS = ['/login', '/_next', '/favicon.ico'];
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) return NextResponse.next();
const accessToken = req.cookies.get('access_token')?.value;
if (!accessToken) {
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
+68
View File
@@ -0,0 +1,68 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: ['class'],
content: ['./src/**/*.{ts,tsx,mdx}'],
theme: {
container: {
center: true,
padding: '2rem',
screens: { '2xl': '1400px' },
},
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--radix-accordion-content-height)' },
},
'accordion-up': {
from: { height: 'var(--radix-accordion-content-height)' },
to: { height: '0' },
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
},
},
},
plugins: [require('tailwindcss-animate')],
};
export default config;
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "@reception/tsconfig/next.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"noUncheckedIndexedAccess": false
},
"include": ["next-env.d.ts", "src/**/*.ts", "src/**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
File diff suppressed because one or more lines are too long