Initial commit: digital reception monorepo (M1-M11 + demo extensions)
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_APP_NAME="Цифровая рецепция"
|
||||
API_BASE_URL=http://localhost:4000
|
||||
API_BASE_URL_INTERNAL=http://localhost:4000
|
||||
@@ -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) |
|
||||
@@ -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"
|
||||
}
|
||||
Vendored
+6
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Выбери зону/камеру, нажми «Начать» — кадры с веб-камеры пишутся в трек на этой камере. Чтобы
|
||||
эмулировать движение (A→B→C→D→E), повторяй «Новая сессия» с разными камерами — 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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) } };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}м`;
|
||||
}
|
||||
@@ -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).*)'],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user