update the codebase poc ver1

This commit is contained in:
DatTT127
2026-07-07 15:54:17 +07:00
parent fed5f277f4
commit 1622dc8fc5
452 changed files with 83999 additions and 66328 deletions

View File

@@ -0,0 +1,83 @@
# Lumina MSK — Front-end Mockup
Vietnamese-language MSK clinical workspace prototype (Sprint 12, FR-25).
## Screens
| Route | Screen | Use cases |
|-------|--------|-----------|
| `/` | Danh sách công việc (Patient Worklist) | UC-48376 |
| `/workspace/:patientId` | Không gian chẩn đoán (Clinical Workspace) | UC-47988, UC-25776, FR-02 partial (session replay) |
## Run locally
```bash
cd CODEBASE/frontend/implementation
npm install
npm run dev
```
Open http://localhost:5173
Backend proxy (ML analyze): FastAPI on `:8001` — see repo root / backend docs.
## Build
```bash
npm run build
npm run preview
```
## Session recording (PoC)
Implemented in the **Xem lại phiên chẩn đoán** sidebar layer:
| Feature | Modes |
|---------|--------|
| **Một khung** / **Nhiều khung** | `RecordingModeSelector.tsx` |
| Multi-frame UIX vector log + WAL | `uixSessionRecorder.ts`, `workers/uixSessionRecorder.worker.ts` |
| Chronological replay (frame switch + overlay draw) | `uixSessionReplayEngine.ts`, `useDiagnosticSessionReplay.ts` |
| **Hủy ghi** / **Phiên mới** / **Lưu phiên** | `SessionLifecycleToolbar.tsx` |
Full spec: [`../spec/session_recording_frame_switch_spec.md`](../spec/session_recording_frame_switch_spec.md)
Coverage map: [`../spec/uix_prototype_coverage.md`](../spec/uix_prototype_coverage.md)
## File structure
```
implementation/
├── public/assets/ # SVG ultrasound overlays + Stitch reference thumbnails
├── src/
│ ├── components/
│ │ ├── layout/ # TopBar, BottomBar, AppShell
│ │ ├── shells/ # PatientListShell, WorkspaceShell (65/35)
│ │ ├── organisms/ # DiagnosticCanvas, ReviewDiagnosticSessionPanel, ReplaySandboxOverlay
│ │ └── molecules/ # SessionLifecycleToolbar, RecordingModeSelector, PatientCard, …
│ ├── hooks/ # useDiagnosticSessionReplay, useImageCanvasTools, …
│ ├── lib/ # sessionLifecycle, uixSession*, segmentationApi, …
│ ├── workers/ # uixSessionRecorder.worker.ts (IndexedDB WAL)
│ ├── data/ # mockData, scanFrames, diagnosticSessionReplay
│ ├── pages/ # Worklist + Clinical Workspace routes
│ └── styles/global.css # Glassmorphism tokens from spec/DESIGN.md
├── package.json
└── vite.config.ts
```
## Design tokens
Colors, typography, and glassmorphism (`blur(12px)`, `#056B58` mint, teal secondary) follow `../spec/DESIGN.md`.
## Demo flows
### Happy path (grading)
1. Open worklist → **Mở phiên chẩn đoán**.
2. Wait for segmentation / grade in Zone B.
3. Toggle mask, pan/zoom, draw on canvas.
4. **Ký & Hoàn tất** (local draft only).
### Multi-frame session replay
1. Sidebar → **Xem lại phiên chẩn đoán****Nhiều khung****Ghi**.
2. Draw on frame 1 → switch frame → draw on frame 2 → **Kết thúc ghi****Lưu phiên**.
3. Load saved session → **Phát liên tục** → scrub across frame switch and mid-stroke overlay.

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lumina MSK — Không gian Lâm sàng</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@600;700&family=Work+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<defs>
<radialGradient id="heat" cx="55%" cy="42%" r="35%">
<stop offset="0%" stop-color="#ff4444" stop-opacity="0.8"/>
<stop offset="50%" stop-color="#ffaa00" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
<radialGradient id="heat2" cx="38%" cy="55%" r="25%">
<stop offset="0%" stop-color="#ff6600" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
</defs>
<ellipse cx="350" cy="210" rx="120" ry="80" fill="url(#heat)"/>
<ellipse cx="250" cy="270" rx="70" ry="50" fill="url(#heat2)"/>
</svg>

After

Width:  |  Height:  |  Size: 711 B

View File

@@ -0,0 +1,14 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Figma MSK_Workspace_UIX-Design node 244:2415 -->
<path
d="M5.25 12.75C3.75 11.25 3.25 9.25 3.75 7.25C4.5 5 6.75 3.5 9.5 3.75C12.25 4 14.25 5.75 14.75 8.25C15.25 10.75 14.25 13 12.5 14.25M11.75 13.5C12.15 13 12.4 12.4 12.5 11.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="6.75" cy="7.75" r="1.05" fill="currentColor" />
<circle cx="9.25" cy="6.5" r="1.05" fill="currentColor" />
<circle cx="11.75" cy="6.5" r="1.05" fill="currentColor" />
<circle cx="14.25" cy="7.75" r="1.05" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 702 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<path
d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z"
fill="none"
stroke="#056b58"
stroke-width="3"
stroke-dasharray="8 4"
opacity="0.85"
/>
<ellipse
cx="340"
cy="230"
rx="55"
ry="35"
fill="#76c8b1"
opacity="0.35"
stroke="#056b58"
stroke-width="2"
/>
<text x="310" y="235" fill="#ffffff" font-family="sans-serif" font-size="11" font-weight="600">M<EFBFBD>ng H</text>
</svg>

View File

@@ -0,0 +1,35 @@
# Stitch Project Assets
**Project:** MSK Collaborative Workspace
**Project ID:** `17628337625105266704`
| Screen | Screen ID | Route mapping |
|--------|-----------|---------------|
| Danh sách công việc - Lumina MSK | `9d638112797c4b8d8a100d526bab43f2` | `/` (Patient Worklist) |
| Chế độ Kiểm tra Lâm sàng - Lumina MSK (VN) | `fa207d6a4f684f32a3b99706275daad9` | `/workspace/:id` (Clinical Review) |
| Không gian chẩn đoán - AI Review (VN) | `03c138643dd9427aaa59a6e16d089793` | Safety overlay state |
| Chế độ siêu âm gốc - Metadata Tinh gọn (Refined Raw View) | `85ece6c4ed6f4c179042b59813237d44` | Segmentation legend bubble on `DiagnosticCanvas` |
| Annotation Workspace with AI Hub | `3bdfcf6ba964447d88b98c8b9b0df9f1` | Floating `AnnotationRibbon` on `DiagnosticCanvas` |
## Fetching assets
Stitch API requires OAuth2 (`STITCH_ACCESS_TOKEN` + `GOOGLE_CLOUD_PROJECT`), not a generic API key.
```bash
# Using @google/stitch-sdk (after OAuth setup)
npm install @google/stitch-sdk
STITCH_ACCESS_TOKEN=... GOOGLE_CLOUD_PROJECT=... node scripts/fetch-stitch.mjs
```
Or use Stitch MCP `get_screen` with `projectId` and `screenId`, then download:
- `htmlCode.downloadUrl` → save to `public/assets/stitch/<screen>/index.html`
- `screenshot.downloadUrl` → save to `public/assets/stitch/<screen>/screenshot.png`
## Local placeholders
This mockup uses handcrafted SVG assets in `public/assets/` aligned with `spec/DESIGN.md` tokens, since Stitch OAuth was unavailable during build.
Reference thumbnails (design intent):
- `worklist-reference.svg`
- `clinical-review-reference.svg`
- `ai-review-reference.svg`

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)"/>
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" opacity="0.45"/>
<rect x="60" y="80" width="280" height="180" fill="rgba(255,255,255,0.85)" stroke="#ba1a1a" stroke-width="2" rx="12"/>
<text x="80" y="110" fill="#ba1a1a" font-family="sans-serif" font-size="12" font-weight="700">Ch<EFBFBD> <11> Leo thang An to<74>n</text>
<text x="80" y="140" fill="#3e4945" font-family="sans-serif" font-size="11"><10>i tho<68>i Socratic</text>
<rect x="80" y="155" width="240" height="40" fill="#f0f5f4" rx="6" stroke="#bec9c4"/>
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)"/>
</svg>

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)"/>
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" stroke="rgba(5,107,88,0.18)"/>
<rect x="268" y="52" width="116" height="232" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.22)" rx="12"/>
<text x="280" y="80" fill="#23686c" font-family="sans-serif" font-size="10" font-weight="600"><10> XU<58>T AI</text>
<circle cx="326" cy="110" r="20" fill="#e8a838"/>
<text x="320" y="115" fill="#fff" font-family="sans-serif" font-size="16" font-weight="700">2</text>
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
</svg>

After

Width:  |  Height:  |  Size: 827 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
<rect x="16" y="16" width="368" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="8"/>
<text x="32" y="42" fill="#23686c" font-family="sans-serif" font-size="14" font-weight="700">Danh s<>ch c<>ng vi<76>c</text>
<rect x="16" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="136" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="256" y="68" width="128" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="16" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="204" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
<text x="16" y="292" fill="#6f7975" font-family="sans-serif" font-size="10">NFR <20> EMR</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img">
<defs>
<radialGradient id="us-bg" cx="50%" cy="45%" r="55%">
<stop offset="0%" stop-color="#2a3533"/>
<stop offset="100%" stop-color="#0a0f0e"/>
</radialGradient>
<filter id="noise">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feBlend in="SourceGraphic" mode="multiply"/>
</filter>
</defs>
<rect width="640" height="480" fill="url(#us-bg)"/>
<ellipse cx="320" cy="240" rx="220" ry="170" fill="#1a2422" opacity="0.6"/>
<path d="M180 200 Q260 120 400 180 T520 280 Q420 360 280 340 T160 260 Z" fill="#3d4a47" opacity="0.5" filter="url(#noise)"/>
<path d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z" fill="#5a6b66" opacity="0.35"/>
<ellipse cx="340" cy="230" rx="45" ry="28" fill="#8a9a94" opacity="0.25"/>
<text x="20" y="30" fill="#76c8b1" font-family="monospace" font-size="14">SI<EFBFBD>U <20>M <20> KH<4B>P G<>I</text>
<text x="20" y="460" fill="#6f7975" font-family="monospace" font-size="12">12.5 MHz <20> Gain 62</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,17 @@
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const src = join(root, 'node_modules/@mediapipe/tasks-genai/wasm');
const dest = join(root, 'public/mediapipe-wasm');
if (!existsSync(src)) {
console.warn('[sync-mediapipe-wasm] Skip: run npm install first.');
process.exit(0);
}
rmSync(dest, { recursive: true, force: true });
mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
console.log('[sync-mediapipe-wasm] Copied WASM assets to public/mediapipe-wasm');

View File

@@ -0,0 +1,16 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { PatientStoreProvider } from './lib/patientStore';
import PatientWorklistPage from './pages/PatientWorklistPage';
import ClinicalWorkspacePage from './pages/ClinicalWorkspacePage';
export default function App() {
return (
<PatientStoreProvider>
<Routes>
<Route path="/" element={<PatientWorklistPage />} />
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</PatientStoreProvider>
);
}

View File

@@ -0,0 +1,273 @@
import { startTransition, useEffect, useState, type ReactNode } from 'react';
import StreamingPlainText from './StreamingPlainText';
const ORDERED_RE = /^(\d+)\.\s+(.*)$/;
const BULLET_RE = /^(\s*)[\*\-]\s+(.*)$/;
function parseInline(text: string, keyPrefix: string): ReactNode[] {
const nodes: ReactNode[] = [];
let rest = text;
let index = 0;
while (rest.length > 0) {
const boldMatch = rest.match(/^\*\*(.+?)\*\*/);
if (boldMatch) {
nodes.push(
<strong key={`${keyPrefix}-b-${index}`}>{boldMatch[1]}</strong>,
);
rest = rest.slice(boldMatch[0].length);
index += 1;
continue;
}
const italicMatch = rest.match(/^\*([^*]+?)\*/);
if (italicMatch) {
nodes.push(
<em key={`${keyPrefix}-i-${index}`}>{italicMatch[1]}</em>,
);
rest = rest.slice(italicMatch[0].length);
index += 1;
continue;
}
const codeMatch = rest.match(/^`([^`]+)`/);
if (codeMatch) {
nodes.push(
<code key={`${keyPrefix}-c-${index}`} className="chat-md__code">
{codeMatch[1]}
</code>,
);
rest = rest.slice(codeMatch[0].length);
index += 1;
continue;
}
const nextSpecial = rest.search(/\*\*|\*|`/);
if (nextSpecial === -1) {
nodes.push(rest);
break;
}
if (nextSpecial > 0) {
nodes.push(rest.slice(0, nextSpecial));
rest = rest.slice(nextSpecial);
continue;
}
nodes.push(rest[0]);
rest = rest.slice(1);
index += 1;
}
return nodes;
}
interface ListItem {
content: string;
children: ListItem[];
}
function parseListItems(lines: string[], start: number): { items: ListItem[]; next: number } {
const items: ListItem[] = [];
let index = start;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
break;
}
const bullet = line.match(BULLET_RE);
if (!bullet) {
break;
}
const indent = bullet[1].length;
if (items.length > 0 && indent > 0) {
const parent = items[items.length - 1];
const nested = parseListItems(lines, index);
parent.children = nested.items;
index = nested.next;
continue;
}
if (indent > 0) {
break;
}
items.push({ content: bullet[2], children: [] });
index += 1;
}
return { items, next: index };
}
function renderListItems(items: ListItem[], ordered: boolean, keyPrefix: string): ReactNode {
const Tag = ordered ? 'ol' : 'ul';
return (
<Tag
key={keyPrefix}
className={ordered ? 'chat-md__ol' : 'chat-md__ul'}
>
{items.map((item, itemIndex) => (
<li key={`${keyPrefix}-${itemIndex}`}>
{parseInline(item.content, `${keyPrefix}-${itemIndex}`)}
{item.children.length > 0
? renderListItems(item.children, false, `${keyPrefix}-${itemIndex}-sub`)
: null}
</li>
))}
</Tag>
);
}
function renderBlocks(text: string): ReactNode[] {
const lines = text.replace(/\r\n/g, '\n').split('\n');
const blocks: ReactNode[] = [];
let index = 0;
let blockKey = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index += 1;
continue;
}
const ordered = line.match(ORDERED_RE);
if (ordered) {
const items: ListItem[] = [];
while (index < lines.length) {
const current = lines[index];
const match = current.match(ORDERED_RE);
if (!match) {
break;
}
const item: ListItem = { content: match[2], children: [] };
index += 1;
const nested = parseListItems(lines, index);
item.children = nested.items;
index = nested.next;
items.push(item);
}
blocks.push(renderListItems(items, true, `ol-${blockKey}`));
blockKey += 1;
continue;
}
const bullet = line.match(BULLET_RE);
if (bullet && bullet[1].length === 0) {
const { items, next } = parseListItems(lines, index);
blocks.push(renderListItems(items, false, `ul-${blockKey}`));
blockKey += 1;
index = next;
continue;
}
const heading = line.match(/^(#{1,3})\s+(.*)$/);
if (heading) {
const level = heading[1].length;
const Tag = level === 1 ? 'h4' : level === 2 ? 'h5' : 'h6';
blocks.push(
<Tag key={`h-${blockKey}`} className="chat-md__heading">
{parseInline(heading[2], `h-${blockKey}`)}
</Tag>,
);
blockKey += 1;
index += 1;
continue;
}
const paragraphLines: string[] = [];
while (index < lines.length && lines[index].trim()) {
const current = lines[index];
if (
ORDERED_RE.test(current) ||
BULLET_RE.test(current) ||
/^#{1,3}\s/.test(current)
) {
break;
}
paragraphLines.push(current);
index += 1;
}
blocks.push(
<p key={`p-${blockKey}`} className="chat-md__p">
{parseInline(paragraphLines.join(' '), `p-${blockKey}`)}
</p>,
);
blockKey += 1;
}
return blocks;
}
function scheduleIdle(work: () => void): () => void {
if (typeof requestIdleCallback !== 'undefined') {
const id = requestIdleCallback(work, { timeout: 150 });
return () => cancelIdleCallback(id);
}
const id = window.setTimeout(work, 0);
return () => clearTimeout(id);
}
interface ChatMarkdownProps {
text: string;
className?: string;
/** Plain text while tokens stream in; formatted markdown once complete. */
streaming?: boolean;
}
export default function ChatMarkdown({ text, className, streaming = false }: ChatMarkdownProps) {
const [formattedBlocks, setFormattedBlocks] = useState<ReactNode[] | null>(null);
useEffect(() => {
if (streaming) {
setFormattedBlocks(null);
return;
}
let cancelled = false;
const cancelIdle = scheduleIdle(() => {
if (cancelled) {
return;
}
const blocks = renderBlocks(text);
startTransition(() => {
if (!cancelled) {
setFormattedBlocks(blocks);
}
});
});
return () => {
cancelled = true;
cancelIdle();
};
}, [streaming, text]);
if (!text.trim()) {
return null;
}
const showPlain = streaming || formattedBlocks === null;
if (showPlain) {
return (
<div className={className ? `chat-md chat-md--plain ${className}` : 'chat-md chat-md--plain'}>
{streaming ? (
<StreamingPlainText text={text} className="chat-md__plain" />
) : (
<p className="chat-md__plain">{text}</p>
)}
</div>
);
}
return (
<div className={className ? `chat-md ${className}` : 'chat-md'}>
{formattedBlocks}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
const CHAT_PHRASES = [
'Đang suy nghĩ',
'Đang phân tích',
'Đang cân nhắc',
'Đang tổng hợp',
] as const;
const AGENT_PHRASES = [
'Đang suy nghĩ',
'Đang lập kế hoạch',
'Đang chuẩn bị',
'Đang xử lý',
] as const;
export type ClinicalChatPonderingVariant = 'chat' | 'agent';
interface ClinicalChatPonderingTextProps {
variant?: ClinicalChatPonderingVariant;
}
export default function ClinicalChatPonderingText({
variant = 'chat',
}: ClinicalChatPonderingTextProps) {
const phrases = variant === 'agent' ? AGENT_PHRASES : CHAT_PHRASES;
const [index, setIndex] = useState(0);
const [flickerKey, setFlickerKey] = useState(0);
useEffect(() => {
const phraseTimer = window.setInterval(() => {
setIndex((current) => (current + 1) % phrases.length);
setFlickerKey((current) => current + 1);
}, 2_400);
return () => window.clearInterval(phraseTimer);
}, [phrases.length]);
return (
<span
key={flickerKey}
className="clinical-chat__pondering"
aria-live="polite"
aria-label={`${phrases[index]}, vui lòng đợi`}
>
<span className="clinical-chat__pondering-text">{phrases[index]}</span>
<span className="clinical-chat__pondering-dots" aria-hidden>
<span />
<span />
<span />
</span>
</span>
);
}

View File

@@ -0,0 +1,42 @@
import { memo, useLayoutEffect, useRef } from 'react';
import { registerClinicalStreamTarget } from '../../lib/llm/clinicalChatStreamRegistry';
interface StreamingPlainTextProps {
text: string;
className?: string;
/** When set, DOM is updated imperatively during Ollama token stream. */
streamTargetKey?: string;
}
/** Updates streaming text via DOM textContent to avoid React child reconciliation. */
function StreamingPlainText({ text, className, streamTargetKey }: StreamingPlainTextProps) {
const ref = useRef<HTMLParagraphElement>(null);
useLayoutEffect(() => {
if (!streamTargetKey) {
return;
}
const el = ref.current;
registerClinicalStreamTarget(streamTargetKey, el);
if (el && text && !el.textContent) {
el.textContent = text;
}
return () => registerClinicalStreamTarget(streamTargetKey, null);
}, [streamTargetKey, text]);
useLayoutEffect(() => {
const el = ref.current;
if (!el || el.textContent === text) {
return;
}
// Imperative Ollama stream owns the DOM while tokens arrive.
if (streamTargetKey) {
return;
}
el.textContent = text;
}, [text, streamTargetKey]);
return <p ref={ref} className={className} />;
}
export default memo(StreamingPlainText);

View File

@@ -0,0 +1,28 @@
/**
* Figma: MSK_Workspace_UIX-Design → node 244:2415 (`palette`, 20×20)
* https://www.figma.com/design/uakk3T4efS1AIckfTEFSZO/MSK_Workspace_UIX-Design?node-id=244-2415
*/
export default function PaletteIcon({ size = 18 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M5.25 12.75C3.75 11.25 3.25 9.25 3.75 7.25C4.5 5 6.75 3.5 9.5 3.75C12.25 4 14.25 5.75 14.75 8.25C15.25 10.75 14.25 13 12.5 14.25M11.75 13.5C12.15 13 12.4 12.4 12.5 11.75"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="6.75" cy="7.75" r="1.05" fill="currentColor" />
<circle cx="9.25" cy="6.5" r="1.05" fill="currentColor" />
<circle cx="11.75" cy="6.5" r="1.05" fill="currentColor" />
<circle cx="14.25" cy="7.75" r="1.05" fill="currentColor" />
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
import TopBar from './TopBar';
import BottomBar from './BottomBar';
interface AppShellProps {
children: ReactNode;
topBar: React.ComponentProps<typeof TopBar>;
}
export default function AppShell({ children, topBar }: AppShellProps) {
return (
<div className="app-shell">
<TopBar {...topBar} />
<main className="app-main">{children}</main>
<BottomBar />
</div>
);
}

View File

@@ -0,0 +1,26 @@
export default function BottomBar() {
return (
<footer className="bottombar glass">
<span className="bottombar__copyright">© 2026, by VKIST</span>
<style>{`
.bottombar {
height: var(--bottombar-h);
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-md);
font-size: 12px;
color: var(--color-on-surface-variant);
border-radius: 0;
border-left: none;
border-right: none;
border-bottom: none;
flex-shrink: 0;
}
.bottombar__copyright {
letter-spacing: 0.02em;
}
`}</style>
</footer>
);
}

View File

@@ -0,0 +1,103 @@
import type { ReactNode } from 'react';
interface TopBarProps {
title?: string;
subtitle?: string;
showBack?: boolean;
onBack?: () => void;
actions?: ReactNode;
phase?: string;
}
export default function TopBar({
title = 'Lumina MSK',
subtitle,
showBack,
onBack,
actions,
phase,
}: TopBarProps) {
return (
<header className="topbar glass">
<div className="topbar__left">
{showBack && (
<button type="button" className="icon-btn" onClick={onBack} aria-label="Quay lại">
</button>
)}
<div className="topbar__brand">
<span className="topbar__logo"></span>
<div>
<h1 className="topbar__title">{title}</h1>
{subtitle && <p className="topbar__subtitle">{subtitle}</p>}
</div>
</div>
</div>
<div className="topbar__center">
{phase && <span className="topbar__phase chip chip-success">{phase}</span>}
</div>
<div className="topbar__actions">{actions}</div>
<style>{`
.topbar {
height: var(--topbar-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-md);
gap: var(--space-md);
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
position: sticky;
top: 0;
z-index: 100;
}
.topbar__left {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.topbar__brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.topbar__logo {
color: var(--color-primary);
font-size: 20px;
}
.topbar__title {
font-size: 16px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar__subtitle {
margin: 0;
font-size: 12px;
color: var(--color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar__center {
flex-shrink: 0;
}
.topbar__phase {
font-size: 11px;
}
.topbar__actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
`}</style>
</header>
);
}

View File

@@ -0,0 +1,202 @@
import { useMemo } from 'react';
import AngleLegendSwitchGuide from './AngleLegendSwitchGuide';
import CalibratedDistributionList, { CalibrationStateBanner } from './CalibratedDistributionList.jsx';
import {
ANGLE_CLASSES,
formatAngleClinicalLabel,
getAngleMeta,
isAngleClass,
type AngleClass,
type AngleClassificationResult,
} from '../../data/angleClassification';
import {
interpretLogits,
predictedClassFromProbabilities,
type CalibrationUserConfig,
} from '../../lib/calibrationEngine';
import { formatRiskFramingVi } from '../../types/calibratedPrediction';
import CalibrationMetricHelp from './CalibrationMetricHelp';
interface AngleClassificationBlobProps {
classification: AngleClassificationResult | null;
userConfig: CalibrationUserConfig;
suppressed?: boolean;
}
const ANGLE_DISTRIBUTION_ENTRIES = ANGLE_CLASSES.map((angleClass) => ({
key: angleClass,
label: formatAngleClinicalLabel(getAngleMeta(angleClass)),
}));
function formatClassLabel(className: string): string {
if (isAngleClass(className)) {
return formatAngleClinicalLabel(getAngleMeta(className));
}
return className;
}
export default function AngleClassificationBlob({
classification,
userConfig,
suppressed,
}: AngleClassificationBlobProps) {
const liveCalibration = useMemo(() => {
const raw = classification?.calibration?.raw_logits;
if (!raw?.length) return classification?.calibration ?? null;
return interpretLogits(raw, ANGLE_CLASSES, userConfig, formatClassLabel);
}, [classification, userConfig]);
const displayClass: AngleClass | null = useMemo(() => {
if (!classification) return null;
if (!liveCalibration) return classification.class;
const predicted = predictedClassFromProbabilities(liveCalibration.class_probabilities, ANGLE_CLASSES);
return isAngleClass(predicted) ? predicted : classification.class;
}, [classification, liveCalibration]);
if (suppressed) {
return null;
}
if (!classification || !displayClass) {
return (
<div className="angle-blob glass">
<span className="angle-blob__eyebrow">Phân loại góc chụp</span>
<p className="angle-blob__pending">Đang chờ kết quả phân loại góc</p>
<style>{styles}</style>
</div>
);
}
const meta = getAngleMeta(displayClass);
const modelDefaultMeta = getAngleMeta(classification.class);
const shiftedFromModel = displayClass !== classification.class;
const riskText =
liveCalibration?.risk_framing_vi ??
formatRiskFramingVi(formatAngleClinicalLabel(meta), classification.confidence);
return (
<div className="angle-blob glass">
<span className="angle-blob__eyebrow">Phân loại góc chụp</span>
<div className="angle-blob__result">
<strong>{formatAngleClinicalLabel(meta)}</strong>
{shiftedFromModel && (
<p className="angle-blob__shift">
hình gốc: {formatAngleClinicalLabel(modelDefaultMeta)} đã điều chỉnh theo T / nghi ngờ lâm sàng.
</p>
)}
{liveCalibration && <CalibrationStateBanner calibration={liveCalibration} />}
<p className="angle-blob__risk">{riskText}</p>
</div>
<div className="angle-blob__legend-block">
<AngleLegendSwitchGuide angleClass={displayClass} />
</div>
<details className="angle-blob__classes" open={liveCalibration?.decision_state !== 'confident'}>
<summary>Phân bố xác suất theo lớp</summary>
<CalibrationMetricHelp layout="block" />
{liveCalibration ? (
<CalibratedDistributionList
calibration={liveCalibration}
entries={ANGLE_DISTRIBUTION_ENTRIES}
predictedKey={displayClass}
/>
) : (
<ul>
{ANGLE_CLASSES.map((angleClass) => {
const item = getAngleMeta(angleClass);
const active = angleClass === displayClass;
return (
<li key={angleClass} className={active ? 'angle-blob__class--active' : undefined}>
<span>{formatAngleClinicalLabel(item)}</span>
</li>
);
})}
</ul>
)}
</details>
<style>{styles}</style>
</div>
);
}
const styles = `
.angle-blob {
margin-top: 4px;
padding: 14px;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 10px;
font-size: 1em;
}
.angle-blob__eyebrow {
font-size: 0.6875em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-on-surface-variant);
}
.angle-blob__pending {
margin: 0;
font-size: 0.8125em;
color: var(--color-on-surface-variant);
}
.angle-blob__result strong {
display: block;
font-size: 0.9375em;
font-family: var(--font-headline);
line-height: 1.4;
margin-bottom: 2px;
}
.angle-blob__shift {
margin: 4px 0 0;
font-size: 0.6875em;
color: var(--color-on-surface-variant);
font-style: italic;
line-height: 1.4;
}
.angle-blob__risk {
margin: 8px 0 0;
font-size: 0.8125em;
color: var(--color-on-surface-variant);
line-height: 1.45;
}
.angle-blob__legend-block {
padding: 12px;
border-radius: var(--radius-sm);
background: rgba(35, 104, 108, 0.06);
border-left: 3px solid var(--color-secondary);
}
.angle-blob__classes {
font-size: 0.875em;
color: var(--color-on-surface-variant);
}
.angle-blob__classes summary {
cursor: pointer;
font-weight: 600;
font-size: 1em;
color: var(--color-secondary);
}
.angle-blob__classes ul {
margin: 8px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.angle-blob__classes li {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
line-height: 1.4;
}
.angle-blob__class--active > span:first-child {
color: var(--color-secondary);
font-weight: 600;
}
`;

View File

@@ -0,0 +1,46 @@
import type { ReactNode } from 'react';
export type AngleLegendColorTone = 'red' | 'purple' | 'cyan' | 'yellow' | 'green' | 'blue';
interface AngleLegendColorTermProps {
tone: AngleLegendColorTone;
children: ReactNode;
}
export default function AngleLegendColorTerm({ tone, children }: AngleLegendColorTermProps) {
return (
<span className={`angle-legend-color angle-legend-color--${tone}`}>{children}</span>
);
}
export const angleLegendColorStyles = `
.angle-legend-color {
font-weight: 600;
padding: 0 4px;
border-radius: 3px;
}
.angle-legend-color--red {
color: rgb(185, 28, 28);
background: rgba(255, 0, 0, 0.12);
}
.angle-legend-color--purple {
color: rgb(156, 39, 176);
background: rgba(255, 0, 255, 0.12);
}
.angle-legend-color--cyan {
color: rgb(0, 131, 143);
background: rgba(0, 255, 255, 0.18);
}
.angle-legend-color--yellow {
color: rgb(161, 98, 7);
background: rgba(255, 255, 0, 0.22);
}
.angle-legend-color--green {
color: rgb(27, 122, 55);
background: rgba(0, 255, 0, 0.14);
}
.angle-legend-color--blue {
color: rgb(25, 78, 166);
background: rgba(0, 0, 255, 0.1);
}
`;

View File

@@ -0,0 +1,213 @@
import {
getAngleMeta,
type AngleClass,
} from '../../data/angleClassification';
import AngleLegendColorTerm, { angleLegendColorStyles } from './AngleLegendColorTerm';
interface AngleLegendSwitchGuideProps {
angleClass: AngleClass;
}
function SupFamilyColorList({ includeHoffa = false }: { includeHoffa?: boolean }) {
return (
<ul className="angle-switch-guide__colors">
<li>
<AngleLegendColorTerm tone="red">Đ</AngleLegendColorTerm>: Dịch khớp (Joint effusion).
</li>
<li>
<AngleLegendColorTerm tone="purple">Tím</AngleLegendColorTerm>: Màng hoạt dịch (Synovium).
</li>
<li>
<strong>Viền trắng dày</strong> trên overlay: bao quanh vùng viêm (dịch khớp màng hoạt dịch).
</li>
{includeHoffa && (
<li>
<AngleLegendColorTerm tone="cyan">Lam sáng</AngleLegendColorTerm>: Mỡ Hoffa (Hoffa fat pad).
</li>
)}
</ul>
);
}
function PostTransColorList() {
return (
<ul className="angle-switch-guide__colors">
<li>
<AngleLegendColorTerm tone="red">Đ</AngleLegendColorTerm>: Nang Baker (Baker&apos;s cyst) thay dịch
khớp như mặt trước.
</li>
<li>
<AngleLegendColorTerm tone="cyan">Lam sáng</AngleLegendColorTerm>: vùng khoeo thay mỡ Hoffa.
</li>
<li>
<AngleLegendColorTerm tone="yellow">Vàng</AngleLegendColorTerm>: Mỡ.
</li>
<li>
<AngleLegendColorTerm tone="green">Xanh </AngleLegendColorTerm>: Xương đùi.
</li>
</ul>
);
}
function CurrentAngleSection({
angleClass,
sectionNumber,
}: {
angleClass: AngleClass;
sectionNumber: number;
}) {
const meta = getAngleMeta(angleClass);
if (angleClass === 'post-trans') {
return (
<section className="angle-switch-guide__section">
<h4 className="angle-switch-guide__section-title">
{sectionNumber}. Góc {meta.labelMedicalEn} ({meta.legendPlaneVi})
</h4>
<p className="angle-switch-guide__catalog"> màu (Theo Phụ lục A.2):</p>
<PostTransColorList />
</section>
);
}
if (angleClass === 'sup-up-long') {
return (
<section className="angle-switch-guide__section">
<h4 className="angle-switch-guide__section-title">
{sectionNumber}. Góc {meta.labelMedicalEn} ({meta.legendPlaneVi})
</h4>
<p className="angle-switch-guide__catalog"> màu (Theo Phụ lục A.1):</p>
<SupFamilyColorList includeHoffa />
<p className="angle-switch-guide__tech-note">
<strong>Lưu ý kỹ thuật:</strong> Góc này hỗ trợ thước đo đ dày tự đng (khung xanh, đưng ngọc, đưng
đ trên overlay).
</p>
</section>
);
}
const noThickness = angleClass === 'sup-trans-flex' || angleClass === 'med-lat';
const supLong = getAngleMeta('sup-up-long');
return (
<section className="angle-switch-guide__section">
<h4 className="angle-switch-guide__section-title">
{sectionNumber}. Góc {meta.labelMedicalEn} ({meta.legendPlaneVi})
</h4>
<p className="angle-switch-guide__catalog"> màu (Theo Phụ lục A.1):</p>
<SupFamilyColorList includeHoffa={angleClass === 'med-lat'} />
{noThickness && (
<p className="angle-switch-guide__tech-note">
<strong>Lưu ý kỹ thuật:</strong> Mặt cắt này không hiển thị thước đo đ dày tự đng (Tính năng đo đ
dày chỉ khả dụng trên mặt cắt dọc {supLong.labelMedicalEn}).
</p>
)}
</section>
);
}
function SwitchTargetSection({
angleClass,
sectionNumber,
}: {
angleClass: AngleClass;
sectionNumber: number;
}) {
const isPost = angleClass === 'post-trans';
const target = isPost ? getAngleMeta('sup-up-long') : getAngleMeta('post-trans');
return (
<section className="angle-switch-guide__section">
<h4 className="angle-switch-guide__section-title">
{sectionNumber}. Góc {target.labelMedicalEn} ({target.legendPlaneVi})
</h4>
<p className="angle-switch-guide__lead">
{isPost
? 'Khi chuyển sang góc quét phía trên bánh chè, định dạng màu quay về Phụ lục A.1:'
: 'Khi chuyển sang góc quét này, định dạng màu sẽ tự động tái cấu trúc (Phụ lục A.2):'}
</p>
{isPost ? (
<SupFamilyColorList includeHoffa />
) : (
<PostTransColorList />
)}
</section>
);
}
export default function AngleLegendSwitchGuide({ angleClass }: AngleLegendSwitchGuideProps) {
return (
<div className="angle-switch-guide">
<h3 className="angle-switch-guide__heading"> Lưu ý quan trọng khi đi góc quét</h3>
<p className="angle-switch-guide__intro">
Khi thay đi góc quét siêu âm, ý nghĩa hiển thị của các vùng màu (overlay) bong bóng thông tin
(tooltip) sẽ thay đi như sau:
</p>
<CurrentAngleSection angleClass={angleClass} sectionNumber={1} />
<SwitchTargetSection angleClass={angleClass} sectionNumber={2} />
<style>{guideStyles}</style>
<style>{angleLegendColorStyles}</style>
</div>
);
}
const guideStyles = `
.angle-switch-guide {
display: flex;
flex-direction: column;
gap: 10px;
font-size: 1em;
}
.angle-switch-guide__heading {
margin: 0;
font-size: 0.6875em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-secondary);
line-height: 1.35;
}
.angle-switch-guide__intro {
margin: 0;
font-size: 0.8125em;
line-height: 1.45;
color: var(--color-on-surface);
}
.angle-switch-guide__section {
margin: 0;
padding-top: 4px;
}
.angle-switch-guide__section-title {
margin: 0 0 6px;
font-size: 0.8125em;
font-weight: 600;
line-height: 1.4;
color: var(--color-on-surface);
}
.angle-switch-guide__catalog,
.angle-switch-guide__lead {
margin: 0 0 6px;
font-size: 0.75em;
font-weight: 500;
color: var(--color-on-surface-variant);
}
.angle-switch-guide__colors {
margin: 0;
padding: 0 0 0 16px;
display: flex;
flex-direction: column;
gap: 5px;
font-size: 0.8125em;
line-height: 1.45;
color: var(--color-on-surface);
}
.angle-switch-guide__tech-note {
margin: 8px 0 0;
font-size: 0.75em;
line-height: 1.45;
color: var(--color-on-surface-variant);
}
.angle-switch-guide__tech-note strong {
color: var(--color-on-surface);
}
`;

View File

@@ -0,0 +1,268 @@
import { useEffect, useId, useRef, useState } from 'react';
import PaletteIcon from '../icons/PaletteIcon';
import { PEN_INK_PALETTE, type PenInkColorOption } from '../../types/canvasAnnotation';
interface AnnotationColorPickerProps {
inkColor: string;
onInkColorChange: (hex: string) => void;
disabled?: boolean;
}
export default function AnnotationColorPicker({
inkColor,
onInkColorChange,
disabled,
}: AnnotationColorPickerProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const listId = useId();
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
const selectColor = (hex: string) => {
onInkColorChange(hex);
setOpen(false);
};
return (
<div ref={rootRef} className="annotation-color-picker" onPointerDown={(event) => event.stopPropagation()}>
<button
type="button"
className={
open
? 'annotation-color-picker__trigger annotation-color-picker__trigger--open'
: 'annotation-color-picker__trigger'
}
onClick={() => setOpen((value) => !value)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
title="Bảng màu bút vẽ"
>
<span className="annotation-color-picker__palette-icon" aria-hidden>
<PaletteIcon size={18} />
</span>
<span
className="annotation-color-picker__ink-dot"
style={{ backgroundColor: inkColor }}
aria-hidden
/>
<span className="annotation-color-picker__label">Màu</span>
</button>
{open && (
<div
id={listId}
className="annotation-color-picker__popover glass"
role="listbox"
aria-label="Chọn màu mực bút vẽ"
>
<p className="annotation-color-picker__heading">Màu bút vẽ</p>
<div className="annotation-color-picker__grid">
{PEN_INK_PALETTE.map((option) => (
<Swatch
key={option.id}
option={option}
selected={inkColor.toLowerCase() === option.hex.toLowerCase()}
onSelect={() => selectColor(option.hex)}
/>
))}
</div>
</div>
)}
<style>{styles}</style>
</div>
);
}
function Swatch({
option,
selected,
onSelect,
}: {
option: PenInkColorOption;
selected: boolean;
onSelect: () => void;
}) {
const needsContrast =
option.contrastOnDark || option.contrastOnLight;
return (
<button
type="button"
role="option"
aria-selected={selected}
aria-label={option.labelVi}
title={`${option.labelVi} (${option.hex})`}
className={
selected
? 'annotation-color-picker__swatch annotation-color-picker__swatch--selected'
: 'annotation-color-picker__swatch'
}
onClick={onSelect}
>
<span
className={
needsContrast
? 'annotation-color-picker__swatch-fill annotation-color-picker__swatch-fill--contrast'
: 'annotation-color-picker__swatch-fill'
}
style={{ backgroundColor: option.hex }}
/>
{selected && (
<span className="annotation-color-picker__check" aria-hidden>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M5 13l4 4L19 7" />
</svg>
</span>
)}
</button>
);
}
const styles = `
.annotation-color-picker {
position: relative;
display: flex;
align-items: center;
}
.annotation-color-picker__trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.annotation-color-picker__trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
}
.annotation-color-picker__trigger--open {
background: rgba(35, 104, 108, 0.85);
color: #fff;
}
.annotation-color-picker__trigger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.annotation-color-picker__palette-icon {
display: flex;
align-items: center;
justify-content: center;
}
.annotation-color-picker__ink-dot {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.annotation-color-picker__label {
line-height: 1;
}
.annotation-color-picker__popover {
position: absolute;
left: 50%;
bottom: calc(100% + 10px);
transform: translateX(-50%);
z-index: 40;
min-width: 188px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(173, 238, 242, 0.35);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
background: rgba(23, 29, 28, 0.94);
backdrop-filter: blur(12px);
}
.annotation-color-picker__heading {
margin: 0 0 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(173, 238, 242, 0.9);
}
.annotation-color-picker__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.annotation-color-picker__swatch {
position: relative;
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.12s;
}
.annotation-color-picker__swatch:hover {
transform: scale(1.08);
}
.annotation-color-picker__swatch--selected {
outline: 2px solid #adeef2;
outline-offset: 2px;
}
.annotation-color-picker__swatch-fill {
width: 28px;
height: 28px;
border-radius: 999px;
display: block;
}
.annotation-color-picker__swatch-fill--contrast {
border: 1.5px solid rgba(255, 255, 255, 0.55);
box-shadow: inset 0 0 0 1px rgba(23, 29, 28, 0.25);
}
.annotation-color-picker__check {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.65));
pointer-events: none;
}
@media (max-width: 640px) {
.annotation-color-picker__label {
display: none;
}
.annotation-color-picker__trigger {
padding: 10px;
}
}
`;

View File

@@ -0,0 +1,329 @@
import type { RefObject } from 'react';
import type { CanvasTool } from '../../types/canvasAnnotation';
import { useDraggableRibbon } from '../../hooks/useDraggableRibbon';
import AnnotationColorPicker from './AnnotationColorPicker';
interface AnnotationRibbonProps {
containerRef: RefObject<HTMLElement | null>;
tool: CanvasTool;
onToolChange: (tool: CanvasTool) => void;
inkColor: string;
onInkColorChange: (hex: string) => void;
zoom: number;
onZoomIn: () => void;
onZoomOut: () => void;
onResetView: () => void;
onClearAnnotations: () => void;
hasAnnotations: boolean;
disabled?: boolean;
}
function ToolIcon({ children }: { children: React.ReactNode }) {
return <span className="annotation-ribbon__icon" aria-hidden>{children}</span>;
}
export default function AnnotationRibbon({
containerRef,
tool,
onToolChange,
inkColor,
onInkColorChange,
zoom,
onZoomIn,
onZoomOut,
onResetView,
onClearAnnotations,
hasAnnotations,
disabled,
}: AnnotationRibbonProps) {
const zoomPct = Math.round(zoom * 100);
const { panelRef, position, isDragging, dragHandleProps } = useDraggableRibbon(containerRef);
const panelStyle =
position != null
? { left: position.x, top: position.y, transform: 'none' as const }
: { left: '50%', bottom: 16, top: 'auto' as const, transform: 'translateX(-50%)' };
return (
<div
ref={panelRef}
className={`annotation-ribbon glass ${disabled ? 'annotation-ribbon--disabled' : ''} ${isDragging ? 'annotation-ribbon--dragging' : ''}`}
style={panelStyle}
role="toolbar"
aria-label="Công cụ chú thích khung hình"
onPointerDown={(event) => event.stopPropagation()}
>
<button
type="button"
className="annotation-ribbon__drag-handle"
aria-label="Kéo để di chuyển thanh công cụ"
title="Kéo để di chuyển"
disabled={disabled}
{...dragHandleProps}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<circle cx="9" cy="7" r="1.5" />
<circle cx="15" cy="7" r="1.5" />
<circle cx="9" cy="12" r="1.5" />
<circle cx="15" cy="12" r="1.5" />
<circle cx="9" cy="17" r="1.5" />
<circle cx="15" cy="17" r="1.5" />
</svg>
</button>
<span className="annotation-ribbon__divider" aria-hidden />
<div className="annotation-ribbon__group" role="group" aria-label="Vẽ và tẩy">
<button
type="button"
className={tool === 'draw' ? 'annotation-ribbon__btn annotation-ribbon__btn--active' : 'annotation-ribbon__btn'}
onClick={() => onToolChange('draw')}
disabled={disabled}
aria-pressed={tool === 'draw'}
title="Vẽ — chuột hoặc cảm ứng"
>
<ToolIcon>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z" />
<path d="m13.5 6.5 3 3" />
</svg>
</ToolIcon>
<span className="annotation-ribbon__label">Vẽ</span>
</button>
<button
type="button"
className={tool === 'eraser' ? 'annotation-ribbon__btn annotation-ribbon__btn--active' : 'annotation-ribbon__btn'}
onClick={() => onToolChange('eraser')}
disabled={disabled}
aria-pressed={tool === 'eraser'}
title="Tẩy nét vẽ"
>
<ToolIcon>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M20 20H8l-6.5-6.5a2.1 2.1 0 0 1 0-3L14 4.5a2.1 2.1 0 0 1 3 0L20 7.5" />
<path d="m6.5 13.5 6 6" />
</svg>
</ToolIcon>
<span className="annotation-ribbon__label">Tẩy</span>
</button>
<AnnotationColorPicker
inkColor={inkColor}
onInkColorChange={onInkColorChange}
disabled={disabled}
/>
</div>
<span className="annotation-ribbon__divider" aria-hidden />
<div className="annotation-ribbon__group" role="group" aria-label="Di chuyển khung">
<button
type="button"
className={tool === 'pan' ? 'annotation-ribbon__btn annotation-ribbon__btn--active' : 'annotation-ribbon__btn'}
onClick={() => onToolChange('pan')}
disabled={disabled}
aria-pressed={tool === 'pan'}
title="Di chuyển — bật để kéo hai ngón (trackpad/cảm ứng) hoặc kéo chuột"
>
<ToolIcon>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 11V5a2 2 0 0 0-4 0v4M14 11V4a2 2 0 0 0-4 0v7M10 11V5a2 2 0 0 0-4 0v8a8 8 0 0 0 16 0v-5a2 2 0 0 0-4 0" />
</svg>
</ToolIcon>
<span className="annotation-ribbon__label">Di chuyển</span>
</button>
</div>
<span className="annotation-ribbon__divider" aria-hidden />
<div className="annotation-ribbon__group" role="group" aria-label="Phóng to thu nhỏ">
<button
type="button"
className="annotation-ribbon__btn annotation-ribbon__btn--compact"
onClick={onZoomOut}
disabled={disabled || zoom <= 1}
title="Thu nhỏ"
aria-label="Thu nhỏ"
>
<ToolIcon>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="M8 11h6M21 21l-4.35-4.35" />
</svg>
</ToolIcon>
</button>
<button
type="button"
className="annotation-ribbon__zoom-readout tnum"
onClick={onResetView}
disabled={disabled}
title="Đặt lại vừa khung"
>
{zoomPct}%
</button>
<button
type="button"
className="annotation-ribbon__btn annotation-ribbon__btn--compact"
onClick={onZoomIn}
disabled={disabled || zoom >= 4}
title="Phóng to"
aria-label="Phóng to"
>
<ToolIcon>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="M11 8v6M8 11h6M21 21l-4.35-4.35" />
</svg>
</ToolIcon>
</button>
</div>
{hasAnnotations && (
<>
<span className="annotation-ribbon__divider" aria-hidden />
<button
type="button"
className="annotation-ribbon__btn annotation-ribbon__btn--ghost"
onClick={onClearAnnotations}
disabled={disabled}
title="Xóa tất cả nét vẽ trên khung này"
>
<ToolIcon>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14" />
</svg>
</ToolIcon>
<span className="annotation-ribbon__label">Xóa nét</span>
</button>
</>
)}
<style>{styles}</style>
</div>
);
}
const styles = `
.annotation-ribbon {
position: absolute;
z-index: 30;
pointer-events: auto;
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
border-radius: 999px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
border: 1px solid rgba(173, 238, 242, 0.35);
max-width: calc(100% - 24px);
flex-wrap: wrap;
justify-content: center;
touch-action: none;
user-select: none;
}
.annotation-ribbon--dragging {
cursor: grabbing;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
}
.annotation-ribbon--disabled {
opacity: 0.45;
pointer-events: none;
}
.annotation-ribbon__drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.55);
cursor: grab;
flex-shrink: 0;
touch-action: none;
}
.annotation-ribbon__drag-handle:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
}
.annotation-ribbon--dragging .annotation-ribbon__drag-handle {
cursor: grabbing;
color: #adeef2;
}
.annotation-ribbon__group {
display: flex;
align-items: center;
gap: 2px;
}
.annotation-ribbon__divider {
width: 1px;
height: 28px;
background: rgba(255, 255, 255, 0.2);
margin: 0 4px;
flex-shrink: 0;
}
.annotation-ribbon__btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border: none;
border-radius: 999px;
background: transparent;
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.annotation-ribbon__btn--compact {
padding: 8px 10px;
}
.annotation-ribbon__btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.12);
}
.annotation-ribbon__btn--active {
background: rgba(35, 104, 108, 0.85);
color: #fff;
}
.annotation-ribbon__btn--ghost {
color: rgba(255, 200, 200, 0.95);
}
.annotation-ribbon__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.annotation-ribbon__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.annotation-ribbon__label {
line-height: 1;
}
.annotation-ribbon__zoom-readout {
min-width: 3.25rem;
padding: 8px 6px;
border: none;
border-radius: 999px;
background: rgba(0, 0, 0, 0.35);
color: #adeef2;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.annotation-ribbon__zoom-readout:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.5);
}
@media (max-width: 640px) {
.annotation-ribbon__label {
display: none;
}
.annotation-ribbon__btn {
padding: 10px;
}
}
`;

View File

@@ -0,0 +1,179 @@
import {
OOD_CLINICIAN_BANNER_VI,
type CalibratedPrediction,
} from '../../types/calibratedPrediction';
export interface DistributionEntry {
key: string;
label: string;
}
interface CalibratedDistributionListProps {
calibration: CalibratedPrediction;
entries: DistributionEntry[];
predictedKey: string;
}
export default function CalibratedDistributionList({
calibration,
entries,
predictedKey,
}: CalibratedDistributionListProps) {
const maxProb = Math.max(...Object.values(calibration.class_probabilities), 1);
return (
<table className="cal-dist" role="table">
<caption className="cal-dist__caption">Phân bố xác suất theo lớp</caption>
<tbody>
{entries.map(({ key, label }) => {
const prob = calibration.class_probabilities[key] ?? 0;
const active = key === predictedKey;
const ambiguous = calibration.ambiguous_set.includes(key);
return (
<tr key={key} className={active ? 'cal-dist__row--active' : undefined}>
<td className="cal-dist__label-cell">
<span className="cal-dist__label">{label}</span>
{ambiguous && calibration.decision_state === 'ambiguous' && (
<span className="cal-dist__tag"> hồ</span>
)}
<div className="cal-dist__bar-track" aria-hidden>
<div
className="cal-dist__bar-fill"
style={{ width: `${(prob / maxProb) * 100}%` }}
/>
</div>
</td>
<td className="cal-dist__pct-cell">
<span className="cal-dist__pct tnum">{prob.toFixed(1)}%</span>
</td>
</tr>
);
})}
</tbody>
<style>{styles}</style>
</table>
);
}
export function CalibrationStateBanner({ calibration }: { calibration: CalibratedPrediction }) {
if (calibration.decision_state === 'confident') {
return null;
}
const className =
calibration.decision_state === 'ood_warning'
? 'cal-banner cal-banner--ood'
: 'cal-banner cal-banner--ambiguous';
return (
<p className={className}>
{calibration.decision_state === 'ood_warning'
? OOD_CLINICIAN_BANNER_VI
: 'Tập mơ hồ — cần đối chiếu thủ công'}
<style>{bannerStyles}</style>
</p>
);
}
const styles = `
.cal-dist {
width: 100%;
margin: 8px 0 0;
border-collapse: collapse;
border: none;
table-layout: fixed;
}
.cal-dist__caption {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.cal-dist td {
border: none;
padding: 0;
vertical-align: top;
}
.cal-dist tr + tr td {
padding-top: 10px;
}
.cal-dist__label-cell {
width: auto;
padding-right: 12px;
}
.cal-dist__pct-cell {
width: 3.75rem;
text-align: right;
white-space: nowrap;
}
.cal-dist__label {
display: inline;
font-size: 1em;
line-height: 1.4;
}
.cal-dist__row--active .cal-dist__label {
color: var(--color-secondary);
font-weight: 600;
}
.cal-dist__tag {
display: inline-block;
margin-left: 6px;
font-size: 9px;
padding: 1px 6px;
border-radius: 999px;
background: rgba(232, 168, 56, 0.2);
color: #9a6b12;
text-transform: uppercase;
letter-spacing: 0.04em;
vertical-align: middle;
}
.cal-dist__bar-track {
margin-top: 6px;
height: 6px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.cal-dist__bar-fill {
height: 100%;
border-radius: 999px;
background: var(--color-secondary);
opacity: 0.85;
}
.cal-dist__pct {
font-size: 0.9375em;
font-weight: 500;
color: var(--color-on-surface-variant);
line-height: 1.4;
}
.cal-dist__row--active .cal-dist__pct {
color: var(--color-secondary);
font-weight: 600;
}
`;
const bannerStyles = `
.cal-banner {
margin: 0;
padding: 8px 10px;
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.45;
font-weight: 500;
}
.cal-banner--ambiguous {
background: rgba(232, 168, 56, 0.12);
color: #8a6110;
border-left: 3px solid #e8a838;
}
.cal-banner--ood {
background: rgba(186, 26, 26, 0.08);
color: #8f1414;
border-left: 3px solid var(--color-error);
}
`;

View File

@@ -0,0 +1,186 @@
import {
CALIBRATION_TIERS,
recommendedTemperatureForTier,
resolveTierFromTemperature,
TEMPERATURE_SLIDER,
type CalibrationTierId,
} from '../../data/calibrationTiers';
import type { CalibrationUserConfig } from '../../lib/calibrationEngine';
import CalibrationMetricHelp from './CalibrationMetricHelp';
interface CalibrationControlsProps {
config: CalibrationUserConfig;
onChange: (next: CalibrationUserConfig) => void;
}
export default function CalibrationControls({ config, onChange }: CalibrationControlsProps) {
const activeTierId = resolveTierFromTemperature(config.temperature);
const setTemperature = (temperature: number) => {
onChange({ ...config, temperature });
};
const snapToTier = (tierId: CalibrationTierId) => {
setTemperature(recommendedTemperatureForTier(tierId));
};
return (
<div className="cal-ctrl">
<div className="cal-ctrl__header">
<span className="cal-ctrl__title">Điều chỉnh nhiệt đ (T)</span>
<span className="cal-ctrl__hint tnum">T = {config.temperature.toFixed(2)}</span>
</div>
<CalibrationMetricHelp layout="block" />
<div className="cal-ctrl__tiers" role="group" aria-label="Bậc hiệu chuẩn lâm sàng">
{CALIBRATION_TIERS.map((tier) => {
const active = tier.id === activeTierId;
return (
<button
key={tier.id}
type="button"
className={active ? 'cal-tier cal-tier--active' : 'cal-tier'}
onClick={() => snapToTier(tier.id)}
aria-pressed={active}
>
<span className="cal-tier__badge">Bậc {tier.tier}</span>
<strong className="cal-tier__name">{tier.labelVi}</strong>
<span className="cal-tier__rule tnum">{tier.ruleVi}</span>
<span className="cal-tier__trigger">{tier.triggerVi}</span>
<span className="cal-tier__effect">{tier.uiEffectVi}</span>
</button>
);
})}
</div>
<label className="cal-ctrl__slider-label">
<span>Kéo T liên tục</span>
<span className="tnum">{config.temperature.toFixed(2)}</span>
</label>
<input
type="range"
min={Math.round(TEMPERATURE_SLIDER.min * 100)}
max={Math.round(TEMPERATURE_SLIDER.max * 100)}
step={Math.round(TEMPERATURE_SLIDER.step * 100)}
value={Math.round(config.temperature * 100)}
className="cal-ctrl__slider"
aria-valuemin={TEMPERATURE_SLIDER.min}
aria-valuemax={TEMPERATURE_SLIDER.max}
aria-valuenow={config.temperature}
onChange={(e) => setTemperature(Number(e.target.value) / 100)}
/>
<div className="cal-ctrl__scale tnum" aria-hidden>
<span>0.5</span>
<span>1.05</span>
<span>1.8</span>
<span>2.5</span>
</div>
<style>{styles}</style>
</div>
);
}
const styles = `
.cal-ctrl {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.06);
}
.cal-ctrl__header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px;
align-items: baseline;
}
.cal-ctrl__title {
font-size: 0.6875em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-on-surface-variant);
}
.cal-ctrl__hint {
font-size: 0.6875em;
color: var(--color-on-surface-variant);
opacity: 0.85;
}
.cal-ctrl__tiers {
display: flex;
flex-direction: column;
gap: 8px;
}
.cal-tier {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid rgba(0, 0, 0, 0.08);
background: var(--color-surface, #fff);
text-align: left;
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
opacity: 0.72;
}
.cal-tier--active {
opacity: 1;
border-color: var(--color-secondary);
background: rgba(35, 104, 108, 0.1);
box-shadow: 0 0 0 1px rgba(35, 104, 108, 0.15);
}
.cal-tier__badge {
font-size: 0.625em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-on-surface-variant);
}
.cal-tier--active .cal-tier__badge {
color: var(--color-secondary);
}
.cal-tier__name {
font-size: 0.8125em;
font-family: var(--font-headline);
line-height: 1.3;
color: var(--color-on-surface);
}
.cal-tier--active .cal-tier__name {
color: var(--color-secondary);
}
.cal-tier__rule {
font-size: 0.6875em;
font-weight: 600;
color: var(--color-on-surface-variant);
}
.cal-tier__trigger,
.cal-tier__effect {
font-size: 0.6875em;
line-height: 1.4;
color: var(--color-on-surface-variant);
}
.cal-ctrl__slider-label {
display: flex;
justify-content: space-between;
font-size: 0.75em;
color: var(--color-on-surface-variant);
margin-top: 2px;
}
.cal-ctrl__slider {
width: 100%;
accent-color: var(--color-secondary);
}
.cal-ctrl__scale {
display: flex;
justify-content: space-between;
font-size: 0.625em;
color: var(--color-on-surface-variant);
opacity: 0.8;
margin-top: -4px;
}
`;

View File

@@ -0,0 +1,319 @@
import type { ReactNode } from 'react';
import {
CALIBRATION_METRIC_HELP_FOUNDATION,
CALIBRATION_METRIC_HELP_KEY_POINTS,
CALIBRATION_METRIC_HELP_KEY_POINTS_TEASER,
CALIBRATION_METRIC_HELP_KEY_POINTS_TITLE,
CALIBRATION_METRIC_HELP_NARRATIVE,
CALIBRATION_METRIC_HELP_NARRATIVE_TEASER,
CALIBRATION_METRIC_HELP_NARRATIVE_TITLE,
CALIBRATION_METRIC_HELP_READING,
CALIBRATION_METRIC_HELP_READING_TITLE,
CALIBRATION_METRIC_HELP_REFERENCES_TEASER,
CALIBRATION_METRIC_HELP_REFERENCES_TITLE,
CALIBRATION_METRIC_HELP_TRIGGER,
} from '../../data/calibrationMetricHelp';
interface CalibrationMetricHelpProps {
/** Full-width in-flow panel — avoids clipping in narrow side column */
layout?: 'block' | 'inline';
}
interface HelpSectionProps {
title: string;
teaser: string;
defaultOpen?: boolean;
children: ReactNode;
}
function HelpSection({ title, teaser, defaultOpen, children }: HelpSectionProps) {
return (
<details className="cal-help-section" open={defaultOpen}>
<summary className="cal-help-section__toggle">
<span className="cal-help-section__chevron" aria-hidden />
<span className="cal-help-section__labels">
<span className="cal-help-section__title">{title}</span>
<span className="cal-help-section__teaser">{teaser}</span>
</span>
</summary>
<div className="cal-help-section__body">{children}</div>
</details>
);
}
export default function CalibrationMetricHelp({ layout = 'block' }: CalibrationMetricHelpProps) {
const isBlock = layout === 'block';
return (
<div className={`cal-metric-help ${isBlock ? 'cal-metric-help--block' : 'cal-metric-help--inline'}`}>
<details className="cal-metric-help__details">
<summary className="cal-metric-help__trigger">
<span className="cal-metric-help__icon" aria-hidden>
?
</span>
<span className="cal-metric-help__trigger-text">{CALIBRATION_METRIC_HELP_TRIGGER}</span>
</summary>
<div className="cal-metric-help__panel" role="note">
<p className="cal-metric-help__intro">
Mở từng mục bạn quan tâm không cần đc hết một lúc.
</p>
<div className="cal-metric-help__sections">
<HelpSection
title={CALIBRATION_METRIC_HELP_NARRATIVE_TITLE}
teaser={CALIBRATION_METRIC_HELP_NARRATIVE_TEASER}
>
{CALIBRATION_METRIC_HELP_NARRATIVE.map((paragraph) => (
<p key={paragraph.slice(0, 32)} className="cal-metric-help__prose">
{paragraph}
</p>
))}
</HelpSection>
<HelpSection
title={CALIBRATION_METRIC_HELP_KEY_POINTS_TITLE}
teaser={CALIBRATION_METRIC_HELP_KEY_POINTS_TEASER}
defaultOpen
>
<ol className="cal-metric-help__key-points">
{CALIBRATION_METRIC_HELP_KEY_POINTS.map((point) => (
<li key={point.title} className="cal-metric-help__key-point">
<strong>{point.title}.</strong> {point.body}
{point.subPoints && (
<ul className="cal-metric-help__sub-points">
{point.subPoints.map((sub) => (
<li key={sub}>{sub}</li>
))}
</ul>
)}
</li>
))}
</ol>
</HelpSection>
<HelpSection
title={CALIBRATION_METRIC_HELP_REFERENCES_TITLE}
teaser={CALIBRATION_METRIC_HELP_REFERENCES_TEASER}
>
<p className="cal-metric-help__foundation">{CALIBRATION_METRIC_HELP_FOUNDATION}</p>
<p className="cal-metric-help__refs-title">{CALIBRATION_METRIC_HELP_READING_TITLE}</p>
<ul className="cal-metric-help__refs">
{CALIBRATION_METRIC_HELP_READING.map((ref) => (
<li key={ref.href}>
<a href={ref.href} target="_blank" rel="noopener noreferrer">
{ref.label}
</a>
</li>
))}
</ul>
</HelpSection>
</div>
</div>
</details>
<style>{styles}</style>
</div>
);
}
const sectionStyles = `
.cal-help-section {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.65);
overflow: hidden;
}
.cal-help-section__toggle {
list-style: none;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
user-select: none;
}
.cal-help-section__toggle::-webkit-details-marker {
display: none;
}
.cal-help-section__toggle:hover {
background: rgba(35, 104, 108, 0.04);
}
.cal-help-section[open] .cal-help-section__toggle {
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(35, 104, 108, 0.06);
}
.cal-help-section__chevron {
flex-shrink: 0;
width: 9px;
height: 9px;
margin-top: 6px;
border-right: 2px solid var(--color-secondary);
border-bottom: 2px solid var(--color-secondary);
transform: rotate(-45deg);
transition: transform 0.15s ease;
}
.cal-help-section[open] .cal-help-section__chevron {
transform: rotate(45deg);
margin-top: 4px;
}
.cal-help-section__labels {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.cal-help-section__title {
font-size: 14px;
font-weight: 600;
font-family: var(--font-headline);
color: var(--color-on-surface);
line-height: 1.35;
}
.cal-help-section__teaser {
font-size: 13px;
color: var(--color-on-surface-variant);
line-height: 1.45;
}
.cal-help-section__body {
padding: 12px 14px 14px;
font-size: 13px;
line-height: 1.55;
}
`;
const styles = `
${sectionStyles}
.cal-metric-help {
text-transform: none;
letter-spacing: normal;
font-weight: normal;
/* rem base — avoids double-shrink when nested in small em containers */
font-size: 13px;
}
.cal-metric-help--block {
display: block;
width: 100%;
}
.cal-metric-help--inline {
display: inline-block;
max-width: 100%;
}
.cal-metric-help__details {
display: block;
width: 100%;
}
.cal-metric-help__trigger {
list-style: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 0;
color: var(--color-secondary);
font-size: 13px;
font-weight: 500;
user-select: none;
}
.cal-metric-help__trigger::-webkit-details-marker {
display: none;
}
.cal-metric-help__icon {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 999px;
border: 1px solid rgba(35, 104, 108, 0.35);
background: rgba(35, 104, 108, 0.08);
color: var(--color-secondary);
font-size: 12px;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.cal-metric-help__trigger-text {
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: rgba(35, 104, 108, 0.35);
}
.cal-metric-help__panel {
margin-top: 10px;
width: 100%;
box-sizing: border-box;
padding: 14px;
border-radius: var(--radius-sm);
background: rgba(35, 104, 108, 0.05);
border: 1px solid rgba(35, 104, 108, 0.12);
border-left: 3px solid var(--color-secondary);
font-size: 13px;
line-height: 1.55;
color: var(--color-on-surface-variant);
text-transform: none;
letter-spacing: normal;
}
.cal-metric-help__intro {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.45;
color: var(--color-on-surface-variant);
}
.cal-metric-help__sections {
display: flex;
flex-direction: column;
gap: 10px;
}
.cal-metric-help__prose {
margin: 0 0 10px;
}
.cal-metric-help__prose:last-child {
margin-bottom: 0;
}
.cal-metric-help__key-points {
margin: 0;
padding-left: 1.2rem;
}
.cal-metric-help__key-point {
margin-bottom: 10px;
}
.cal-metric-help__key-point:last-child {
margin-bottom: 0;
}
.cal-metric-help__key-point strong {
color: var(--color-on-surface);
font-weight: 600;
}
.cal-metric-help__sub-points {
margin: 6px 0 0;
padding-left: 1rem;
list-style: disc;
}
.cal-metric-help__sub-points li {
margin-bottom: 4px;
}
.cal-metric-help__foundation {
margin: 0 0 10px;
font-style: italic;
font-size: 13px;
line-height: 1.5;
}
.cal-metric-help__refs-title {
margin: 0 0 6px;
font-size: 13px;
font-weight: 600;
color: var(--color-on-surface);
}
.cal-metric-help__refs {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.cal-metric-help__refs a {
color: var(--color-secondary);
font-size: 13px;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-word;
}
`;

View File

@@ -0,0 +1,82 @@
import { memo } from 'react';
import type { ClinicalChatMessage } from '../../types/clinicalChat';
import ChatMarkdown from '../atoms/ChatMarkdown';
import ClinicalChatPonderingText from '../atoms/ClinicalChatPonderingText';
import StreamingPlainText from '../atoms/StreamingPlainText';
import ClinicalChatThought from './ClinicalChatThought';
import { streamTargetKey } from '../../lib/llm/clinicalChatStreamRegistry';
function formatTime(date: Date): string {
return date.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit' });
}
interface ClinicalChatMessageBubbleProps {
message: ClinicalChatMessage;
}
function ClinicalChatMessageBubble({ message }: ClinicalChatMessageBubbleProps) {
return (
<div className={`clinical-chat__bubble clinical-chat__bubble--${message.role}`}>
<div className="clinical-chat__bubble-meta">
<span className="clinical-chat__role">
{message.role === 'user' ? 'Bạn' : 'AI lâm sàng'}
</span>
<time className="clinical-chat__time tnum" dateTime={message.timestamp.toISOString()}>
{formatTime(message.timestamp)}
</time>
</div>
{message.pondering ? (
<ClinicalChatPonderingText variant={message.ponderingVariant ?? 'chat'} />
) : message.streaming &&
!message.content &&
!message.tracksThought &&
!message.thoughtContent ? (
<span className="clinical-chat__typing" aria-label="AI đang trả lời">
<span />
<span />
<span />
</span>
) : (
<>
{message.tracksThought || message.thoughtContent ? (
<ClinicalChatThought
messageId={message.id}
content={message.thoughtContent ?? ''}
thoughtStreaming={Boolean(message.streaming && !message.thoughtComplete)}
answerStreaming={message.streaming}
/>
) : null}
{message.content || (message.tracksThought && message.streaming) ? (
message.tracksThought ? (
<StreamingPlainText
text={message.content}
streamTargetKey={streamTargetKey(message.id, 'answer')}
className="clinical-chat__answer-md chat-md__plain"
/>
) : (
<ChatMarkdown
text={message.content}
streaming={message.streaming}
className="clinical-chat__answer-md"
/>
)
) : null}
</>
)}
</div>
);
}
export default memo(
ClinicalChatMessageBubble,
(prev, next) =>
prev.message.id === next.message.id &&
prev.message.content === next.message.content &&
prev.message.thoughtContent === next.message.thoughtContent &&
prev.message.tracksThought === next.message.tracksThought &&
prev.message.thoughtComplete === next.message.thoughtComplete &&
prev.message.pondering === next.message.pondering &&
prev.message.ponderingVariant === next.message.ponderingVariant &&
prev.message.streaming === next.message.streaming &&
prev.message.role === next.message.role,
);

View File

@@ -0,0 +1,98 @@
import { memo, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import StreamingPlainText from '../atoms/StreamingPlainText';
import { streamTargetKey } from '../../lib/llm/clinicalChatStreamRegistry';
interface ClinicalChatThoughtProps {
messageId: string;
content: string;
/** True while the thought channel is still being generated. */
thoughtStreaming?: boolean;
/** True while the answer channel is still streaming (defers heavy markdown parse). */
answerStreaming?: boolean;
}
/** Reasoning panel — plain text only (markdown disabled while isolating stream crashes). */
function ClinicalChatThought({
messageId,
content,
thoughtStreaming = false,
}: ClinicalChatThoughtProps) {
const panelId = useId();
const wasThoughtStreamingRef = useRef(thoughtStreaming);
const userToggledRef = useRef(false);
const [expanded, setExpanded] = useState(true);
useEffect(() => {
setExpanded(true);
userToggledRef.current = false;
wasThoughtStreamingRef.current = false;
}, [messageId]);
useLayoutEffect(() => {
if (thoughtStreaming) {
if (!userToggledRef.current) {
setExpanded(true);
}
} else if (wasThoughtStreamingRef.current && !thoughtStreaming && !userToggledRef.current) {
setExpanded(false);
}
wasThoughtStreamingRef.current = thoughtStreaming;
}, [thoughtStreaming]);
if (!content.trim() && !thoughtStreaming) {
return null;
}
const toggle = () => {
userToggledRef.current = true;
setExpanded((open) => !open);
};
const label = thoughtStreaming ? 'Đang suy luận' : 'Suy luận';
const preview = !thoughtStreaming && !expanded ? content.replace(/\s+/g, ' ').trim().slice(0, 100) : '';
return (
<div
className={`clinical-chat__thought ${thoughtStreaming ? 'clinical-chat__thought--streaming' : ''} ${expanded ? 'clinical-chat__thought--open' : 'clinical-chat__thought--closed'}`}
>
<button
type="button"
className="clinical-chat__thought-toggle"
onClick={toggle}
aria-expanded={expanded}
aria-controls={panelId}
>
<span className="clinical-chat__thought-chevron" aria-hidden>
{expanded ? '▾' : '▸'}
</span>
<span className="clinical-chat__thought-label">🤔 {label}</span>
{thoughtStreaming ? (
<span className="clinical-chat__thought-live" aria-label="Đang cập nhật">
<span />
<span />
<span />
</span>
) : null}
</button>
{!expanded && preview ? (
<p className="clinical-chat__thought-preview">{preview}</p>
) : null}
{expanded ? (
<div id={panelId} className="clinical-chat__thought-body">
<StreamingPlainText
text={content}
streamTargetKey={streamTargetKey(messageId, 'thought')}
className="clinical-chat__thought-md chat-md__plain"
/>
</div>
) : null}
</div>
);
}
export default memo(ClinicalChatThought, (prev, next) =>
prev.messageId === next.messageId &&
prev.content === next.content &&
prev.thoughtStreaming === next.thoughtStreaming &&
prev.answerStreaming === next.answerStreaming,
);

View File

@@ -0,0 +1,132 @@
import { useEffect, useRef } from 'react';
import type { AnnotationPoint } from '../../types/canvasAnnotation';
interface ClosedLoopPromptProps {
anchor: AnnotationPoint;
onConfirm: () => void;
onDismiss: () => void;
}
export default function ClosedLoopPrompt({ anchor, onConfirm, onDismiss }: ClosedLoopPromptProps) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
onDismiss();
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onDismiss();
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [onDismiss]);
return (
<div
ref={rootRef}
className="closed-loop-prompt glass"
style={{
left: anchor.x,
top: anchor.y - 20,
}}
role="dialog"
aria-live="polite"
aria-label="Xác nhận vùng đánh dấu"
onPointerDown={(event) => event.stopPropagation()}
>
<p className="closed-loop-prompt__label">
Bạn muốn tạo vùng đánh dấu trên khu vực này không?
</p>
<div className="closed-loop-prompt__actions">
<button
type="button"
className="closed-loop-prompt__btn closed-loop-prompt__btn--confirm"
onClick={onConfirm}
aria-label="Xác nhận tạo vùng đánh dấu"
title="Xác nhận"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
<button
type="button"
className="closed-loop-prompt__btn closed-loop-prompt__btn--dismiss"
onClick={onDismiss}
aria-label="Hủy"
title="Hủy"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<style>{styles}</style>
</div>
);
}
const styles = `
.closed-loop-prompt {
position: absolute;
z-index: 1000;
transform: translate(-50%, -100%);
max-width: min(280px, calc(100% - 24px));
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(173, 238, 242, 0.4);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
background: rgba(23, 29, 28, 0.95);
backdrop-filter: blur(12px);
pointer-events: auto;
}
.closed-loop-prompt__label {
margin: 0 0 10px;
font-size: 13px;
line-height: 1.45;
font-weight: 600;
color: #fff;
}
.closed-loop-prompt__actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.closed-loop-prompt__btn {
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s, transform 0.12s;
}
.closed-loop-prompt__btn:hover {
transform: scale(1.06);
}
.closed-loop-prompt__btn--confirm {
background: rgba(35, 104, 108, 0.9);
color: #fff;
}
.closed-loop-prompt__btn--confirm:hover {
background: rgba(35, 104, 108, 1);
}
.closed-loop-prompt__btn--dismiss {
background: rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.9);
}
.closed-loop-prompt__btn--dismiss:hover {
background: rgba(255, 255, 255, 0.2);
}
`;

View File

@@ -0,0 +1,380 @@
import type { DiagnosticFormDraft, DiseaseProgression, NextProcedure, PowerDopplerScore } from '../../data/diagnosticForm';
import {
DISEASE_PROGRESSION_OPTIONS,
NEXT_PROCEDURE_OPTIONS,
POWER_DOPPLER_SCORES,
} from '../../data/diagnosticForm';
interface DiagnosticFormStepFieldsProps {
step: number;
draft: DiagnosticFormDraft;
jointLabel: string;
onDraftChange: (draft: DiagnosticFormDraft) => void;
readOnlySnapshot?: boolean;
}
export default function DiagnosticFormStepFields({
step,
draft,
jointLabel,
onDraftChange,
readOnlySnapshot = false,
}: DiagnosticFormStepFieldsProps) {
const setProgression = (value: DiseaseProgression) => {
onDraftChange({
...draft,
diseaseProgression: draft.diseaseProgression === value ? null : value,
});
};
const setDoppler = (value: PowerDopplerScore) => {
onDraftChange({
...draft,
powerDopplerScore: draft.powerDopplerScore === value ? null : value,
});
};
const setProcedure = (value: NextProcedure) => {
onDraftChange({
...draft,
nextProcedure: draft.nextProcedure === value ? null : value,
});
};
if (step === 0) {
return (
<div className="diag-form-fields__pill-group" role="group" aria-label="Tiến triển bệnh">
{DISEASE_PROGRESSION_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={
draft.diseaseProgression === option.value
? 'diag-form-fields__pill diag-form-fields__pill--active'
: 'diag-form-fields__pill'
}
onClick={() => setProgression(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
if (step === 1) {
return (
<div className="diag-form-fields__measure-block">
<p className="diag-form-fields__measure-label">Thang điểm Power Doppler</p>
<div className="diag-form-fields__score-group" role="group" aria-label="Power Doppler">
{POWER_DOPPLER_SCORES.map((score) => (
<button
key={score}
type="button"
className={
draft.powerDopplerScore === score
? 'diag-form-fields__score diag-form-fields__score--active'
: 'diag-form-fields__score'
}
onClick={() => setDoppler(score)}
>
{score}
</button>
))}
</div>
<label className="diag-form-fields__field">
<span>
Đ dày màng hoạt dịch {jointLabel}{' '}
<span className="diag-form-fields__field-note">(không hoặc không biết: 1)</span>
</span>
<input
type="text"
inputMode="decimal"
className="tnum"
value={draft.synovialThicknessUnknown ? '1' : draft.synovialThicknessMm}
disabled={draft.synovialThicknessUnknown}
onChange={(event) => onDraftChange({ ...draft, synovialThicknessMm: event.target.value })}
placeholder="mm"
/>
</label>
<label className="diag-form-fields__checkbox">
<input
type="checkbox"
checked={draft.synovialThicknessUnknown}
onChange={(event) =>
onDraftChange({
...draft,
synovialThicknessUnknown: event.target.checked,
synovialThicknessMm: event.target.checked ? '' : draft.synovialThicknessMm,
})
}
/>
<span>Không hoặc không đo đưc</span>
</label>
</div>
);
}
if (step === 2) {
return (
<div className="diag-form-fields__anatomy-block">
<label className="diag-form-fields__field diag-form-fields__subfield">
<span>Khớp gối phải</span>
<textarea
className="diag-form-fields__textarea--scroll diag-form-fields__textarea--sub"
value={draft.anatomyDescriptionRight}
onChange={(event) =>
onDraftChange({ ...draft, anatomyDescriptionRight: event.target.value })
}
placeholder="Nhận định lâm sàng và ghi chú về giải phẫu khớp gối phải…"
/>
</label>
<label className="diag-form-fields__field diag-form-fields__subfield">
<span>Khớp gối trái</span>
<textarea
className="diag-form-fields__textarea--scroll diag-form-fields__textarea--sub"
value={draft.anatomyDescriptionLeft}
onChange={(event) =>
onDraftChange({ ...draft, anatomyDescriptionLeft: event.target.value })
}
placeholder="Nhận định lâm sàng và ghi chú về giải phẫu khớp gối trái…"
/>
</label>
</div>
);
}
if (step === 3) {
return (
<div className="diag-form-fields__snapshot">
{draft.diagnosisWorkImageDataUrl ? (
<img
src={draft.diagnosisWorkImageDataUrl}
alt="Ảnh siêu âm đã chú thích"
className="diag-form-fields__snapshot-img"
/>
) : (
<div className="diag-form-fields__snapshot-empty">Chưa nh chẩn đoán đã chú thích.</div>
)}
{readOnlySnapshot && draft.diagnosisWorkImageDataUrl && (
<p className="diag-form-fields__snapshot-note">nh lưu từ khung chẩn đoán tại thời điểm lập phiếu.</p>
)}
</div>
);
}
if (step === 4) {
return (
<div className="diag-form-fields__procedure-block">
<p className="diag-form-fields__measure-label">Chỉ đnh thủ thuật tiếp theo</p>
<div className="diag-form-fields__procedure-list" role="radiogroup" aria-label="Chỉ định thủ thuật">
{NEXT_PROCEDURE_OPTIONS.map((option) => (
<label
key={option.value}
className={
draft.nextProcedure === option.value
? 'diag-form-fields__procedure-option diag-form-fields__procedure-option--active'
: 'diag-form-fields__procedure-option'
}
>
<input
type="radio"
name="signOffProcedure"
value={option.value}
checked={draft.nextProcedure === option.value}
onChange={() => setProcedure(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
<label className="diag-form-fields__field">
<span>Tại sao cần thực hiện chỉ đnh</span>
<textarea
className="diag-form-fields__textarea--scroll diag-form-fields__textarea--compact"
value={draft.procedureReason}
onChange={(event) => onDraftChange({ ...draft, procedureReason: event.target.value })}
placeholder="Giải thích lý do lâm sàng…"
/>
</label>
<label className="diag-form-fields__field">
<span>Người phụ trách thực hiện</span>
<input
type="text"
value={draft.procedureResponsibleName}
onChange={(event) =>
onDraftChange({ ...draft, procedureResponsibleName: event.target.value })
}
placeholder="Họ tên bác sĩ / kỹ thuật viên phụ trách"
/>
</label>
</div>
);
}
return (
<label className="diag-form-fields__field">
<span className="sr-only">Kết luận bác </span>
<textarea
className="diag-form-fields__textarea--scroll"
value={draft.doctorConclusion}
onChange={(event) => onDraftChange({ ...draft, doctorConclusion: event.target.value })}
placeholder="Nhập kết luận / chẩn đoán / yêu cầu cần can thiệp đối với bệnh nhân."
/>
</label>
);
}
export const diagnosticFormFieldStyles = `
.diag-form-fields__pill-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.diag-form-fields__pill {
width: 100%;
padding: 14px 16px;
border-radius: 999px;
border: 1px solid var(--color-outline-variant);
background: rgba(255, 255, 255, 0.9);
color: var(--color-on-surface);
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
}
.diag-form-fields__pill--active {
background: rgba(35, 104, 108, 0.12);
border-color: var(--color-secondary);
color: var(--color-secondary);
}
.diag-form-fields__measure-block,
.diag-form-fields__procedure-block,
.diag-form-fields__anatomy-block {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
}
.diag-form-fields__measure-label {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-on-surface);
}
.diag-form-fields__score-group {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.diag-form-fields__score {
aspect-ratio: 1;
border-radius: var(--radius-md);
border: 1px solid var(--color-outline-variant);
background: #fff;
font-size: 1.125rem;
font-weight: 700;
cursor: pointer;
}
.diag-form-fields__score--active {
background: var(--color-secondary);
border-color: var(--color-secondary);
color: #fff;
}
.diag-form-fields__field {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-on-surface);
}
.diag-form-fields__subfield span {
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--color-secondary);
}
.diag-form-fields__field-note {
font-weight: 400;
color: var(--color-on-surface-variant);
}
.diag-form-fields__field input,
.diag-form-fields__field textarea {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-outline-variant);
font: inherit;
font-weight: 400;
background: #fff;
}
.diag-form-fields__textarea--scroll {
resize: none;
height: 140px;
max-height: 140px;
overflow-y: auto;
}
.diag-form-fields__textarea--sub {
height: 100px;
max-height: 100px;
}
.diag-form-fields__textarea--compact {
height: 88px;
max-height: 88px;
}
.diag-form-fields__checkbox {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 0.8125rem;
font-weight: 400;
color: var(--color-on-surface-variant);
cursor: pointer;
}
.diag-form-fields__procedure-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.diag-form-fields__procedure-option {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-outline-variant);
background: #fff;
font-size: 0.8125rem;
cursor: pointer;
line-height: 1.35;
}
.diag-form-fields__procedure-option--active {
border-color: var(--color-secondary);
background: rgba(35, 104, 108, 0.08);
color: var(--color-secondary);
}
.diag-form-fields__snapshot-img {
width: 100%;
max-height: 180px;
object-fit: contain;
border-radius: var(--radius-md);
border: 1px solid var(--color-outline-variant);
background: #0a0f0e;
}
.diag-form-fields__snapshot-empty {
padding: 24px 12px;
text-align: center;
font-size: 0.8125rem;
color: var(--color-on-surface-variant);
border: 1px dashed var(--color-outline-variant);
border-radius: var(--radius-md);
}
.diag-form-fields__snapshot-note {
margin: 0;
font-size: 0.75rem;
color: var(--color-on-surface-variant);
font-style: italic;
}
`;

View File

@@ -0,0 +1,207 @@
import { useState } from 'react';
import { copyTextToClipboard } from '../../lib/copyToClipboard';
import {
buildMlErrorTicketText,
type MlServiceError,
} from '../../lib/mlServiceError';
interface MlServiceErrorPanelProps {
error: MlServiceError;
frameLabel?: string;
onRetry?: () => void;
}
export default function MlServiceErrorPanel({ error, frameLabel, onRetry }: MlServiceErrorPanelProps) {
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const showSupportBlock = error.code !== 'NO_RESULT';
async function copyTicketText() {
const text = buildMlErrorTicketText(error, frameLabel);
const ok = await copyTextToClipboard(text);
if (ok) {
setCopyFailed(false);
setCopied(true);
window.setTimeout(() => setCopied(false), 2500);
return;
}
setCopyFailed(true);
window.setTimeout(() => setCopyFailed(false), 4000);
}
return (
<div className="ml-svc-error" role="alert" aria-live="polite">
<p className="ml-svc-error__title">{error.title}</p>
<p className="ml-svc-error__message">{error.message}</p>
{showSupportBlock && (
<div className="ml-svc-error__support">
<p className="ml-svc-error__ref-label"> tham chiếu hỗ trợ</p>
<code className="ml-svc-error__ref" tabIndex={0}>
{error.supportReference}
</code>
<p className="ml-svc-error__ref-hint">
Cung cấp này khi gửi ticket cho IT giúp đi kỹ thuật tra cứu nhanh.
</p>
{copyFailed && (
<p className="ml-svc-error__copy-fail">
Không sao chép tự đng đưc hãy chọn sao chép trên thủ công.
</p>
)}
</div>
)}
{error.ticketHints.length > 0 && (
<ul className="ml-svc-error__hints">
{error.ticketHints.map((hint) => (
<li key={hint}>{hint}</li>
))}
</ul>
)}
<div className="ml-svc-error__actions">
{onRetry && (
<button type="button" className="ml-svc-error__btn" onClick={onRetry}>
Thử tải lại
</button>
)}
{showSupportBlock && (
<button type="button" className="ml-svc-error__btn ml-svc-error__btn--secondary" onClick={copyTicketText}>
{copied ? 'Đã sao chép' : 'Sao chép cho ticket'}
</button>
)}
</div>
{showSupportBlock && (
<details className="ml-svc-error__details">
<summary>Chi tiết kỹ thuật (cho IT)</summary>
<pre className="ml-svc-error__technical">{error.technicalDetail}</pre>
</details>
)}
<style>{`
.ml-svc-error {
position: absolute;
inset: 0;
z-index: 14;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 24px 20px;
background: rgba(10, 15, 14, 0.78);
color: #f4f7f6;
text-align: center;
pointer-events: auto;
overflow-y: auto;
}
.ml-svc-error__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
line-height: 1.35;
max-width: 22rem;
}
.ml-svc-error__message {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
max-width: 26rem;
opacity: 0.92;
}
.ml-svc-error__support {
margin-top: 4px;
padding: 10px 14px;
border-radius: var(--radius-md, 8px);
background: rgba(255, 255, 255, 0.08);
max-width: 24rem;
}
.ml-svc-error__ref-label {
margin: 0 0 4px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.75;
}
.ml-svc-error__ref {
display: block;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: 0.06em;
user-select: all;
cursor: text;
}
.ml-svc-error__ref-hint {
margin: 6px 0 0;
font-size: 0.72rem;
line-height: 1.4;
opacity: 0.8;
}
.ml-svc-error__copy-fail {
margin: 8px 0 0;
font-size: 0.72rem;
line-height: 1.4;
color: #fcd34d;
}
.ml-svc-error__hints {
margin: 0;
padding-left: 1.1rem;
text-align: left;
font-size: 0.78rem;
line-height: 1.45;
max-width: 24rem;
opacity: 0.88;
}
.ml-svc-error__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
margin-top: 4px;
}
.ml-svc-error__btn {
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.14);
color: inherit;
border-radius: var(--radius-md, 8px);
padding: 7px 14px;
font-size: 0.8rem;
cursor: pointer;
}
.ml-svc-error__btn:hover {
background: rgba(255, 255, 255, 0.22);
}
.ml-svc-error__btn--secondary {
background: transparent;
}
.ml-svc-error__details {
margin-top: 6px;
max-width: 26rem;
width: 100%;
text-align: left;
font-size: 0.75rem;
}
.ml-svc-error__details summary {
cursor: pointer;
opacity: 0.8;
list-style: none;
}
.ml-svc-error__details summary::-webkit-details-marker {
display: none;
}
.ml-svc-error__technical {
margin: 8px 0 0;
padding: 10px;
border-radius: var(--radius-md, 8px);
background: rgba(0, 0, 0, 0.35);
font-size: 0.68rem;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,173 @@
import type { PatientCase } from '../../data/mockData';
interface PatientCardProps {
patient: PatientCase;
/** Cached inference grade (03). Omit mock placeholders — null = not analyzed yet. */
cachedAiGrade?: number | null;
aiGradeLoading?: boolean;
onOpen: (id: string) => void;
onEdit: (patient: PatientCase) => void;
}
const STATUS_CHIP: Record<PatientCase['status'], string> = {
urgent: 'chip-urgent',
processing: 'chip-processing',
followup: 'chip-followup',
ready: 'chip-success',
};
function EditIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
}
export default function PatientCard({
patient,
cachedAiGrade = null,
aiGradeLoading = false,
onOpen,
onEdit,
}: PatientCardProps) {
return (
<article className="patient-card glass">
<div className="patient-card__header">
<div className="patient-card__avatar" aria-hidden>
{patient.name.charAt(0)}
</div>
<div className="patient-card__info">
<h3>{patient.name}</h3>
<p className="tnum">{patient.mrn}</p>
</div>
<span className={`chip ${STATUS_CHIP[patient.status]}`}>{patient.statusLabel}</span>
</div>
<div className="patient-card__body">
<div className="patient-card__row">
<span>Vị trí</span>
<strong>{patient.joint}</strong>
</div>
<div className="patient-card__row">
<span>Quét lúc</span>
<strong className="tnum">{patient.lastScan}</strong>
</div>
<div className="patient-card__row">
<span>Đ xuất AI</span>
{aiGradeLoading ? (
<strong className="patient-card__ai-muted">Đang tải</strong>
) : cachedAiGrade != null ? (
<strong className="tnum">Đ {cachedAiGrade}</strong>
) : (
<strong className="patient-card__ai-muted">Chưa phân tích</strong>
)}
</div>
</div>
<div className="patient-card__actions">
<button type="button" className="btn btn-primary patient-card__cta" onClick={() => onOpen(patient.id)}>
Mở phiên chẩn đoán
</button>
<button
type="button"
className="patient-card__edit-btn"
aria-label={`Chỉnh sửa hồ sơ ${patient.name}`}
onClick={() => onEdit(patient)}
>
<EditIcon />
</button>
</div>
<style>{`
.patient-card {
border-radius: var(--radius-lg);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.patient-card__header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.patient-card__avatar {
width: 44px;
height: 44px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-primary-container), var(--color-secondary-container));
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-headline);
font-weight: 700;
color: var(--color-on-primary-container);
flex-shrink: 0;
}
.patient-card__info {
flex: 1;
min-width: 0;
}
.patient-card__info h3 {
font-size: 16px;
font-weight: 600;
}
.patient-card__info p {
margin: 2px 0 0;
font-size: 13px;
color: var(--color-on-surface-variant);
}
.patient-card__body {
display: flex;
flex-direction: column;
gap: 8px;
}
.patient-card__row {
display: flex;
justify-content: space-between;
font-size: 14px;
}
.patient-card__row span {
color: var(--color-on-surface-variant);
}
.patient-card__ai-muted {
font-weight: 500;
font-size: 13px;
color: var(--color-on-surface-variant);
font-style: italic;
}
.patient-card__actions {
display: flex;
gap: 8px;
align-items: stretch;
}
.patient-card__cta {
flex: 1 1 78%;
min-width: 0;
}
.patient-card__edit-btn {
flex: 0 0 44px;
width: 44px;
height: auto;
min-height: 44px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: #f1f5f9;
color: var(--color-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
}
.patient-card__edit-btn:hover {
background: #cbd5e1;
border-color: #cbd5e1;
}
.patient-card__edit-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
`}</style>
</article>
);
}

View File

@@ -0,0 +1,141 @@
import {
eventKindColor,
formatRecordedTime,
formatReplayClock,
type DiagnosticSessionEvent,
} from '../../data/diagnosticSessionReplay';
interface RecordedSessionActionsListProps {
events: DiagnosticSessionEvent[];
activeEventId?: string;
onSelectEvent?: (eventId: string, index: number) => void;
}
export default function RecordedSessionActionsList({
events,
activeEventId,
onSelectEvent,
}: RecordedSessionActionsListProps) {
if (events.length === 0) return null;
return (
<section className="recorded-actions" aria-label="Danh sách hành động đã ghi">
<header className="recorded-actions__header">
<h3 className="recorded-actions__title">Danh sách hành đng đã ghi</h3>
<span className="recorded-actions__count">{events.length} bước</span>
</header>
<ol className="recorded-actions__list">
{events.map((event, index) => (
<li key={event.id}>
<button
type="button"
className={`recorded-actions__item${activeEventId === event.id ? ' recorded-actions__item--active' : ''}`}
onClick={() => onSelectEvent?.(event.id, index)}
>
<span
className="recorded-actions__kind"
style={{ background: eventKindColor(event.kind) }}
aria-hidden
/>
<span className="recorded-actions__body">
<span className="recorded-actions__item-title">
{index + 1}. {event.title}
</span>
<span className="recorded-actions__item-detail">{event.detail}</span>
<span className="recorded-actions__item-meta tnum">
{formatReplayClock(event.elapsedSeconds)} · Ghi nhận lúc {formatRecordedTime(event.recordedAt)}
</span>
</span>
</button>
</li>
))}
</ol>
<style>{`
.recorded-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 220px;
}
.recorded-actions__header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.recorded-actions__title {
margin: 0;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: #76c8b1;
}
.recorded-actions__count {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
}
.recorded-actions__list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.recorded-actions__item {
width: 100%;
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
text-align: left;
cursor: pointer;
}
.recorded-actions__item:hover {
border-color: rgba(118, 200, 177, 0.35);
}
.recorded-actions__item--active {
border-color: rgba(118, 200, 177, 0.55);
background: rgba(26, 95, 96, 0.22);
}
.recorded-actions__kind {
flex-shrink: 0;
width: 4px;
align-self: stretch;
border-radius: 999px;
min-height: 36px;
}
.recorded-actions__body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.recorded-actions__item-title {
font-size: 12px;
font-weight: 700;
color: #f1f5f9;
line-height: 1.35;
}
.recorded-actions__item-detail {
font-size: 11px;
color: #cbd5e1;
line-height: 1.4;
}
.recorded-actions__item-meta {
font-size: 10px;
color: #94a3b8;
}
`}</style>
</section>
);
}

View File

@@ -0,0 +1,85 @@
import type { RecordingMode } from '../../lib/sessionRecordingTypes';
const MODE_OPTIONS: { id: RecordingMode; label: string; hint: string }[] = [
{
id: 'single_frame',
label: 'Một khung',
hint: 'Ghi trên khung hiện tại — không đổi khung khi đang ghi',
},
{
id: 'multi_frame',
label: 'Nhiều khung',
hint: 'Một phiên liên tục — mỗi lần đổi khung tạo đoạn mới trên cùng dòng thời gian',
},
];
interface RecordingModeSelectorProps {
value: RecordingMode;
onChange: (mode: RecordingMode) => void;
disabled?: boolean;
}
export default function RecordingModeSelector({ value, onChange, disabled }: RecordingModeSelectorProps) {
const activeHint = MODE_OPTIONS.find((option) => option.id === value)?.hint ?? '';
return (
<fieldset className="recording-mode-selector" disabled={disabled}>
<legend>Phạm vi ghi phiên</legend>
<div className="recording-mode-selector__options">
{MODE_OPTIONS.map((option) => (
<label key={option.id} className="recording-mode-selector__option">
<input
type="radio"
name="recording-mode"
value={option.id}
checked={value === option.id}
onChange={() => onChange(option.id)}
/>
{option.label}
</label>
))}
</div>
{activeHint && <p className="recording-mode-selector__hint">{activeHint}</p>}
<style>{`
.recording-mode-selector {
border: none;
margin: 0;
padding: 0;
}
.recording-mode-selector legend {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 6px;
}
.recording-mode-selector__options {
display: flex;
flex-direction: column;
gap: 4px;
}
.recording-mode-selector__option {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #e2e8f0;
cursor: pointer;
}
.recording-mode-selector__option input {
accent-color: #76c8b1;
}
.recording-mode-selector__hint {
margin: 6px 0 0;
font-size: 11px;
line-height: 1.45;
color: #64748b;
}
.recording-mode-selector:disabled .recording-mode-selector__option {
opacity: 0.55;
cursor: not-allowed;
}
`}</style>
</fieldset>
);
}

View File

@@ -0,0 +1,310 @@
import {
formatSavedSessionDate,
sessionDurationLabel,
type SavedSessionRecord,
} from '../../lib/savedSessionStore';
import type { EncounterGroup } from '../../lib/sessionRecordingTypes';
interface SavedSessionsDialogProps {
open: boolean;
sessions: SavedSessionRecord[];
encounterGroups: EncounterGroup[];
activeSessionId: string | null;
onClose: () => void;
onLoad: (sessionId: string) => void;
onDelete: (sessionId: string) => void;
}
function SessionRow({
session,
isActive,
onLoad,
onDelete,
nested,
}: {
session: SavedSessionRecord;
isActive: boolean;
onLoad: (id: string) => void;
onDelete: (id: string) => void;
nested?: boolean;
}) {
const eventCount = session.events.length;
const duration = sessionDurationLabel(session.events);
return (
<li
className={`saved-sessions-dialog__item${isActive ? ' saved-sessions-dialog__item--active' : ''}${nested ? ' saved-sessions-dialog__item--nested' : ''}`}
>
<div className="saved-sessions-dialog__item-main">
<strong>{session.name}</strong>
<span className="saved-sessions-dialog__meta tnum">
{formatSavedSessionDate(session.savedAt)} · {duration} · {eventCount} hành đng
{session.segments.length > 1 ? ` · ${session.segments.length} khung` : ''}
</span>
{session.description && <p className="saved-sessions-dialog__desc">{session.description}</p>}
</div>
<div className="saved-sessions-dialog__actions">
<button type="button" className="saved-sessions-dialog__load" onClick={() => onLoad(session.id)}>
{isActive ? 'Đang xem' : 'Mở'}
</button>
<button
type="button"
className="saved-sessions-dialog__delete"
onClick={() => onDelete(session.id)}
aria-label={`Xóa phiên ${session.name}`}
>
Xóa
</button>
</div>
</li>
);
}
export default function SavedSessionsDialog({
open,
sessions,
encounterGroups,
activeSessionId,
onClose,
onLoad,
onDelete,
}: SavedSessionsDialogProps) {
if (!open) return null;
const groupedIds = new Set(encounterGroups.flatMap((group) => group.childRecordIds));
const standaloneSessions = sessions.filter((session) => !groupedIds.has(session.id));
return (
<div className="saved-sessions-dialog" role="dialog" aria-modal aria-labelledby="saved-sessions-title">
<button type="button" className="saved-sessions-dialog__backdrop" aria-label="Đóng" onClick={onClose} />
<div className="saved-sessions-dialog__panel">
<header className="saved-sessions-dialog__header">
<h3 id="saved-sessions-title">Danh sách phiên đã lưu</h3>
<button type="button" className="saved-sessions-dialog__close" onClick={onClose} aria-label="Đóng">
×
</button>
</header>
{sessions.length === 0 ? (
<p className="saved-sessions-dialog__empty">Chưa phiên ghi nào đưc lưu cho bệnh nhân này.</p>
) : (
<ul className="saved-sessions-dialog__list">
{encounterGroups.map((group) => {
const children = group.childRecordIds
.map((id) => sessions.find((session) => session.id === id))
.filter((session): session is SavedSessionRecord => Boolean(session));
if (children.length === 0) return null;
return (
<li key={group.id} className="saved-sessions-dialog__group">
<div className="saved-sessions-dialog__group-header">
<strong>{group.displayName ?? `Phiên ${formatSavedSessionDate(group.startedAt)}`}</strong>
<span className="saved-sessions-dialog__meta tnum">{children.length} khung</span>
</div>
<ul className="saved-sessions-dialog__group-children">
{children.map((session) => (
<SessionRow
key={session.id}
session={session}
isActive={session.id === activeSessionId}
onLoad={onLoad}
onDelete={onDelete}
nested
/>
))}
</ul>
</li>
);
})}
{standaloneSessions.map((session) => (
<SessionRow
key={session.id}
session={session}
isActive={session.id === activeSessionId}
onLoad={onLoad}
onDelete={onDelete}
/>
))}
</ul>
)}
<footer className="saved-sessions-dialog__footer">
<button type="button" className="saved-sessions-dialog__footer-btn" onClick={onClose}>
Đóng
</button>
</footer>
</div>
<style>{`
.saved-sessions-dialog {
position: fixed;
inset: 0;
z-index: 120;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.saved-sessions-dialog__backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(15, 23, 42, 0.55);
cursor: pointer;
}
.saved-sessions-dialog__panel {
position: relative;
width: min(480px, 100%);
max-height: min(70vh, 520px);
display: flex;
flex-direction: column;
border-radius: 12px;
background: #1e293b;
border: 1px solid #334155;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.35);
color: #e2e8f0;
}
.saved-sessions-dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #334155;
}
.saved-sessions-dialog__header h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.saved-sessions-dialog__close {
border: none;
background: transparent;
color: #94a3b8;
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.saved-sessions-dialog__empty {
margin: 0;
padding: 24px 16px;
font-size: 13px;
color: #94a3b8;
}
.saved-sessions-dialog__list {
list-style: none;
margin: 0;
padding: 8px;
overflow: auto;
flex: 1;
min-height: 0;
}
.saved-sessions-dialog__group {
list-style: none;
margin-bottom: 8px;
border: 1px solid #334155;
border-radius: 8px;
overflow: hidden;
}
.saved-sessions-dialog__group-header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
background: rgba(15, 23, 42, 0.65);
border-bottom: 1px solid #334155;
}
.saved-sessions-dialog__group-header strong {
font-size: 12px;
}
.saved-sessions-dialog__group-children {
list-style: none;
margin: 0;
padding: 0;
}
.saved-sessions-dialog__item {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid transparent;
}
.saved-sessions-dialog__item--nested {
border-radius: 0;
border-bottom: 1px solid rgba(51, 65, 85, 0.65);
}
.saved-sessions-dialog__item--active {
border-color: rgba(118, 200, 177, 0.45);
background: rgba(26, 95, 96, 0.25);
}
.saved-sessions-dialog__item-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.saved-sessions-dialog__item-main strong {
font-size: 13px;
color: #f8fafc;
}
.saved-sessions-dialog__meta {
font-size: 11px;
color: #94a3b8;
}
.saved-sessions-dialog__desc {
margin: 0;
font-size: 12px;
color: #cbd5e1;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.saved-sessions-dialog__actions {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.saved-sessions-dialog__load,
.saved-sessions-dialog__delete {
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 5px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.saved-sessions-dialog__load {
background: rgba(26, 95, 96, 0.85);
color: #ecfdf5;
border-color: rgba(118, 200, 177, 0.35);
}
.saved-sessions-dialog__delete {
background: transparent;
color: #fca5a5;
border-color: rgba(248, 113, 113, 0.25);
}
.saved-sessions-dialog__footer {
padding: 10px 16px 14px;
border-top: 1px solid #334155;
display: flex;
justify-content: flex-end;
}
.saved-sessions-dialog__footer-btn {
border-radius: 8px;
border: 1px solid #475569;
background: rgba(15, 23, 42, 0.8);
color: #e2e8f0;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,697 @@
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import {
getSegmentationLegend,
rgbToCss,
SEGMENTATION_LEGEND_NOTES,
type SegmentationAngleType,
type SegmentationLegendEntry,
type SegmentationLegendNote,
type SegmentationLegendNoteIndicator,
} from '../../data/segmentationLegend';
interface SegmentationLegendBubbleProps {
visible: boolean;
containerRef: RefObject<HTMLElement | null>;
angleType?: SegmentationAngleType;
entries?: SegmentationLegendEntry[];
showMeasurementNote?: boolean;
}
interface Point {
x: number;
y: number;
}
interface Size {
width: number;
height: number;
}
interface Layout {
position: Point;
size: Size;
}
type Corner = 'nw' | 'ne' | 'sw' | 'se';
const EDGE_PADDING = 12;
const DEFAULT_SIZE: Size = { width: 240, height: 340 };
const MIN_SIZE: Size = { width: 200, height: 220 };
const HANDLE_HIT = 14;
const CORNERS: Corner[] = ['nw', 'ne', 'sw', 'se'];
function clampSize(size: Size, container: HTMLElement): Size {
const maxWidth = Math.max(MIN_SIZE.width, container.clientWidth - EDGE_PADDING * 2);
const maxHeight = Math.max(MIN_SIZE.height, container.clientHeight - EDGE_PADDING * 2);
return {
width: Math.min(Math.max(size.width, MIN_SIZE.width), maxWidth),
height: Math.min(Math.max(size.height, MIN_SIZE.height), maxHeight),
};
}
function clampLayout(layout: Layout, container: HTMLElement): Layout {
const size = clampSize(layout.size, container);
const maxX = Math.max(0, container.clientWidth - size.width);
const maxY = Math.max(0, container.clientHeight - size.height);
return {
size,
position: {
x: Math.min(Math.max(0, layout.position.x), maxX),
y: Math.min(Math.max(0, layout.position.y), maxY),
},
};
}
function defaultLayout(container: HTMLElement): Layout {
const size = clampSize(DEFAULT_SIZE, container);
return clampLayout(
{
position: {
x: container.clientWidth - size.width - EDGE_PADDING,
y: EDGE_PADDING,
},
size,
},
container,
);
}
function NoteIndicator({ kind }: { kind: SegmentationLegendNoteIndicator }) {
return (
<span className={`seg-legend-bubble__note-indicator seg-legend-bubble__note-indicator--${kind}`} aria-hidden />
);
}
function LegendNote({ note }: { note: SegmentationLegendNote }) {
return (
<p className="seg-legend-bubble__note">
{note.indicator && <NoteIndicator kind={note.indicator} />}
<span className="seg-legend-bubble__note-copy">
<span className="seg-legend-bubble__note-vi">{note.textVi}</span>
<span className="seg-legend-bubble__note-en">{note.textEn}</span>
</span>
</p>
);
}
export default function SegmentationLegendBubble({
visible,
containerRef,
angleType = 'sup',
entries,
showMeasurementNote = false,
}: SegmentationLegendBubbleProps) {
const bubbleRef = useRef<HTMLElement>(null);
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
const resizeSessionRef = useRef<{
corner: Corner;
pointerId: number;
startPointer: Point;
startLayout: Layout;
} | null>(null);
const [layout, setLayout] = useState<Layout | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [isReady, setIsReady] = useState(false);
const legendItems = entries ?? getSegmentationLegend(angleType);
const matchesAngle = (note: (typeof SEGMENTATION_LEGEND_NOTES)[number]) =>
!note.supOnly || angleType === 'sup';
const generalNotes = SEGMENTATION_LEGEND_NOTES.filter(
(note) => matchesAngle(note) && !note.measurementOnly,
);
const measurementNotes = showMeasurementNote
? SEGMENTATION_LEGEND_NOTES.filter((note) => matchesAngle(note) && note.measurementOnly)
: [];
const notesCount = generalNotes.length + measurementNotes.length;
const syncLayout = useCallback(() => {
const container = containerRef.current;
if (!container) return;
setLayout((current) => {
const next = clampLayout(current ?? defaultLayout(container), container);
return next;
});
setIsReady(true);
}, [containerRef]);
useEffect(() => {
if (!visible) {
setIsReady(false);
return;
}
const frame = requestAnimationFrame(() => syncLayout());
return () => cancelAnimationFrame(frame);
}, [visible, syncLayout, legendItems.length, notesCount]);
useEffect(() => {
if (!visible) return;
const handleResize = () => syncLayout();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [visible, syncLayout]);
const handleDragPointerDown = (event: React.PointerEvent<HTMLElement>) => {
const container = containerRef.current;
const bubble = bubbleRef.current;
if (!container || !bubble || !layout || event.button !== 0 || isResizing) return;
const bubbleRect = bubble.getBoundingClientRect();
dragOffsetRef.current = {
x: event.clientX - bubbleRect.left,
y: event.clientY - bubbleRect.top,
};
setIsDragging(true);
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
};
const handleDragPointerMove = (event: React.PointerEvent<HTMLElement>) => {
if (!isDragging) return;
const container = containerRef.current;
if (!container) return;
const containerRect = container.getBoundingClientRect();
setLayout((current) => {
if (!current) return current;
return clampLayout(
{
...current,
position: {
x: event.clientX - containerRect.left - dragOffsetRef.current.x,
y: event.clientY - containerRect.top - dragOffsetRef.current.y,
},
},
container,
);
});
};
const endDrag = (event: React.PointerEvent<HTMLElement>) => {
if (!isDragging) return;
setIsDragging(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const handleResizePointerDown = (corner: Corner) => (event: React.PointerEvent<HTMLButtonElement>) => {
if (!layout || event.button !== 0) return;
resizeSessionRef.current = {
corner,
pointerId: event.pointerId,
startPointer: { x: event.clientX, y: event.clientY },
startLayout: layout,
};
setIsResizing(true);
event.currentTarget.setPointerCapture(event.pointerId);
event.stopPropagation();
event.preventDefault();
};
useEffect(() => {
if (!isResizing) return;
const handlePointerMove = (event: PointerEvent) => {
const session = resizeSessionRef.current;
const container = containerRef.current;
if (!session || !container || event.pointerId !== session.pointerId) return;
const deltaX = event.clientX - session.startPointer.x;
const deltaY = event.clientY - session.startPointer.y;
const { startLayout } = session;
let nextLayout: Layout = {
position: { ...startLayout.position },
size: { ...startLayout.size },
};
switch (session.corner) {
case 'se':
nextLayout.size.width = startLayout.size.width + deltaX;
nextLayout.size.height = startLayout.size.height + deltaY;
break;
case 'sw':
nextLayout.size.width = startLayout.size.width - deltaX;
nextLayout.size.height = startLayout.size.height + deltaY;
nextLayout.position.x = startLayout.position.x + deltaX;
break;
case 'ne':
nextLayout.size.width = startLayout.size.width + deltaX;
nextLayout.size.height = startLayout.size.height - deltaY;
nextLayout.position.y = startLayout.position.y + deltaY;
break;
case 'nw':
nextLayout.size.width = startLayout.size.width - deltaX;
nextLayout.size.height = startLayout.size.height - deltaY;
nextLayout.position.x = startLayout.position.x + deltaX;
nextLayout.position.y = startLayout.position.y + deltaY;
break;
}
setLayout(clampLayout(nextLayout, container));
};
const handlePointerUp = (event: PointerEvent) => {
const session = resizeSessionRef.current;
if (!session || event.pointerId !== session.pointerId) return;
resizeSessionRef.current = null;
setIsResizing(false);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [containerRef, isResizing]);
if (!visible) return null;
return (
<aside
ref={bubbleRef}
className={`seg-legend-bubble glass-elevated${isDragging ? ' seg-legend-bubble--dragging' : ''}${isResizing ? ' seg-legend-bubble--resizing' : ''}${isReady ? ' seg-legend-bubble--ready' : ''}`}
style={
layout
? {
left: `${layout.position.x}px`,
top: `${layout.position.y}px`,
width: `${layout.size.width}px`,
height: `${layout.size.height}px`,
}
: undefined
}
aria-label="Chú thích phân đoạn"
>
{CORNERS.map((corner) => (
<button
key={corner}
type="button"
className={`seg-legend-bubble__resize-handle seg-legend-bubble__resize-handle--${corner}`}
aria-label={`Đổi kích thước góc ${corner}`}
onPointerDown={handleResizePointerDown(corner)}
/>
))}
<header
className="seg-legend-bubble__header"
onPointerDown={handleDragPointerDown}
onPointerMove={handleDragPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
aria-grabbed={isDragging}
>
<span className="seg-legend-bubble__drag-handle" aria-hidden>
</span>
<div className="seg-legend-bubble__header-copy">
<span className="seg-legend-bubble__eyebrow">Phân đoạn AI</span>
<h3 className="seg-legend-bubble__title">Chú thích màu</h3>
</div>
</header>
<div className="seg-legend-bubble__body">
<ul className="seg-legend-bubble__list" role="list">
{legendItems.map((item) => (
<li key={item.key} className="seg-legend-bubble__item">
<span
className={`seg-legend-bubble__swatch${item.highlight ? ' seg-legend-bubble__swatch--highlight' : ''}`}
style={{ backgroundColor: rgbToCss(item.color) }}
aria-hidden
/>
<div className="seg-legend-bubble__copy">
<span className="seg-legend-bubble__label-vi">{item.nameVi}</span>
<span className="seg-legend-bubble__label-en">{item.nameEn}</span>
</div>
</li>
))}
</ul>
<div className="seg-legend-bubble__notes">
{generalNotes.map((note) => (
<LegendNote key={note.id} note={note} />
))}
{measurementNotes.length > 0 && (
<>
<div className="seg-legend-bubble__notes-divider" role="separator" aria-hidden />
{measurementNotes.map((note) => (
<LegendNote key={note.id} note={note} />
))}
</>
)}
</div>
</div>
<style>{`
.seg-legend-bubble {
position: absolute;
display: flex;
flex-direction: column;
border-radius: var(--radius-lg);
overflow: hidden;
z-index: 20;
pointer-events: auto;
opacity: 0;
user-select: none;
}
.seg-legend-bubble--ready {
opacity: 1;
}
.seg-legend-bubble--dragging,
.seg-legend-bubble--resizing {
box-shadow: 0 20px 48px rgba(77, 141, 145, 0.2);
}
.seg-legend-bubble--dragging {
cursor: grabbing;
}
.seg-legend-bubble__resize-handle {
position: absolute;
width: ${HANDLE_HIT}px;
height: ${HANDLE_HIT}px;
padding: 0;
border: none;
background: transparent;
opacity: 0;
z-index: 2;
transition: opacity 0.15s ease;
}
.seg-legend-bubble:hover .seg-legend-bubble__resize-handle,
.seg-legend-bubble--resizing .seg-legend-bubble__resize-handle,
.seg-legend-bubble__resize-handle:focus-visible {
opacity: 1;
}
.seg-legend-bubble__resize-handle::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
border: 2px solid var(--color-secondary);
border-radius: 2px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(23, 29, 28, 0.18);
}
.seg-legend-bubble__resize-handle--nw {
top: -4px;
left: -4px;
cursor: nwse-resize;
}
.seg-legend-bubble__resize-handle--nw::after {
top: 3px;
left: 3px;
}
.seg-legend-bubble__resize-handle--ne {
top: -4px;
right: -4px;
cursor: nesw-resize;
}
.seg-legend-bubble__resize-handle--ne::after {
top: 3px;
right: 3px;
}
.seg-legend-bubble__resize-handle--sw {
bottom: -4px;
left: -4px;
cursor: nesw-resize;
}
.seg-legend-bubble__resize-handle--sw::after {
bottom: 3px;
left: 3px;
}
.seg-legend-bubble__resize-handle--se {
bottom: -4px;
right: -4px;
cursor: nwse-resize;
}
.seg-legend-bubble__resize-handle--se::after {
bottom: 3px;
right: 3px;
}
.seg-legend-bubble__header {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px 8px;
border-bottom: 1px solid var(--color-outline-variant);
flex-shrink: 0;
cursor: grab;
touch-action: none;
}
.seg-legend-bubble--dragging .seg-legend-bubble__header {
cursor: grabbing;
}
.seg-legend-bubble__drag-handle {
font-size: 12px;
line-height: 1;
letter-spacing: -2px;
color: var(--color-outline);
padding-top: 2px;
flex-shrink: 0;
}
.seg-legend-bubble__header-copy {
min-width: 0;
}
.seg-legend-bubble__eyebrow {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-secondary);
margin-bottom: 2px;
}
.seg-legend-bubble__title {
font-size: 13px;
font-weight: 600;
color: var(--color-on-surface);
line-height: 1.3;
}
.seg-legend-bubble__body {
flex: 1;
min-height: 0;
overflow-y: auto;
touch-action: pan-y;
scrollbar-width: thin;
scrollbar-color: rgba(35, 104, 108, 0.42) transparent;
}
.seg-legend-bubble__body::-webkit-scrollbar {
width: 6px;
}
.seg-legend-bubble__body::-webkit-scrollbar-track {
background: transparent;
}
.seg-legend-bubble__body::-webkit-scrollbar-thumb {
background-color: rgba(35, 104, 108, 0.38);
border-radius: 999px;
border: 1px solid transparent;
background-clip: padding-box;
}
.seg-legend-bubble__body::-webkit-scrollbar-thumb:hover {
background-color: rgba(35, 104, 108, 0.58);
}
.seg-legend-bubble__list {
list-style: none;
margin: 0;
padding: 6px 4px 0 0;
}
.seg-legend-bubble__item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 7px 12px;
}
.seg-legend-bubble__swatch {
width: 14px;
height: 14px;
border-radius: 4px;
flex-shrink: 0;
margin-top: 2px;
border: 1px solid rgba(23, 29, 28, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.seg-legend-bubble__swatch--highlight {
box-shadow:
0 0 0 2px #fff,
0 0 0 3px rgba(23, 29, 28, 0.25);
}
.seg-legend-bubble__copy {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
flex: 1;
}
.seg-legend-bubble__label-vi,
.seg-legend-bubble__label-en,
.seg-legend-bubble__note,
.seg-legend-bubble__note-en {
overflow-wrap: anywhere;
word-break: normal;
}
.seg-legend-bubble__label-vi {
font-size: 12px;
font-weight: 600;
color: var(--color-on-surface);
line-height: 1.25;
}
.seg-legend-bubble__label-en {
font-size: 11px;
color: var(--color-on-surface-variant);
line-height: 1.25;
}
.seg-legend-bubble__notes {
padding: 10px 12px 12px;
border-top: 1px solid rgba(23, 29, 28, 0.14);
background: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: column;
gap: 10px;
}
.seg-legend-bubble__notes-divider {
height: 1px;
margin: 0;
background: rgba(23, 29, 28, 0.16);
flex-shrink: 0;
}
.seg-legend-bubble__note {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--color-on-surface);
display: flex;
align-items: flex-start;
gap: 10px;
}
.seg-legend-bubble__note-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.seg-legend-bubble__note-indicator {
width: 14px;
height: 14px;
flex-shrink: 0;
margin-top: 2px;
border-radius: 2px;
position: relative;
}
.seg-legend-bubble__note-indicator--bbox-green {
border: 2px solid rgb(0, 255, 0);
background: transparent;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
}
.seg-legend-bubble__note-indicator--outline-white {
border: 3px solid rgb(255, 255, 255);
background: rgba(255, 0, 0, 0.35);
box-shadow: 0 0 0 1px rgba(23, 29, 28, 0.2);
}
.seg-legend-bubble__note-indicator--vline-cyan,
.seg-legend-bubble__note-indicator--vline-red {
background: transparent;
}
.seg-legend-bubble__note-indicator--vline-cyan::before,
.seg-legend-bubble__note-indicator--vline-cyan::after,
.seg-legend-bubble__note-indicator--vline-red::before {
content: '';
position: absolute;
top: 1px;
bottom: 1px;
width: 2px;
border-radius: 1px;
}
.seg-legend-bubble__note-indicator--vline-cyan::before {
left: 3px;
background: rgb(0, 255, 255);
}
.seg-legend-bubble__note-indicator--vline-cyan::after {
right: 3px;
background: rgb(0, 255, 255);
}
.seg-legend-bubble__note-indicator--vline-red::before {
left: 50%;
transform: translateX(-50%);
background: rgb(255, 0, 0);
width: 3px;
}
.seg-legend-bubble__note-vi {
font-size: 12px;
font-weight: 600;
color: var(--color-on-surface);
line-height: 1.35;
}
.seg-legend-bubble__note-en {
font-size: 10px;
font-style: italic;
color: var(--color-on-surface-variant);
line-height: 1.35;
}
`}</style>
</aside>
);
}

View File

@@ -0,0 +1,93 @@
interface SegmentationOverlayProps {
overlaySrc: string | null;
isLoading?: boolean;
}
export default function SegmentationOverlay({ overlaySrc, isLoading }: SegmentationOverlayProps) {
if (isLoading && !overlaySrc) {
return (
<div
className="segmentation-overlay segmentation-overlay--loading"
role="status"
aria-live="polite"
aria-label="Đang phân đoạn AI"
>
<div className="segmentation-overlay__loading-card">
<span className="segmentation-overlay__spinner" aria-hidden />
<p className="segmentation-overlay__loading-title">Đang phân đoạn AI</p>
<p className="segmentation-overlay__loading-subtitle">AI segmentation in progress</p>
</div>
<style>{`
.segmentation-overlay--loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(10, 15, 14, 0.58);
pointer-events: none;
z-index: 12;
}
.segmentation-overlay__loading-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
max-width: min(92%, 360px);
padding: 20px 24px;
border-radius: var(--radius-lg);
border: 1px solid rgba(173, 238, 242, 0.35);
background: rgba(23, 29, 28, 0.88);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
text-align: center;
}
.segmentation-overlay__spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(173, 238, 242, 0.28);
border-top-color: var(--color-secondary-container);
border-radius: 50%;
animation: segmentation-overlay-spin 0.85s linear infinite;
}
.segmentation-overlay__loading-title {
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
letter-spacing: 0.01em;
color: #fff;
}
.segmentation-overlay__loading-subtitle {
margin: 0;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
color: rgba(255, 255, 255, 0.78);
}
@keyframes segmentation-overlay-spin {
to {
transform: rotate(360deg);
}
}
`}</style>
</div>
);
}
if (!overlaySrc) return null;
return (
<img
src={overlaySrc}
alt=""
aria-hidden
className="diagnostic-canvas__overlay diagnostic-canvas__overlay--mask"
/>
);
}

View File

@@ -0,0 +1,114 @@
import { createPortal } from 'react-dom';
interface SessionCancelRecordDialogProps {
open: boolean;
onDismiss: () => void;
onConfirm: () => void;
}
export default function SessionCancelRecordDialog({
open,
onDismiss,
onConfirm,
}: SessionCancelRecordDialogProps) {
if (!open) return null;
return createPortal(
<div
className="session-cancel-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="session-cancel-title"
onClick={onDismiss}
>
<div className="session-cancel-dialog__card" onClick={(event) => event.stopPropagation()}>
<h3 id="session-cancel-title" className="session-cancel-dialog__title">
Hủy phiên ghi?
</h3>
<p className="session-cancel-dialog__caption">
Phiên ghi hiện tại chưa đưc lưu sẽ bị xóa. nh DICOM đã tải lên vẫn đưc giữ lại.
</p>
<div className="session-cancel-dialog__actions">
<button
type="button"
className="session-cancel-dialog__btn session-cancel-dialog__btn--dismiss"
onClick={onDismiss}
>
Quay lại
</button>
<button
type="button"
className="session-cancel-dialog__btn session-cancel-dialog__btn--confirm"
onClick={onConfirm}
>
Hủy ghi
</button>
</div>
</div>
<style>{`
.session-cancel-dialog {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(15, 23, 42, 0.62);
cursor: pointer;
}
.session-cancel-dialog__card {
width: min(420px, 100%);
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #1e293b;
padding: 22px 20px 18px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
cursor: default;
}
.session-cancel-dialog__title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 700;
color: #f8fafc;
}
.session-cancel-dialog__caption {
margin: 0 0 18px;
font-size: 13px;
line-height: 1.5;
color: #94a3b8;
}
.session-cancel-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.session-cancel-dialog__btn {
border-radius: 10px;
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.session-cancel-dialog__btn--dismiss {
border: 1px solid rgba(148, 163, 184, 0.35);
background: #1e293b;
color: #cbd5e1;
}
.session-cancel-dialog__btn--dismiss:hover {
background: rgba(51, 65, 85, 0.65);
}
.session-cancel-dialog__btn--confirm {
border: 1px solid #b91c1c;
background: #dc2626;
color: #fff;
}
.session-cancel-dialog__btn--confirm:hover {
background: #b91c1c;
}
`}</style>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,461 @@
import type { SessionSaveUiState } from '../../lib/sessionLifecycle';
interface SessionLifecycleToolbarProps {
status: 'idle' | 'recording' | 'paused' | 'ended';
saveState: SessionSaveUiState;
saveName: string;
savedSessionCount: number;
onStart: () => void;
onPause: () => void;
onResume: () => void;
onEnd: () => void;
onReset: () => void;
onNewSession: () => void;
canStartNewSession: boolean;
onSaveClick: () => void;
onSaveNameChange: (value: string) => void;
onConfirmSave: () => void;
onCancelSave: () => void;
onCancelRecord: () => void;
canCancelRecord: boolean;
onOpenSessionsList: () => void;
}
function RecordDotIcon({ pulsing }: { pulsing: boolean }) {
return <span className={`lifecycle-toolbar__rec-dot${pulsing ? ' lifecycle-toolbar__rec-dot--pulse' : ''}`} aria-hidden />;
}
function PauseBarsIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
);
}
function ResumeIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M8 5v14l11-7z" />
</svg>
);
}
function StopIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
);
}
function NewSessionIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
function ResetIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
<path d="M3 12a9 9 0 1 0 3-6.7" />
<path d="M3 4v5h5" />
</svg>
);
}
function SaveIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<path d="M17 21v-8H7v8M7 3v5h8" />
</svg>
);
}
function CancelRecordIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="M8 8l8 8M16 8l-8 8" />
</svg>
);
}
function ListIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" />
</svg>
);
}
export default function SessionLifecycleToolbar({
status,
saveState,
saveName,
savedSessionCount,
onStart,
onPause,
onResume,
onEnd,
onReset,
onNewSession,
canStartNewSession,
onSaveClick,
onSaveNameChange,
onConfirmSave,
onCancelSave,
onCancelRecord,
canCancelRecord,
onOpenSessionsList,
}: SessionLifecycleToolbarProps) {
const isRecording = status === 'recording';
const isPaused = status === 'paused';
const isEnded = status === 'ended';
const canEnd = isRecording || isPaused;
const canConfirmSave = saveName.trim().length > 0;
const handlePrimaryRecordClick = () => {
if (isPaused || isEnded) {
onResume();
return;
}
onStart();
};
return (
<div className="lifecycle-toolbar-wrap">
<div className="lifecycle-toolbar" role="toolbar" aria-label="Điều khiển vòng đời phiên ghi">
{isPaused ? (
<button type="button" className="lifecycle-toolbar__btn lifecycle-toolbar__btn--resume" onClick={onResume}>
<ResumeIcon />
<span>Tiếp tục ghi</span>
</button>
) : (
<button
type="button"
className={`lifecycle-toolbar__btn lifecycle-toolbar__btn--record${isRecording ? ' lifecycle-toolbar__btn--recording' : ''}`}
onClick={handlePrimaryRecordClick}
disabled={isRecording}
aria-pressed={isRecording}
>
<RecordDotIcon pulsing={isRecording} />
<span>{isRecording ? 'Đang ghi...' : 'Ghi'}</span>
</button>
)}
<span className="lifecycle-toolbar__divider" aria-hidden />
<button
type="button"
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--pause"
onClick={onPause}
disabled={!isRecording}
>
<PauseBarsIcon />
<span>Tạm dừng</span>
</button>
<span className="lifecycle-toolbar__divider" aria-hidden />
<button
type="button"
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--end"
onClick={onEnd}
disabled={!canEnd}
>
<StopIcon />
<span>Kết thúc ghi</span>
</button>
<span className="lifecycle-toolbar__divider" aria-hidden />
<button
type="button"
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--new"
onClick={onNewSession}
disabled={!canStartNewSession}
title="Tạo phiên ghi trống mới — nhấn Ghi khi sẵn sàng"
>
<NewSessionIcon />
<span>Phiên mới</span>
</button>
<span className="lifecycle-toolbar__divider" aria-hidden />
<button
type="button"
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--reset"
onClick={onReset}
title="Xóa phiên hiện tại và ghi lại từ đầu"
>
<ResetIcon />
<span>Làm mới từ đu</span>
</button>
</div>
<div className="lifecycle-toolbar__save-row">
<div className="lifecycle-toolbar__save-group">
{saveState === 'confirm' ? (
<>
<label className="lifecycle-toolbar__save-label">
<span className="sr-only">Tên phiên ghi</span>
<input
type="text"
className="lifecycle-toolbar__save-input"
value={saveName}
placeholder="Đặt tên phiên ghi…"
onChange={(event) => onSaveNameChange(event.target.value)}
autoFocus
/>
</label>
<button
type="button"
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--save lifecycle-toolbar__btn--save-confirm"
onClick={onConfirmSave}
disabled={!canConfirmSave}
>
<SaveIcon />
<span>Xác nhận lưu</span>
</button>
<button type="button" className="lifecycle-toolbar__save-cancel" onClick={onCancelSave}>
Hủy
</button>
</>
) : (
<>
<button
type="button"
className={`lifecycle-toolbar__btn lifecycle-toolbar__btn--save lifecycle-toolbar__btn--save-${saveState}`}
onClick={onSaveClick}
disabled={saveState !== 'savable'}
aria-disabled={saveState !== 'savable'}
>
<SaveIcon />
<span>Lưu phiên</span>
</button>
<button
type="button"
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--cancel-record"
onClick={onCancelRecord}
disabled={!canCancelRecord}
title="Hủy phiên ghi hiện tại, không lưu"
>
<CancelRecordIcon />
<span>Hủy ghi</span>
</button>
</>
)}
</div>
<button
type="button"
className="lifecycle-toolbar__btn lifecycle-toolbar__btn--sessions"
onClick={onOpenSessionsList}
>
<ListIcon />
<span>Danh sách phiên ({savedSessionCount})</span>
</button>
</div>
<style>{`
.lifecycle-toolbar-wrap {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 8px;
border-bottom: 1px solid #334155;
padding-bottom: 12px;
margin-bottom: 4px;
}
.lifecycle-toolbar {
display: flex;
align-items: stretch;
gap: 0;
}
.lifecycle-toolbar__save-row {
display: flex;
align-items: center;
gap: 8px;
}
.lifecycle-toolbar__save-group {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.lifecycle-toolbar__save-label {
flex: 1;
min-width: 0;
}
.lifecycle-toolbar__save-input {
width: 100%;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(118, 200, 177, 0.45);
background: rgba(15, 23, 42, 0.92);
color: #f8fafc;
font-size: 12px;
}
.lifecycle-toolbar__save-input::placeholder {
color: #64748b;
}
.lifecycle-toolbar__save-cancel {
border: none;
background: transparent;
color: #94a3b8;
font-size: 11px;
font-weight: 600;
cursor: pointer;
padding: 6px 4px;
white-space: nowrap;
}
.lifecycle-toolbar__divider {
width: 1px;
background: #334155;
margin: 4px 4px;
flex-shrink: 0;
}
.lifecycle-toolbar__btn {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 8px 4px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(15, 23, 42, 0.72);
color: #e2e8f0;
font-size: 10px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.lifecycle-toolbar__btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.lifecycle-toolbar__btn--record {
background: rgba(15, 23, 42, 0.92);
}
.lifecycle-toolbar__btn--recording {
border-color: rgba(220, 38, 38, 0.45);
background: rgba(30, 23, 23, 0.85);
}
.lifecycle-toolbar__btn--resume {
border-color: rgba(118, 200, 177, 0.35);
color: #76c8b1;
}
.lifecycle-toolbar__btn--pause:not(:disabled):hover {
border-color: rgba(148, 163, 184, 0.45);
}
.lifecycle-toolbar__btn--end:not(:disabled) {
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.lifecycle-toolbar__btn--end:not(:disabled):hover {
border-color: rgba(248, 113, 113, 0.55);
background: rgba(69, 10, 10, 0.35);
}
.lifecycle-toolbar__btn--new {
border-color: rgba(118, 200, 177, 0.4);
color: #a7f3d0;
background: rgba(15, 45, 48, 0.35);
}
.lifecycle-toolbar__btn--new:not(:disabled):hover {
border-color: rgba(118, 200, 177, 0.65);
background: rgba(26, 95, 96, 0.45);
color: #ecfdf5;
}
.lifecycle-toolbar__btn--reset {
border-color: rgba(251, 191, 36, 0.5);
color: #fcd34d;
background: rgba(146, 64, 14, 0.22);
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.12);
}
.lifecycle-toolbar__btn--reset:hover {
border-color: rgba(251, 191, 36, 0.75);
background: rgba(146, 64, 14, 0.34);
color: #fef08a;
}
.lifecycle-toolbar__btn--reset svg {
color: #fbbf24;
}
.lifecycle-toolbar__btn--save {
flex: 0 1 auto;
min-width: 108px;
border-color: rgba(148, 163, 184, 0.2);
color: #94a3b8;
}
.lifecycle-toolbar__btn--save-savable {
border-color: rgba(118, 200, 177, 0.55);
color: #76c8b1;
background: rgba(26, 95, 96, 0.35);
}
.lifecycle-toolbar__btn--save-savable:not(:disabled):hover {
background: rgba(26, 95, 96, 0.55);
color: #ecfdf5;
}
.lifecycle-toolbar__btn--save-confirm {
flex: 0 0 auto;
border-color: rgba(118, 200, 177, 0.65);
color: #ecfdf5;
background: rgba(26, 95, 96, 0.9);
}
.lifecycle-toolbar__btn--cancel-record {
flex: 0 1 auto;
min-width: 96px;
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
background: rgba(69, 10, 10, 0.22);
}
.lifecycle-toolbar__btn--cancel-record:not(:disabled):hover {
border-color: rgba(248, 113, 113, 0.55);
background: rgba(127, 29, 29, 0.38);
color: #fee2e2;
}
.lifecycle-toolbar__btn--sessions {
flex: 0 1 auto;
min-width: 0;
border-color: rgba(148, 163, 184, 0.25);
color: #cbd5e1;
}
.lifecycle-toolbar__btn--sessions:hover {
border-color: rgba(148, 163, 184, 0.45);
background: rgba(30, 41, 59, 0.85);
}
.lifecycle-toolbar__rec-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #dc2626;
flex-shrink: 0;
}
.lifecycle-toolbar__rec-dot--pulse {
animation: lifecycle-rec-pulse 1s ease-in-out infinite;
}
@keyframes lifecycle-rec-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,118 @@
import { createPortal } from 'react-dom';
interface SessionNewSessionDialogProps {
open: boolean;
onDismiss: () => void;
onConfirm: () => void;
}
export default function SessionNewSessionDialog({
open,
onDismiss,
onConfirm,
}: SessionNewSessionDialogProps) {
if (!open) return null;
return createPortal(
<div
className="session-new-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="session-new-title"
onClick={onDismiss}
>
<div className="session-new-dialog__card" onClick={(event) => event.stopPropagation()}>
<h3 id="session-new-title" className="session-new-dialog__title">
Tạo phiên ghi mới?
</h3>
<p className="session-new-dialog__caption">
Canvas nhật phiên hiện tại sẽ đưc xóa. Phiên mới trạng thái chờ nhấn{' '}
<strong>Ghi</strong> khi sẵn sàng bắt đu. nh DICOM vẫn đưc giữ lại.
</p>
<div className="session-new-dialog__actions">
<button
type="button"
className="session-new-dialog__btn session-new-dialog__btn--dismiss"
onClick={onDismiss}
>
Quay lại
</button>
<button
type="button"
className="session-new-dialog__btn session-new-dialog__btn--confirm"
onClick={onConfirm}
>
Phiên mới
</button>
</div>
</div>
<style>{`
.session-new-dialog {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(15, 23, 42, 0.62);
cursor: pointer;
}
.session-new-dialog__card {
width: min(440px, 100%);
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #1e293b;
padding: 22px 20px 18px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
cursor: default;
}
.session-new-dialog__title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 700;
color: #f8fafc;
}
.session-new-dialog__caption {
margin: 0 0 18px;
font-size: 13px;
line-height: 1.5;
color: #94a3b8;
}
.session-new-dialog__caption strong {
color: #cbd5e1;
}
.session-new-dialog__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.session-new-dialog__btn {
border-radius: 10px;
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.session-new-dialog__btn--dismiss {
border: 1px solid rgba(148, 163, 184, 0.35);
background: #1e293b;
color: #cbd5e1;
}
.session-new-dialog__btn--dismiss:hover {
background: rgba(51, 65, 85, 0.65);
}
.session-new-dialog__btn--confirm {
border: 1px solid rgba(118, 200, 177, 0.55);
background: rgba(26, 95, 96, 0.85);
color: #ecfdf5;
}
.session-new-dialog__btn--confirm:hover {
background: rgba(26, 95, 96, 1);
}
`}</style>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,146 @@
import { createPortal } from 'react-dom';
interface SessionResetConfirmDialogProps {
open: boolean;
onCancel: () => void;
onConfirmAndRecord: () => void;
onConfirmDeleteOnly: () => void;
}
export default function SessionResetConfirmDialog({
open,
onCancel,
onConfirmAndRecord,
onConfirmDeleteOnly,
}: SessionResetConfirmDialogProps) {
if (!open) return null;
return createPortal(
<div
className="session-reset-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="session-reset-title"
onClick={onCancel}
>
<div
className="session-reset-dialog__card"
onClick={(event) => event.stopPropagation()}
>
<h3 id="session-reset-title" className="session-reset-dialog__title">
Xóa toàn bộ tiến trình lịch sử?
</h3>
<p className="session-reset-dialog__caption">
Hành đng này sẽ xóa vĩnh viễn tất cả nhật thao tác danh sách tệp nh DICOM đã tải lên trong
phiên làm việc này. Chọn <strong>Xóa &amp; Ghi lại</strong> đ bắt đu ghi ngay, hoặc{' '}
<strong>Chỉ xóa &amp; Dừng</strong> đ xóa dừng ghi (không tự ghi lại).
</p>
<div className="session-reset-dialog__actions">
<button type="button" className="session-reset-dialog__btn session-reset-dialog__btn--cancel" onClick={onCancel}>
Hủy
</button>
<button
type="button"
className="session-reset-dialog__btn session-reset-dialog__btn--delete-only"
onClick={onConfirmDeleteOnly}
>
Chỉ xóa &amp; Dừng
</button>
<button
type="button"
className="session-reset-dialog__btn session-reset-dialog__btn--confirm"
onClick={onConfirmAndRecord}
>
Xóa &amp; Ghi lại
</button>
</div>
</div>
<style>{`
.session-reset-dialog {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(15, 23, 42, 0.62);
cursor: pointer;
}
.session-reset-dialog__card {
position: relative;
z-index: 1;
width: min(460px, 100%);
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: #1e293b;
padding: 22px 20px 18px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.35);
cursor: default;
pointer-events: auto;
}
.session-reset-dialog__title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 700;
color: #f8fafc;
}
.session-reset-dialog__caption {
margin: 0 0 18px;
font-size: 13px;
line-height: 1.5;
color: #94a3b8;
}
.session-reset-dialog__caption strong {
color: #cbd5e1;
font-weight: 600;
}
.session-reset-dialog__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
position: relative;
z-index: 2;
}
.session-reset-dialog__btn {
position: relative;
z-index: 1;
border-radius: 10px;
padding: 9px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
pointer-events: auto;
}
.session-reset-dialog__btn--cancel {
border: 1px solid rgba(148, 163, 184, 0.35);
background: #1e293b;
color: #cbd5e1;
}
.session-reset-dialog__btn--cancel:hover {
background: rgba(51, 65, 85, 0.65);
}
.session-reset-dialog__btn--delete-only {
border: 1px solid rgba(251, 191, 36, 0.55);
background: rgba(146, 64, 14, 0.18);
color: #fcd34d;
}
.session-reset-dialog__btn--delete-only:hover {
background: rgba(146, 64, 14, 0.3);
border-color: rgba(251, 191, 36, 0.75);
}
.session-reset-dialog__btn--confirm {
border: 1px solid #b91c1c;
background: #dc2626;
color: #fff;
}
.session-reset-dialog__btn--confirm:hover {
background: #b91c1c;
}
`}</style>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,210 @@
import { useMemo } from 'react';
import { GRADE_LABELS } from '../../data/mockData';
import type { SynovitisSeverityResult } from '../../data/synovitisSeverity';
import {
INFLAMMATION_CLASSES,
INFLAMMATION_LABELS,
type InflammationClassificationResult,
} from '../../data/inflammationClassification';
import {
interpretLogits,
predictedClassFromProbabilities,
type CalibrationUserConfig,
} from '../../lib/calibrationEngine';
import CalibratedDistributionList, { CalibrationStateBanner } from './CalibratedDistributionList';
import CalibrationMetricHelp from './CalibrationMetricHelp';
interface SeverityBadgeProps {
severity: SynovitisSeverityResult | null;
severityLoading?: boolean;
inflammation: InflammationClassificationResult | null;
inflammationLoading?: boolean;
userConfig: CalibrationUserConfig;
suppressed?: boolean;
}
const GRADE_COLORS: Record<number, string> = {
0: '#76c8b1',
1: '#5fb89e',
2: '#e8a838',
3: '#ba1a1a',
};
const INFLAMMATION_DISTRIBUTION_ENTRIES = INFLAMMATION_CLASSES.map((key) => ({
key,
label: INFLAMMATION_LABELS[key],
}));
export default function SeverityBadge({
severity,
severityLoading = false,
inflammation,
inflammationLoading = false,
userConfig,
suppressed,
}: SeverityBadgeProps) {
const liveCalibration = useMemo(() => {
const raw = inflammation?.calibration?.raw_logits;
if (!raw?.length) return inflammation?.calibration ?? null;
return interpretLogits(raw, INFLAMMATION_CLASSES, userConfig, (key) => INFLAMMATION_LABELS[key as keyof typeof INFLAMMATION_LABELS] ?? key);
}, [inflammation, userConfig]);
const predictedKey = useMemo(() => {
if (!liveCalibration) return inflammation?.detected ? 'inflammation' : 'no_inflammation';
return predictedClassFromProbabilities(liveCalibration.class_probabilities, INFLAMMATION_CLASSES);
}, [liveCalibration, inflammation]);
if (suppressed) {
return (
<div className="severity-badge severity-badge--suppressed glass">
<span className="severity-badge__label">Đ xuất AI bị n</span>
<p>Chế đ an toàn đánh giá thủ công bắt buộc</p>
<style>{badgeStyles}</style>
</div>
);
}
const grade = severity?.level ?? null;
const gradeLabel = severity?.label ?? (grade != null ? GRADE_LABELS[grade] : null);
return (
<div className="severity-panel">
{severityLoading ? (
<div className="severity-panel__block glass">
<span className="severity-panel__eyebrow">Viêm màng hoạt dịch đ nặng (từ phân đoạn)</span>
<p className="severity-panel__pending">Đang chờ kết quả đ nặng từ phân đoạn</p>
</div>
) : grade != null && gradeLabel ? (
<div className="severity-badge glass" style={{ borderColor: GRADE_COLORS[grade] ?? GRADE_COLORS[0] }}>
<span className="severity-badge__grade tnum" style={{ color: GRADE_COLORS[grade] ?? GRADE_COLORS[0] }}>
{grade}
</span>
<div>
<span className="severity-badge__label">Viêm màng hoạt dịch đ nặng (từ phân đoạn)</span>
<strong>{gradeLabel}</strong>
{severity?.description && (
<p className="severity-panel__desc">{severity.description}</p>
)}
</div>
</div>
) : null}
{inflammationLoading ? (
<div className="severity-panel__block glass">
<span className="severity-panel__eyebrow">Phát hiện viêm</span>
<p className="severity-panel__pending">Đang chờ kết quả phát hiện viêm</p>
</div>
) : inflammation && liveCalibration ? (
<div className="severity-panel__inflam glass">
<span className="severity-panel__eyebrow">Phát hiện viêm</span>
<CalibrationMetricHelp layout="block" />
<strong className="severity-panel__pred">
{INFLAMMATION_LABELS[predictedKey as keyof typeof INFLAMMATION_LABELS]}
</strong>
<CalibrationStateBanner calibration={liveCalibration} />
<p className="severity-panel__risk">{liveCalibration.risk_framing_vi}</p>
<CalibratedDistributionList
calibration={liveCalibration}
entries={INFLAMMATION_DISTRIBUTION_ENTRIES}
predictedKey={predictedKey}
/>
</div>
) : null}
<style>{badgeStyles}</style>
</div>
);
}
const badgeStyles = `
.severity-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.severity-panel__block {
padding: 14px;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 8px;
}
.severity-panel__pending {
margin: 0;
font-size: 0.8125em;
color: var(--color-on-surface-variant);
line-height: 1.45;
}
.severity-panel__desc {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-on-surface-variant);
line-height: 1.4;
font-weight: 400;
}
.severity-badge {
display: flex;
align-items: center;
gap: 14px;
padding: var(--space-md);
border-radius: var(--radius-lg);
border-width: 2px;
border-style: solid;
}
.severity-badge--suppressed {
border-color: var(--color-error);
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.severity-badge--suppressed p {
margin: 0;
font-size: 13px;
color: var(--color-on-surface-variant);
}
.severity-badge__grade {
font-family: var(--font-headline);
font-size: 40px;
font-weight: 700;
line-height: 1;
width: 52px;
text-align: center;
}
.severity-badge__label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-on-surface-variant);
margin-bottom: 2px;
}
.severity-badge strong {
font-size: 18px;
font-family: var(--font-headline);
}
.severity-panel__inflam {
padding: 12px 14px;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
gap: 8px;
}
.severity-panel__eyebrow {
display: block;
font-size: 0.6875em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-on-surface-variant);
}
.severity-panel__pred {
font-size: 0.9375em;
font-family: var(--font-headline);
}
.severity-panel__risk {
margin: 0;
font-size: 0.8125em;
color: var(--color-on-surface-variant);
line-height: 1.45;
}
`;

View File

@@ -0,0 +1,49 @@
interface StatusSummaryCardProps {
label: string;
count: number;
variant: 'urgent' | 'processing' | 'followup';
description: string;
}
const VARIANT_CLASS = {
urgent: 'chip-urgent',
processing: 'chip-processing',
followup: 'chip-followup',
};
export default function StatusSummaryCard({
label,
count,
variant,
description,
}: StatusSummaryCardProps) {
return (
<div className="status-card glass">
<span className={`chip ${VARIANT_CLASS[variant]}`}>{label}</span>
<p className="status-card__count tnum">{count}</p>
<p className="status-card__desc">{description}</p>
<style>{`
.status-card {
border-radius: var(--radius-lg);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: 6px;
}
.status-card__count {
font-family: var(--font-headline);
font-size: 32px;
font-weight: 700;
color: var(--color-secondary);
margin: 4px 0 0;
line-height: 1;
}
.status-card__desc {
margin: 0;
font-size: 13px;
color: var(--color-on-surface-variant);
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,13 @@
import PatientProfileModal from './PatientProfileModal';
import type { NewPatientInput } from '../../lib/patientStore';
interface AddPatientProfileModalProps {
open: boolean;
onClose: () => void;
onSubmit: (input: NewPatientInput) => Promise<void>;
}
/** @deprecated Use PatientProfileModal with mode="add" */
export default function AddPatientProfileModal(props: AddPatientProfileModalProps) {
return <PatientProfileModal mode="add" {...props} />;
}

View File

@@ -0,0 +1,385 @@
import { useEffect, useId, useState } from 'react';
import {
DIAGNOSTIC_FORM_STEP_COUNT,
DIAGNOSTIC_FORM_STEP_TITLES,
type DiagnosticFormDraft,
} from '../../data/diagnosticForm';
import DiagnosticFormStepFields, { diagnosticFormFieldStyles } from '../molecules/DiagnosticFormStepFields';
interface DiagnosisSignOffOverlayProps {
open: boolean;
draft: DiagnosticFormDraft;
doctorName: string;
jointLabel?: string;
facilityName?: string;
onDraftChange: (draft: DiagnosticFormDraft) => void;
onConfirm: () => void;
onCancel: () => void;
}
function stepHint(step: number): string | null {
if (step === 0) return 'Nếu bệnh nhân lần đầu, có thể bỏ qua bước này.';
return null;
}
function DocumentIcon() {
return (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
<path d="M14 2v6h6M12 18v-6M9 15h6" />
</svg>
);
}
export default function DiagnosisSignOffOverlay({
open,
draft,
doctorName,
jointLabel = 'khớp gối',
facilityName = 'Phòng khám MSK — Hệ thống Ghi nhận Lâm sàng',
onDraftChange,
onConfirm,
onCancel,
}: DiagnosisSignOffOverlayProps) {
const titleId = useId();
const [step, setStep] = useState(0);
useEffect(() => {
if (open) setStep(0);
}, [open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onCancel();
if (event.key === 'ArrowRight' && step < DIAGNOSTIC_FORM_STEP_COUNT - 1) {
setStep((s) => s + 1);
}
if (event.key === 'ArrowLeft' && step > 0) {
setStep((s) => s - 1);
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onCancel, step]);
if (!open) return null;
const isFirst = step === 0;
const isLast = step === DIAGNOSTIC_FORM_STEP_COUNT - 1;
const heading = DIAGNOSTIC_FORM_STEP_TITLES[step] ?? '';
const bodyScrollable = step === 2 || step === 4;
return (
<div className="diag-signoff" role="presentation">
<button
type="button"
className="diag-signoff__backdrop"
aria-label="Đóng xác nhận chẩn đoán"
onClick={onCancel}
/>
<div
className="diag-signoff__modal"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="diag-signoff__header">
<span className="diag-signoff__badge" aria-hidden>
<DocumentIcon />
</span>
<h2 id={titleId} className="diag-signoff__title">
Xác nhận kết quả chẩn đoán
</h2>
<p className="diag-signoff__facility">{facilityName}</p>
</header>
<p className="diag-signoff__liability">
Bằng việc thực hiện thao tác này, bạn đng ý người{' '}
<strong className="diag-signoff__liability-em">
chịu trách nhiệm về mặt pháp &amp; y đc
</strong>{' '}
cho kết quả chẩn đoán này!
</p>
<article className="diag-signoff__carousel-card">
<div className="diag-signoff__carousel-head">
<h3 className="diag-signoff__carousel-title">{heading}</h3>
<div className="diag-signoff__carousel-nav">
<button
type="button"
className="diag-signoff__nav-btn"
disabled={isFirst}
onClick={() => setStep((s) => s - 1)}
aria-label="Bước trước"
>
</button>
<button
type="button"
className="diag-signoff__nav-btn"
disabled={isLast}
onClick={() => setStep((s) => s + 1)}
aria-label="Bước sau"
>
</button>
</div>
</div>
{stepHint(step) && <p className="diag-signoff__step-hint">{stepHint(step)}</p>}
<div
className={
bodyScrollable
? 'diag-signoff__carousel-body diag-signoff__carousel-body--scroll'
: 'diag-signoff__carousel-body'
}
>
<DiagnosticFormStepFields
step={step}
draft={draft}
jointLabel={jointLabel}
onDraftChange={onDraftChange}
readOnlySnapshot
/>
</div>
<footer className="diag-signoff__carousel-foot">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
<span>
xác nhận bởi: <strong>{doctorName}</strong>
</span>
</footer>
</article>
<div className="diag-signoff__dots" role="tablist" aria-label="Các bước xác nhận">
{Array.from({ length: DIAGNOSTIC_FORM_STEP_COUNT }, (_, index) => (
<button
key={index}
type="button"
role="tab"
aria-selected={step === index}
aria-label={`Bước ${index + 1}`}
className={step === index ? 'diag-signoff__dot diag-signoff__dot--active' : 'diag-signoff__dot'}
onClick={() => setStep(index)}
/>
))}
</div>
<footer className="diag-signoff__actions">
<button type="button" className="diag-signoff__confirm btn btn-primary" onClick={onConfirm}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
Xác nhận &amp; Cập nhật
</button>
<button type="button" className="diag-signoff__cancel btn btn-ghost" onClick={onCancel}>
Hủy bỏ
</button>
</footer>
</div>
<style>{`${diagnosticFormFieldStyles}${styles}`}</style>
</div>
);
}
const styles = `
.diag-signoff {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.diag-signoff__backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
}
.diag-signoff__modal {
position: relative;
width: min(480px, 100%);
max-height: min(92vh, 860px);
overflow-y: auto;
background: #fff;
border-radius: 16px;
padding: 28px 28px 24px;
display: flex;
flex-direction: column;
gap: 18px;
box-shadow: 0 24px 48px rgba(10, 15, 14, 0.22);
}
.diag-signoff__header {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 8px;
}
.diag-signoff__badge {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 999px;
background: rgba(35, 104, 108, 0.12);
color: var(--color-secondary);
}
.diag-signoff__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #1a2423;
line-height: 1.3;
}
.diag-signoff__facility {
margin: 0;
font-size: 0.8125rem;
color: var(--color-on-surface-variant);
}
.diag-signoff__liability {
margin: 0;
text-align: center;
font-size: 0.8125rem;
line-height: 1.55;
color: var(--color-on-surface);
padding: 0 4px;
}
.diag-signoff__liability-em {
font-weight: 800;
text-decoration: underline;
text-underline-offset: 3px;
color: #0a0f0e;
}
.diag-signoff__carousel-card {
border: 1px solid var(--color-outline-variant);
border-radius: 12px;
background: rgba(245, 250, 249, 0.5);
overflow: hidden;
display: flex;
flex-direction: column;
}
.diag-signoff__carousel-head {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: start;
padding: 14px 14px 8px;
border-bottom: 1px solid rgba(35, 104, 108, 0.1);
}
.diag-signoff__carousel-title {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
line-height: 1.35;
color: var(--color-on-surface);
}
.diag-signoff__carousel-nav {
display: flex;
gap: 6px;
}
.diag-signoff__nav-btn {
width: 32px;
height: 32px;
border: 1px solid var(--color-outline-variant);
border-radius: 999px;
background: #fff;
color: var(--color-secondary);
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.diag-signoff__nav-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.diag-signoff__step-hint {
margin: 0;
padding: 0 14px 6px;
font-size: 0.75rem;
color: var(--color-on-surface-variant);
font-style: italic;
}
.diag-signoff__carousel-body {
padding: 14px;
min-height: 160px;
display: flex;
flex-direction: column;
justify-content: center;
}
.diag-signoff__carousel-body--scroll {
max-height: 280px;
overflow-y: auto;
justify-content: flex-start;
}
.diag-signoff__carousel-foot {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
border-top: 1px solid rgba(35, 104, 108, 0.1);
font-size: 0.6875rem;
color: var(--color-on-surface-variant);
background: rgba(255, 255, 255, 0.85);
}
.diag-signoff__carousel-foot strong {
color: var(--color-on-surface);
font-weight: 600;
}
.diag-signoff__dots {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.diag-signoff__dot {
width: 8px;
height: 8px;
border-radius: 999px;
border: none;
padding: 0;
background: rgba(35, 104, 108, 0.25);
cursor: pointer;
}
.diag-signoff__dot--active {
width: 22px;
background: var(--color-secondary);
}
.diag-signoff__actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.diag-signoff__confirm {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 16px;
font-weight: 600;
}
.diag-signoff__cancel {
width: 100%;
border: 1px solid var(--color-outline-variant);
background: #fff;
color: var(--color-on-surface);
padding: 12px 16px;
}
`;

View File

@@ -0,0 +1,594 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { AngleClassificationResult } from '../../data/angleClassification';
import type { InflammationClassificationResult } from '../../data/inflammationClassification';
import type { SynovitisSeverityResult } from '../../data/synovitisSeverity';
import { angleSupportsThicknessMeasurement } from '../../data/angleClassification';
import { SCAN_FRAMES, type ScanFrame } from '../../data/scanFrames';
import { getScanFramesForPatient } from '../../data/patientScanProfiles.generated';
import { useImageCanvasTools } from '../../hooks/useImageCanvasTools';
import { useDicomFrameRecording } from '../../hooks/useDicomFrameRecording';
import { useSegmentationOverlay } from '../../hooks/useSegmentationOverlay';
import { useSessionLifecycle } from '../../lib/sessionLifecycle';
import type { FrameOverlaySnapshot } from '../../lib/sessionRecordingTypes';
import AnnotationRibbon from '../molecules/AnnotationRibbon';
import ClosedLoopPrompt from '../molecules/ClosedLoopPrompt';
import SegmentationLegendBubble from '../molecules/SegmentationLegendBubble';
import SegmentationOverlay from '../molecules/SegmentationOverlay';
import MlServiceErrorPanel from '../molecules/MlServiceErrorPanel';
interface DiagnosticCanvasProps {
showMask: boolean;
onToggleMask: () => void;
showGradcam?: boolean;
onToggleGradcam?: () => void;
dimmed?: boolean;
frameIndex?: number;
onFrameIndexChange?: (index: number) => void;
onAngleClassificationChange?: (result: AngleClassificationResult | null) => void;
onInflammationClassificationChange?: (result: InflammationClassificationResult | null) => void;
onSynovitisSeverityChange?: (result: SynovitisSeverityResult | null) => void;
onSegmentationLoadingChange?: (loading: boolean) => void;
onRegisterSnapshotCapture?: (capture: () => Promise<string | null>) => void;
onReplayViewport?: (viewport: { showMask: boolean; showGradcam: boolean }) => void;
segmentOverlayReplay?: FrameOverlaySnapshot | null;
patientMrn?: string;
patientId?: string;
scanFrames?: ScanFrame[];
}
export default function DiagnosticCanvas({
showMask,
onToggleMask,
showGradcam = false,
onToggleGradcam,
dimmed,
frameIndex: frameIndexProp,
onFrameIndexChange,
onAngleClassificationChange,
onInflammationClassificationChange,
onSynovitisSeverityChange,
onSegmentationLoadingChange,
onRegisterSnapshotCapture,
onReplayViewport,
segmentOverlayReplay,
patientMrn,
patientId,
scanFrames: scanFramesProp,
}: DiagnosticCanvasProps) {
const activeFrames =
(scanFramesProp && scanFramesProp.length > 0)
? scanFramesProp
: patientId
? getScanFramesForPatient(patientId)
: SCAN_FRAMES;
const framesForCanvas = activeFrames.length > 0 ? activeFrames : SCAN_FRAMES;
const [internalFrameIndex, setInternalFrameIndex] = useState(0);
const frameIndex = frameIndexProp ?? internalFrameIndex;
const {
handleFrameSwitch,
setRecordingContext,
isSingleFrameNavLocked,
activeSegmentLabel,
replayFrameRestore,
clearReplayFrameRestore,
draftToastMessage,
} = useSessionLifecycle();
const [frameNavNotice, setFrameNavNotice] = useState<string | null>(null);
const lockedFrameIndexRef = useRef<number | null>(null);
const frameIndexRef = useRef(frameIndex);
frameIndexRef.current = frameIndex;
const applyFrameIndex = useCallback(
(resolved: number) => {
if (onFrameIndexChange) {
onFrameIndexChange(resolved);
} else {
setInternalFrameIndex(resolved);
}
},
[onFrameIndexChange],
);
const setFrameIndex = useCallback(
(next: number | ((prev: number) => number)) => {
const current = frameIndexRef.current;
const resolved = typeof next === 'function' ? next(current) : next;
if (
isSingleFrameNavLocked &&
lockedFrameIndexRef.current != null &&
resolved !== lockedFrameIndexRef.current
) {
setFrameNavNotice('Đang ghi một khung — chọn Nhiều khung để ghi khi đổi khung');
return;
}
applyFrameIndex(resolved);
},
[applyFrameIndex, isSingleFrameNavLocked],
);
useEffect(() => {
if (isSingleFrameNavLocked) {
if (lockedFrameIndexRef.current === null) {
lockedFrameIndexRef.current = frameIndex;
}
return;
}
lockedFrameIndexRef.current = null;
}, [frameIndex, isSingleFrameNavLocked]);
useEffect(() => {
if (!isSingleFrameNavLocked || lockedFrameIndexRef.current === null) return;
if (frameIndex !== lockedFrameIndexRef.current) {
applyFrameIndex(lockedFrameIndexRef.current);
}
}, [applyFrameIndex, frameIndex, isSingleFrameNavLocked]);
useEffect(() => {
if (!frameNavNotice) return;
const timer = window.setTimeout(() => setFrameNavNotice(null), 3500);
return () => window.clearTimeout(timer);
}, [frameNavNotice]);
const frame = framesForCanvas[frameIndex] ?? framesForCanvas[0]!;
const frameLabel = `Khung ${frameIndex + 1} / ${framesForCanvas.length} · ${frame.label}`;
const frameRef = useMemo(
() => ({
frameId: frame.id,
frameIndex,
frameLabel,
imageSrc: frame.src,
}),
[frame.id, frameIndex, frameLabel, frame.src],
);
const lockFrameNav = isSingleFrameNavLocked;
const { overlaySrc, interpretation, angleClassification, inflammationClassification, synovitisSeverity, isLoading, isSegmentationLoading, error, source, retry } =
useSegmentationOverlay(frame.id, frame.src, framesForCanvas, patientMrn);
useEffect(() => {
applyFrameIndex(0);
}, [patientId, framesForCanvas.length, applyFrameIndex]);
useEffect(() => {
onAngleClassificationChange?.(angleClassification);
}, [angleClassification, onAngleClassificationChange]);
useEffect(() => {
onInflammationClassificationChange?.(inflammationClassification);
}, [inflammationClassification, onInflammationClassificationChange]);
useEffect(() => {
onSynovitisSeverityChange?.(synovitisSeverity);
}, [synovitisSeverity, onSynovitisSeverityChange]);
useEffect(() => {
onSegmentationLoadingChange?.(isSegmentationLoading);
}, [isSegmentationLoading, onSegmentationLoadingChange]);
const goPrev = () =>
setFrameIndex((current) => (current > 0 ? current - 1 : framesForCanvas.length - 1));
const goNext = () =>
setFrameIndex((current) => (current < framesForCanvas.length - 1 ? current + 1 : 0));
const thicknessMm = interpretation?.measurement?.thickness_mm;
const showMeasurementLegendNotes = showMask && interpretation?.angleType === 'sup';
const showThicknessHud =
showMask &&
angleClassification != null &&
angleSupportsThicknessMeasurement(angleClassification.class) &&
thicknessMm != null;
const {
canvasRef,
viewportRef,
stageRef,
tool,
setTool,
inkColor,
setInkColor,
zoom,
zoomIn,
zoomOut,
resetView,
clearAnnotations,
closedLoopPrompt,
closedLoopPromptViewportAnchor,
confirmClosedLoopPrompt,
dismissClosedLoopPrompt,
onPointerDown,
onPointerMove,
onPointerUp,
viewportCursor,
hasAnnotations,
captureSnapshot,
importOverlayForFrame,
importAllOverlays,
getViewportState,
} = useImageCanvasTools(frame.id);
useEffect(() => {
const pan = getViewportState();
setRecordingContext(frameRef, {
zoom: pan.zoom,
panX: pan.panX,
panY: pan.panY,
showMask,
showGradcam,
});
}, [frameRef, getViewportState, setRecordingContext, showGradcam, showMask, zoom]);
useLayoutEffect(() => {
if (!segmentOverlayReplay) return;
if (segmentOverlayReplay.frameId !== frame.id) return;
importOverlayForFrame(segmentOverlayReplay);
}, [frame.id, importOverlayForFrame, segmentOverlayReplay]);
useEffect(() => {
if (!segmentOverlayReplay) return;
if (segmentOverlayReplay.frameId !== frame.id) return;
importOverlayForFrame(segmentOverlayReplay);
}, [frame.id, importOverlayForFrame, segmentOverlayReplay]);
useEffect(() => {
if (!replayFrameRestore) return;
if (!isSingleFrameNavLocked && replayFrameRestore.frameIndex !== frameIndex) {
setFrameIndex(replayFrameRestore.frameIndex);
}
importAllOverlays(replayFrameRestore.overlaysByFrameId);
onReplayViewport?.({
showMask: replayFrameRestore.viewport.showMask,
showGradcam: replayFrameRestore.viewport.showGradcam,
});
clearReplayFrameRestore();
}, [
clearReplayFrameRestore,
frameIndex,
importAllOverlays,
isSingleFrameNavLocked,
onReplayViewport,
replayFrameRestore,
setFrameIndex,
]);
useEffect(() => {
onRegisterSnapshotCapture?.(captureSnapshot);
}, [captureSnapshot, onRegisterSnapshotCapture]);
const dicomRecording = useDicomFrameRecording({
viewportRef,
frameRef,
frameCount: framesForCanvas.length,
patientId,
tool,
showMask,
showGradcam,
zoom,
onMaskToggle: onToggleMask,
onToolChange: setTool,
onZoomIn: zoomIn,
onZoomOut: zoomOut,
onResetView: resetView,
onClearAnnotations: clearAnnotations,
onFrameSwitch: handleFrameSwitch,
getViewportState,
});
const panHandlers =
tool === 'pan'
? { onPointerDown, onPointerMove, onPointerUp }
: { onPointerDown: undefined, onPointerMove: undefined, onPointerUp: undefined };
const drawHandlers =
tool === 'draw' || tool === 'eraser'
? { onPointerDown, onPointerMove, onPointerUp }
: { onPointerDown: undefined, onPointerMove: undefined, onPointerUp: undefined };
return (
<div className={`diagnostic-canvas glass ${dimmed ? 'diagnostic-canvas--dimmed' : ''}`}>
<div className="diagnostic-canvas__toolbar">
<div className="diagnostic-canvas__frame-nav">
<button
type="button"
className="diagnostic-canvas__nav-btn"
onClick={goPrev}
disabled={lockFrameNav}
aria-label="Khung trước"
>
</button>
<span className="diagnostic-canvas__label">{frameLabel}</span>
<button
type="button"
className="diagnostic-canvas__nav-btn"
onClick={goNext}
disabled={lockFrameNav}
aria-label="Khung sau"
>
</button>
</div>
<div className="diagnostic-canvas__toggles">
<label className="diagnostic-canvas__toggle">
<input type="checkbox" checked={showMask} onChange={dicomRecording.handleMaskToggle} />
Kết quả phân đoạn
</label>
{onToggleGradcam && (
<label className="diagnostic-canvas__toggle">
<input type="checkbox" checked={showGradcam} onChange={onToggleGradcam} />
GradCAM
</label>
)}
</div>
</div>
<div
ref={viewportRef}
className="diagnostic-canvas__viewport"
style={{ cursor: viewportCursor }}
>
{activeSegmentLabel && (
<span className="diagnostic-canvas__segment-chip">{activeSegmentLabel}</span>
)}
{draftToastMessage && (
<div className="diagnostic-canvas__draft-toast" role="status">{draftToastMessage}</div>
)}
{frameNavNotice && (
<div className="diagnostic-canvas__draft-toast" role="status">{frameNavNotice}</div>
)}
<div
ref={stageRef}
className="diagnostic-canvas__stage"
{...panHandlers}
>
<img
src={frame.src}
alt={`Khung siêu âm — ${frame.label}`}
className="diagnostic-canvas__frame"
draggable={false}
/>
{showGradcam && (
<img
src="/assets/gradcam-overlay.svg"
alt=""
aria-hidden
className="diagnostic-canvas__overlay diagnostic-canvas__overlay--gradcam"
draggable={false}
/>
)}
{showMask && (
<SegmentationOverlay overlaySrc={overlaySrc} isLoading={isLoading} />
)}
{showMask && error && !isLoading && (
<MlServiceErrorPanel
error={error}
frameLabel={frameLabel}
onRetry={source === 'backend' ? retry : undefined}
/>
)}
<canvas
ref={canvasRef}
className="diagnostic-canvas__annotation-canvas"
aria-label="Lớp chú thích lâm sàng"
style={{
pointerEvents: tool === 'draw' || tool === 'eraser' ? 'auto' : 'none',
}}
{...drawHandlers}
/>
</div>
{closedLoopPrompt && closedLoopPromptViewportAnchor && (
<ClosedLoopPrompt
anchor={closedLoopPromptViewportAnchor}
onConfirm={confirmClosedLoopPrompt}
onDismiss={dismissClosedLoopPrompt}
/>
)}
<AnnotationRibbon
containerRef={viewportRef}
tool={tool}
onToolChange={dicomRecording.handleToolChange}
inkColor={inkColor}
onInkColorChange={setInkColor}
zoom={zoom}
onZoomIn={dicomRecording.handleZoomIn}
onZoomOut={dicomRecording.handleZoomOut}
onResetView={dicomRecording.handleResetView}
onClearAnnotations={dicomRecording.handleClearAnnotations}
hasAnnotations={hasAnnotations}
disabled={dimmed}
/>
<SegmentationLegendBubble
visible={showMask && !!interpretation}
containerRef={viewportRef}
angleType={interpretation?.angleType ?? 'sup'}
entries={interpretation?.legendEntries}
showMeasurementNote={showMeasurementLegendNotes}
/>
{showThicknessHud && (
<div className="diagnostic-canvas__hud">
<span className="tnum">{thicknessMm.toFixed(2)} mm</span>
<span>Đ dày (dịch + màng hoạt dịch)</span>
</div>
)}
</div>
<style>{`
.diagnostic-canvas {
flex: 1;
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
min-height: 400px;
overflow: hidden;
}
.diagnostic-canvas--dimmed {
opacity: 0.45;
pointer-events: none;
}
.diagnostic-canvas__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px var(--space-md);
border-bottom: 1px solid var(--glass-border);
flex-wrap: wrap;
gap: 8px;
}
.diagnostic-canvas__frame-nav {
display: flex;
align-items: center;
gap: 8px;
}
.diagnostic-canvas__nav-btn {
width: 28px;
height: 28px;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.6);
color: var(--color-secondary);
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.diagnostic-canvas__nav-btn:hover {
background: rgba(255, 255, 255, 0.9);
}
.diagnostic-canvas__nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.diagnostic-canvas__nav-btn:disabled:hover {
background: rgba(255, 255, 255, 0.6);
}
.diagnostic-canvas__label {
font-size: 13px;
font-weight: 600;
color: var(--color-secondary);
}
.diagnostic-canvas__toggles {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.diagnostic-canvas__toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
cursor: pointer;
}
.diagnostic-canvas__viewport {
position: relative;
flex: 1;
background: #0a0f0e;
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
overflow: hidden;
touch-action: none;
user-select: none;
}
.diagnostic-canvas__segment-chip {
position: absolute;
top: 10px;
left: 10px;
z-index: 60;
padding: 4px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
color: #ecfdf5;
background: rgba(220, 38, 38, 0.85);
border: 1px solid rgba(254, 202, 202, 0.35);
pointer-events: none;
}
.diagnostic-canvas__draft-toast {
position: absolute;
top: 44px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
max-width: min(90%, 420px);
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
color: #e2e8f0;
background: rgba(15, 23, 42, 0.92);
border: 1px solid rgba(118, 200, 177, 0.35);
pointer-events: none;
}
.diagnostic-canvas__stage {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center center;
will-change: transform;
backface-visibility: hidden;
}
.diagnostic-canvas__frame {
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
position: relative;
z-index: 1;
}
.diagnostic-canvas__annotation-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
touch-action: none;
z-index: 500;
}
.diagnostic-canvas__overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.diagnostic-canvas__overlay--gradcam {
mix-blend-mode: screen;
opacity: 0.65;
}
.diagnostic-canvas__overlay--mask {
opacity: 1;
mix-blend-mode: normal;
z-index: 10;
}
.diagnostic-canvas__hud {
position: absolute;
bottom: 72px;
left: 12px;
background: rgba(0, 0, 0, 0.65);
color: #fff;
padding: 8px 12px;
border-radius: var(--radius-md);
font-size: 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
.diagnostic-canvas__hud span:first-child {
font-size: 18px;
font-weight: 600;
}
.diagnostic-canvas__hud span:last-child {
font-size: 11px;
line-height: 1.3;
max-width: 11rem;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,613 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import {
ACCEPTED_SCAN_FILE_INPUT,
isAcceptedScanFile,
JOINT_OPTIONS,
PATIENT_STATUS_OPTIONS,
type PatientStatusOption,
} from '../../data/patientConstants';
import type { PatientCase, PatientStatus } from '../../data/mockData';
import type { NewPatientInput, UpdatePatientInput } from '../../lib/patientStore';
import type { ScanAssetMeta } from '../../lib/patientScanAssetsStore';
import { usePatientStore } from '../../lib/patientStore';
import { PATIENT_PROFILE_MODAL_STYLES } from './patientProfileModalStyles';
interface StagedNewFile {
id: string;
file: File;
progress: number;
status: 'queued' | 'uploading' | 'done';
}
interface ExistingAssetState {
meta: ScanAssetMeta;
markedForDeletion: boolean;
}
interface FormState {
name: string;
mrn: string;
joint: string;
status: PatientStatus;
}
type PatientProfileModalProps =
| {
mode: 'add';
open: boolean;
onClose: () => void;
onSubmit: (input: NewPatientInput) => Promise<void>;
}
| {
mode: 'edit';
patient: PatientCase;
open: boolean;
onClose: () => void;
onSubmit: (input: UpdatePatientInput) => Promise<void>;
};
function formFromPatient(patient: PatientCase): FormState {
return {
name: patient.name,
mrn: patient.mrn,
joint: patient.joint,
status: patient.status,
};
}
function PlusUserIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M19 8v6M22 11h-6" />
</svg>
);
}
function SaveIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
}
function TrashIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M3 6h18M8 6V4h8v2M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
}
function UploadCloudIcon() {
return (
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
<path d="M12 16V4m0 0 4 4m-4-4-4 4" />
<path d="M4 17v1a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1" />
</svg>
);
}
function FileIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z" />
<path d="M14 2v6h6" />
</svg>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function simulateUpload(id: string, onProgress: (id: string, progress: number) => void): Promise<void> {
return new Promise((resolve) => {
let progress = 0;
const tick = () => {
progress += 12 + Math.random() * 18;
if (progress >= 100) {
onProgress(id, 100);
resolve();
return;
}
onProgress(id, Math.round(progress));
window.setTimeout(tick, 120 + Math.random() * 80);
};
tick();
});
}
export default function PatientProfileModal(props: PatientProfileModalProps) {
const { open, onClose, mode } = props;
const patient = mode === 'edit' ? props.patient : null;
const { getPatientScanAssets } = usePatientStore();
const titleId = useId();
const fileInputRef = useRef<HTMLInputElement>(null);
const [form, setForm] = useState<FormState>(
mode === 'edit' && patient ? formFromPatient(patient) : { name: '', mrn: '', joint: JOINT_OPTIONS[0], status: 'processing' },
);
const [initialForm, setInitialForm] = useState<FormState>(form);
const [existingAssets, setExistingAssets] = useState<ExistingAssetState[]>([]);
const [newFiles, setNewFiles] = useState<StagedNewFile[]>([]);
const [dragOver, setDragOver] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [fileError, setFileError] = useState<string | null>(null);
const [assetsLoading, setAssetsLoading] = useState(false);
const reset = useCallback(() => {
const empty: FormState = { name: '', mrn: '', joint: JOINT_OPTIONS[0], status: 'processing' };
setForm(mode === 'edit' && patient ? formFromPatient(patient) : empty);
setInitialForm(mode === 'edit' && patient ? formFromPatient(patient) : empty);
setExistingAssets([]);
setNewFiles([]);
setDragOver(false);
setSubmitting(false);
setFileError(null);
setAssetsLoading(false);
}, [mode, patient]);
useEffect(() => {
if (!open) {
reset();
return;
}
if (mode === 'edit' && patient) {
const hydrated = formFromPatient(patient);
setForm(hydrated);
setInitialForm(hydrated);
setAssetsLoading(true);
void getPatientScanAssets(patient.id)
.then((assets) => {
const rows = assets.map((meta) => ({ meta, markedForDeletion: false }));
setExistingAssets(rows);
})
.finally(() => setAssetsLoading(false));
}
}, [open, mode, patient, getPatientScanAssets, reset]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !submitting) onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [open, onClose, submitting]);
const requiredFilled =
form.name.trim().length > 0 &&
form.mrn.trim().length > 0 &&
form.joint.length > 0 &&
Boolean(form.status);
const formChanged =
form.name.trim() !== initialForm.name.trim() ||
form.mrn.trim() !== initialForm.mrn.trim() ||
form.joint !== initialForm.joint ||
form.status !== initialForm.status;
const assetsChanged =
existingAssets.some((asset) => asset.markedForDeletion) || newFiles.length > 0;
const activeAssetCount =
existingAssets.filter((asset) => !asset.markedForDeletion).length + newFiles.length;
const hasChanges = mode === 'edit' ? formChanged || assetsChanged : true;
const assetStateValid = !assetsChanged || activeAssetCount > 0;
const canSubmitAdd = requiredFilled && newFiles.length > 0 && !submitting;
const canSubmit =
mode === 'add'
? canSubmitAdd
: requiredFilled && hasChanges && assetStateValid && !submitting && !assetsLoading;
const addFiles = (files: FileList | File[]) => {
const incoming = Array.from(files);
const valid = incoming.filter(isAcceptedScanFile);
const invalidCount = incoming.length - valid.length;
if (invalidCount > 0) {
setFileError('Chỉ chấp nhận ảnh PNG, JPEG hoặc DICOM (.dcm).');
} else {
setFileError(null);
}
if (valid.length === 0) return;
const existingNames = new Set([
...existingAssets.filter((a) => !a.markedForDeletion).map((a) => a.meta.fileName),
...newFiles.map((item) => item.file.name),
]);
const next = valid
.filter((file) => !existingNames.has(file.name))
.map((file) => ({
id: crypto.randomUUID(),
file,
progress: 0,
status: 'queued' as const,
}));
if (next.length > 0) setNewFiles((current) => [...current, ...next]);
};
const removeNewFile = (id: string) => {
if (submitting) return;
setNewFiles((current) => current.filter((item) => item.id !== id));
};
const toggleDeleteExisting = (id: string) => {
if (submitting) return;
setExistingAssets((current) =>
current.map((asset) =>
asset.meta.id === id ? { ...asset, markedForDeletion: !asset.markedForDeletion } : asset,
),
);
};
const handleSubmit = async () => {
if (!canSubmit) return;
setSubmitting(true);
const uploadTargets = mode === 'add' ? newFiles : newFiles.filter((f) => f.status !== 'done');
setNewFiles((current) =>
current.map((item) => ({ ...item, status: 'uploading' as const, progress: 0 })),
);
const updateProgress = (id: string, progress: number) => {
setNewFiles((current) =>
current.map((item) =>
item.id === id ? { ...item, progress, status: progress >= 100 ? 'done' : 'uploading' } : item,
),
);
};
try {
await Promise.all(uploadTargets.map((item) => simulateUpload(item.id, updateProgress)));
if (mode === 'add') {
await props.onSubmit({
name: form.name,
mrn: form.mrn,
joint: form.joint,
status: form.status,
scanFiles: newFiles.map((item) => item.file),
});
} else if (patient) {
await props.onSubmit({
patientId: patient.id,
name: form.name,
mrn: form.mrn,
joint: form.joint,
status: form.status,
removedAssetIds: existingAssets.filter((a) => a.markedForDeletion).map((a) => a.meta.id),
newScanFiles: newFiles.map((item) => item.file),
});
}
onClose();
} finally {
setSubmitting(false);
}
};
const modalTitle = useMemo(() => {
if (mode === 'edit' && patient) return `Chỉnh sửa hồ sơ: ${patient.name}`;
return 'Thêm hồ sơ bệnh nhân mới';
}, [mode, patient]);
if (!open) return null;
const showAssetInventory = mode === 'edit';
return (
<div className="patient-profile-modal" role="presentation">
<button
type="button"
className="patient-profile-modal__backdrop"
aria-label={mode === 'edit' ? 'Đóng biểu mẫu chỉnh sửa' : 'Đóng biểu mẫu thêm bệnh nhân'}
onClick={() => !submitting && onClose()}
disabled={submitting}
/>
<div className="patient-profile-modal__sheet" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<header className="patient-profile-modal__header">
<h2 id={titleId} className="patient-profile-modal__title">
{modalTitle}
</h2>
<button
type="button"
className="patient-profile-modal__close icon-btn"
aria-label="Đóng"
onClick={onClose}
disabled={submitting}
>
<CloseIcon />
</button>
</header>
<div className="patient-profile-modal__body">
<div className="patient-profile-modal__grid">
<label className="patient-profile-modal__field patient-profile-modal__field--full">
<span className="patient-profile-modal__label">Họ tên bệnh nhân</span>
<input
type="text"
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder="Ví dụ: Nguyễn Văn An"
disabled={submitting}
/>
</label>
<label className="patient-profile-modal__field">
<span className="patient-profile-modal__label"> bệnh nhân (MRN)</span>
<input
type="text"
className="tnum"
value={form.mrn}
onChange={(event) => setForm((prev) => ({ ...prev, mrn: event.target.value }))}
placeholder="BN-2024-XXXX"
disabled={submitting}
/>
</label>
<label className="patient-profile-modal__field">
<span className="patient-profile-modal__label">Vị trí khớp chụp</span>
<select
value={form.joint}
onChange={(event) => setForm((prev) => ({ ...prev, joint: event.target.value }))}
disabled={submitting}
>
{JOINT_OPTIONS.map((joint) => (
<option key={joint} value={joint}>
{joint}
</option>
))}
</select>
</label>
<div className="patient-profile-modal__field patient-profile-modal__field--full">
<span className="patient-profile-modal__label">Trạng thái tiếp nhận</span>
<div className="patient-profile-modal__status-pills" role="radiogroup" aria-label="Trạng thái tiếp nhận">
{PATIENT_STATUS_OPTIONS.map((option: PatientStatusOption) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={form.status === option.value}
className={`patient-profile-modal__status-pill chip ${option.chipClass} ${
form.status === option.value ? 'patient-profile-modal__status-pill--active' : ''
}`}
onClick={() => setForm((prev) => ({ ...prev, status: option.value }))}
disabled={submitting}
>
{option.label}
</button>
))}
</div>
</div>
</div>
<div
className={`patient-profile-modal__dropzone ${dragOver ? 'patient-profile-modal__dropzone--active' : ''}`}
onDragEnter={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragOver={(event) => {
event.preventDefault();
setDragOver(true);
}}
onDragLeave={(event) => {
event.preventDefault();
if (event.currentTarget.contains(event.relatedTarget as Node)) return;
setDragOver(false);
}}
onDrop={(event) => {
event.preventDefault();
setDragOver(false);
if (submitting) return;
addFiles(event.dataTransfer.files);
}}
>
<UploadCloudIcon />
<p className="patient-profile-modal__dropzone-primary">
{mode === 'edit'
? 'Kéo và thả tệp DICOM/ảnh mới tại đây để tải lên'
: 'Kéo và thả ảnh siêu âm hoặc DICOM tại đây'}
</p>
<p className="patient-profile-modal__dropzone-secondary">
hoặc{' '}
<button
type="button"
className="patient-profile-modal__browse-link"
onClick={() => fileInputRef.current?.click()}
disabled={submitting}
>
Chọn tệp từ máy tính
</button>
</p>
<p className="patient-profile-modal__dropzone-caption">
Hỗ trợ PNG, JPEG DICOM (.dcm). Tự đng trích xuất mốc thời gian quét.
</p>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_SCAN_FILE_INPUT}
multiple
className="sr-only"
onChange={(event) => {
if (event.target.files) addFiles(event.target.files);
event.target.value = '';
}}
/>
</div>
{fileError && <p className="patient-profile-modal__file-error">{fileError}</p>}
{mode === 'add' && newFiles.length > 0 && (
<ul className="patient-profile-modal__file-list" aria-label="Danh sách tệp quét">
{newFiles.map((item) => (
<li key={item.id} className="patient-profile-modal__file-item">
<span className="patient-profile-modal__file-icon" aria-hidden>
<FileIcon />
</span>
<div className="patient-profile-modal__file-meta">
<span className="patient-profile-modal__file-name">{item.file.name}</span>
<span className="patient-profile-modal__file-size tnum">{formatFileSize(item.file.size)}</span>
{(item.status === 'uploading' || item.status === 'done') && (
<div className="patient-profile-modal__progress" aria-hidden>
<div className="patient-profile-modal__progress-bar" style={{ width: `${item.progress}%` }} />
</div>
)}
</div>
<button
type="button"
className="patient-profile-modal__file-remove icon-btn"
aria-label={`Xóa ${item.file.name}`}
onClick={() => removeNewFile(item.id)}
disabled={submitting}
>
<CloseIcon />
</button>
</li>
))}
</ul>
)}
{showAssetInventory && (
<section className="patient-profile-modal__inventory">
<h3 className="patient-profile-modal__inventory-title">Danh sách tệp dữ liệu hiện tại</h3>
{assetsLoading ? (
<p className="patient-profile-modal__inventory-loading">Đang tải danh sách tệp</p>
) : existingAssets.length === 0 ? (
<p className="patient-profile-modal__inventory-empty">Chưa tệp dữ liệu đưc lưu.</p>
) : (
<ul className="patient-profile-modal__file-list">
{existingAssets.map((asset) => (
<li
key={asset.meta.id}
className={`patient-profile-modal__file-item ${
asset.markedForDeletion ? 'patient-profile-modal__file-item--deleted' : ''
}`}
>
<span className="patient-profile-modal__file-icon" aria-hidden>
<FileIcon />
</span>
<div className="patient-profile-modal__file-meta">
<span className="patient-profile-modal__file-name">{asset.meta.fileName}</span>
<span className="patient-profile-modal__file-size tnum">
{formatFileSize(asset.meta.size)}
{asset.markedForDeletion && (
<span className="patient-profile-modal__delete-badge"> · Sẽ xóa</span>
)}
</span>
</div>
<button
type="button"
className={
asset.markedForDeletion
? 'patient-profile-modal__undo-link'
: 'patient-profile-modal__trash-btn icon-btn'
}
aria-label={
asset.markedForDeletion
? `Hoàn tác xóa ${asset.meta.fileName}`
: `Đánh dấu xóa ${asset.meta.fileName}`
}
onClick={() => toggleDeleteExisting(asset.meta.id)}
disabled={submitting}
>
{asset.markedForDeletion ? 'Hoàn tác' : <TrashIcon />}
</button>
</li>
))}
</ul>
)}
</section>
)}
{mode === 'edit' && newFiles.length > 0 && (
<section className="patient-profile-modal__inventory">
<h3 className="patient-profile-modal__inventory-title">Tệp tải lên mới</h3>
<ul className="patient-profile-modal__file-list">
{newFiles.map((item) => (
<li key={item.id} className="patient-profile-modal__file-item patient-profile-modal__file-item--new">
<span className="patient-profile-modal__file-icon" aria-hidden>
<FileIcon />
</span>
<div className="patient-profile-modal__file-meta">
<span className="patient-profile-modal__file-name">
{item.file.name}
<span className="patient-profile-modal__new-badge">Tải lên mới</span>
</span>
<span className="patient-profile-modal__file-size tnum">{formatFileSize(item.file.size)}</span>
{item.status === 'uploading' && (
<div className="patient-profile-modal__progress" aria-hidden>
<div className="patient-profile-modal__progress-bar" style={{ width: `${item.progress}%` }} />
</div>
)}
</div>
<button
type="button"
className="patient-profile-modal__file-remove icon-btn"
aria-label={`Xóa ${item.file.name}`}
onClick={() => removeNewFile(item.id)}
disabled={submitting}
>
<CloseIcon />
</button>
</li>
))}
</ul>
</section>
)}
</div>
<footer className="patient-profile-modal__footer">
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={submitting}>
Hủy bỏ
</button>
<button type="button" className="btn btn-primary" disabled={!canSubmit} onClick={() => void handleSubmit()}>
{submitting ? (
<>
<span className="patient-profile-modal__spinner" aria-hidden />
{mode === 'edit' ? 'Đang lưu…' : 'Đang tải lên…'}
</>
) : mode === 'edit' ? (
<>
<SaveIcon />
Lưu thay đi
</>
) : (
<>
<PlusUserIcon />
Tạo hồ &amp; tải lên
</>
)}
</button>
</footer>
</div>
<style>{PATIENT_PROFILE_MODAL_STYLES}</style>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import type { PointerTraceSample } from '../../data/diagnosticSessionReplay';
import type { ReplayViewportState } from '../../hooks/useDiagnosticSessionReplay';
interface ReplaySandboxOverlayProps {
viewport: ReplayViewportState;
}
function PointerTrail({ points }: { points: PointerTraceSample[] }) {
if (points.length < 2) return null;
const segmentCount = points.length - 1;
return (
<>
{points.slice(1).map((point, index) => {
const previous = points[index];
const progress = segmentCount <= 1 ? 1 : (index + 1) / segmentCount;
const opacity = 0.06 + progress * 0.74;
const width = 0.12 + progress * 0.5;
return (
<line
key={`${previous.elapsedSeconds}-${point.elapsedSeconds}-${index}`}
x1={previous.x}
y1={previous.y}
x2={point.x}
y2={point.y}
stroke="#ff007f"
strokeOpacity={opacity}
strokeWidth={width}
strokeLinecap="round"
/>
);
})}
</>
);
}
export default function ReplaySandboxOverlay({ viewport }: ReplaySandboxOverlayProps) {
if (!viewport.active) return null;
const useTraceMotion = viewport.showPointerTrace && viewport.trailPoints.length > 0;
const penColor = viewport.penColor ?? '#ff007f';
const ghostSize = viewport.isOverlayEditing ? 16 : 14;
return (
<div className="replay-sandbox" aria-hidden={false}>
<div className="replay-sandbox__shield" aria-label="Chế độ xem lại — tương tác bị khóa" />
{viewport.showPointerTrace && (
<svg
className="replay-sandbox__trail"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden
>
<PointerTrail points={viewport.trailPoints} />
</svg>
)}
<div
className={`replay-sandbox__ghost ${viewport.ripple ? 'replay-sandbox__ghost--ripple' : ''}${viewport.isOverlayEditing ? ' replay-sandbox__ghost--drawing' : ''}`}
style={{
left: `${viewport.ghostX}%`,
top: `${viewport.ghostY}%`,
transition: useTraceMotion ? 'left 0.08s linear, top 0.08s linear' : 'left 0.45s ease, top 0.45s ease',
['--replay-pen-color' as string]: penColor,
}}
>
<span
className="replay-sandbox__ghost-core"
style={{
width: ghostSize,
height: ghostSize,
background: penColor,
boxShadow: `0 0 0 4px ${penColor}40, 0 0 18px ${penColor}a8`,
}}
/>
{viewport.ripple && (
<span className="replay-sandbox__ghost-ring" style={{ borderColor: `${penColor}bf` }} />
)}
</div>
{viewport.event && (
<div className="replay-sandbox__event-chip">
<strong>{viewport.event.title}</strong>
<span>{viewport.event.detail}</span>
</div>
)}
<style>{`
.replay-sandbox {
position: absolute;
inset: 0;
z-index: 40;
pointer-events: none;
}
.replay-sandbox__shield {
position: absolute;
inset: 0;
pointer-events: auto;
background: rgba(15, 23, 42, 0.04);
cursor: not-allowed;
}
.replay-sandbox__trail {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
overflow: visible;
pointer-events: none;
}
.replay-sandbox__ghost {
position: absolute;
transform: translate(-50%, -50%);
z-index: 2;
}
.replay-sandbox__ghost--drawing .replay-sandbox__ghost-core {
animation: replay-pen-pulse 0.6s ease-in-out infinite;
}
@keyframes replay-pen-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.replay-sandbox__ghost-core {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ff007f;
box-shadow: 0 0 0 4px rgba(255, 0, 127, 0.25), 0 0 18px rgba(255, 0, 127, 0.65);
}
.replay-sandbox__ghost-ring {
position: absolute;
inset: -10px;
border-radius: 50%;
border: 2px solid rgba(255, 0, 127, 0.75);
animation: replay-ghost-ring 0.65s ease-out forwards;
}
@keyframes replay-ghost-ring {
from {
transform: scale(0.6);
opacity: 0.95;
}
to {
transform: scale(2.2);
opacity: 0;
}
}
.replay-sandbox__event-chip {
position: absolute;
left: 12px;
bottom: 12px;
max-width: min(360px, calc(100% - 24px));
padding: 10px 12px;
border-radius: 10px;
background: rgba(30, 41, 59, 0.92);
color: #f8fafc;
font-size: 12px;
line-height: 1.4;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
z-index: 3;
}
.replay-sandbox__event-chip strong {
color: #76c8b1;
font-size: 12px;
}
.replay-sandbox__event-chip span {
color: #e2e8f0;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,960 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
eventKindColor,
formatRecordedTime,
formatReplayClock,
tooltipForEvent,
} from '../../data/diagnosticSessionReplay';
import {
useDiagnosticSessionReplay,
type PlaybackSpeed,
type ReplayViewportState,
} from '../../hooks/useDiagnosticSessionReplay';
import {
applyEventEdits,
loadEventEdits,
loadSessionDescription,
loadShowPointerTracePreference,
loadStepNotes,
saveEventEdit,
saveSessionDescription,
saveShowPointerTracePreference,
saveStepNote,
} from '../../lib/sessionReplayNotesStore';
import { useSessionLifecycle } from '../../lib/sessionLifecycle';
import type { FrameOverlaySnapshot, SegmentViewportState } from '../../lib/sessionRecordingTypes';
import SessionLifecycleToolbar from '../molecules/SessionLifecycleToolbar';
import SessionCancelRecordDialog from '../molecules/SessionCancelRecordDialog';
import SessionNewSessionDialog from '../molecules/SessionNewSessionDialog';
import SessionResetConfirmDialog from '../molecules/SessionResetConfirmDialog';
import SavedSessionsDialog from '../molecules/SavedSessionsDialog';
import RecordingModeSelector from '../molecules/RecordingModeSelector';
import RecordedSessionActionsList from '../molecules/RecordedSessionActionsList';
interface ReviewDiagnosticSessionPanelProps {
patientId: string;
patientName: string;
patientMrn: string;
onViewportChange?: (state: ReplayViewportState) => void;
onFrameRestore?: (
frameId: string,
frameIndex: number,
segmentOverlay: FrameOverlaySnapshot,
) => void;
onReplaySegmentViewport?: (viewport: SegmentViewportState) => void;
}
function PlayIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M8 5v14l11-7z" />
</svg>
);
}
function PauseIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
);
}
function SkipBackIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M11 19V5l-7 7 7 7z" />
<path d="M18 19V5l-7 7 7 7z" />
</svg>
);
}
function SkipForwardIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M13 5v14l7-7-7-7z" />
<path d="M6 5v14l7-7-7-7z" />
</svg>
);
}
export default function ReviewDiagnosticSessionPanel({
patientId,
onViewportChange,
onFrameRestore,
onReplaySegmentViewport,
}: ReviewDiagnosticSessionPanelProps) {
const lifecycle = useSessionLifecycle();
const sessionLog = lifecycle.sessionLog;
const isSessionEnded = lifecycle.status === 'ended';
const isLiveCapture = lifecycle.status === 'recording' || lifecycle.status === 'paused';
const [eventEdits, setEventEdits] = useState(() => loadEventEdits(patientId));
const [showPointerTrace, setShowPointerTrace] = useState(() => loadShowPointerTracePreference(patientId));
const resolvedLog = useMemo(
() => ({
...sessionLog,
events: sessionLog.events.map((event) => applyEventEdits(event, eventEdits)),
}),
[sessionLog, eventEdits],
);
const replay = useDiagnosticSessionReplay({
log: resolvedLog,
showPointerTrace,
onViewportChange: isLiveCapture ? undefined : onViewportChange,
onFrameRestore:
isLiveCapture || lifecycle.status !== 'ended'
? undefined
: (frameId, frameIndex, segmentOverlay) =>
onFrameRestore?.(frameId, frameIndex, segmentOverlay),
onReplaySegmentViewport: isLiveCapture ? undefined : onReplaySegmentViewport,
});
const timelineRef = useRef<HTMLDivElement>(null);
const prevLifecycleStatusRef = useRef<string | null>(null);
const [isScrubbing, setIsScrubbing] = useState(false);
const [hoveredEventId, setHoveredEventId] = useState<string | null>(null);
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
const [sessionDescription, setSessionDescription] = useState(() => loadSessionDescription(patientId));
const [stepNotes, setStepNotes] = useState<Record<string, string>>(() => loadStepNotes(patientId));
useEffect(() => {
setSessionDescription(loadSessionDescription(patientId));
setStepNotes(loadStepNotes(patientId));
setEventEdits(loadEventEdits(patientId));
setShowPointerTrace(loadShowPointerTracePreference(patientId));
}, [patientId, sessionLog.events.length, lifecycle.status, lifecycle.activeSavedSessionId]);
const replayRef = useRef(replay);
replayRef.current = replay;
useEffect(() => {
const prev = prevLifecycleStatusRef.current;
prevLifecycleStatusRef.current = lifecycle.status;
if (lifecycle.status === 'ended' && prev !== 'ended') {
replayRef.current.switchMode('carousel');
replayRef.current.goToStep(0);
replayRef.current.resetPlaybackPosition();
}
if (lifecycle.status === 'recording' && prev === 'idle') {
replayRef.current.resetPlayback();
}
}, [lifecycle.status]);
const persistSessionDescription = useCallback(
(value: string) => {
setSessionDescription(value);
saveSessionDescription(patientId, value);
},
[patientId],
);
const persistStepNote = useCallback(
(eventId: string, value: string) => {
setStepNotes((current) => {
const next = { ...current };
if (value.trim()) next[eventId] = value.trim();
else delete next[eventId];
return next;
});
saveStepNote(patientId, eventId, value);
},
[patientId],
);
const persistEventEdit = useCallback(
(eventId: string, field: 'title' | 'detail', value: string) => {
const patch = field === 'title' ? { title: value } : { detail: value };
setEventEdits(saveEventEdit(patientId, eventId, patch));
},
[patientId],
);
const progressPct = Math.min(
100,
Math.max(0, (replay.elapsedSeconds / Math.max(replay.totalDuration, 0.001)) * 100),
);
const hasRecordedSession = replay.events.length > 0;
const activeEventId = replay.activeEvent?.id ?? '';
const activeStepNote = stepNotes[activeEventId] ?? '';
const activeTitleValue = replay.activeEvent?.title ?? '';
const activeDetailValue = replay.activeEvent?.detail ?? '';
const hoveredEvent = hoveredEventId
? replay.events.find((event) => event.id === hoveredEventId) ?? null
: null;
const hoveredTickLeft = hoveredEvent
? Math.min(98, Math.max(2, (hoveredEvent.elapsedSeconds / replay.totalDuration) * 100))
: 50;
const updateTimelineHover = useCallback(
(clientX: number) => {
if (isScrubbing) {
setHoveredEventId(null);
return;
}
const track = timelineRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
if (rect.width <= 0) return;
const x = clientX - rect.left;
const hitRadiusPx = 14;
let closestId: string | null = null;
let closestDist = hitRadiusPx;
for (const event of replay.events) {
const eventX = (event.elapsedSeconds / replay.totalDuration) * rect.width;
const dist = Math.abs(x - eventX);
if (dist <= closestDist) {
closestDist = dist;
closestId = event.id;
}
}
setHoveredEventId(closestId);
},
[isScrubbing, replay.events, replay.totalDuration],
);
const seekFromClientX = useCallback(
(clientX: number) => {
const track = timelineRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
if (rect.width <= 0) return;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
replay.seek(ratio * replay.totalDuration);
},
[replay],
);
const onTimelinePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest('.review-session__timeline-tick')) return;
setIsScrubbing(true);
event.currentTarget.setPointerCapture(event.pointerId);
seekFromClientX(event.clientX);
};
const onTimelinePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
seekFromClientX(event.clientX);
};
const onTimelinePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbing) return;
setIsScrubbing(false);
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
return (
<div className="review-session">
<header className="review-session__header">
<h2 className="review-session__title">Xem lại phiên chẩn đoán</h2>
<RecordingModeSelector
value={lifecycle.recordingMode}
onChange={lifecycle.setRecordingMode}
disabled={lifecycle.status === 'recording' || lifecycle.status === 'paused'}
/>
</header>
<SessionLifecycleToolbar
status={lifecycle.status}
saveState={lifecycle.saveUiState}
saveName={lifecycle.saveNameDraft}
savedSessionCount={lifecycle.savedSessions.length}
onStart={lifecycle.startRecording}
onPause={lifecycle.pauseRecording}
onResume={lifecycle.resumeRecording}
onEnd={lifecycle.endRecording}
onReset={lifecycle.requestReset}
onNewSession={lifecycle.requestNewSession}
canStartNewSession={lifecycle.canStartNewSession}
onSaveClick={lifecycle.beginSaveSession}
onSaveNameChange={lifecycle.setSaveNameDraft}
onConfirmSave={lifecycle.confirmSaveSession}
onCancelSave={lifecycle.cancelSaveSession}
onCancelRecord={lifecycle.requestCancelRecording}
canCancelRecord={lifecycle.canCancelRecording}
onOpenSessionsList={lifecycle.openSessionsDialog}
/>
<SessionCancelRecordDialog
open={lifecycle.cancelRecordDialogOpen}
onDismiss={lifecycle.dismissCancelRecording}
onConfirm={lifecycle.confirmCancelRecording}
/>
<SessionNewSessionDialog
open={lifecycle.newSessionDialogOpen}
onDismiss={lifecycle.dismissNewSession}
onConfirm={lifecycle.confirmNewSession}
/>
<SavedSessionsDialog
open={lifecycle.sessionsDialogOpen}
sessions={lifecycle.savedSessions}
encounterGroups={lifecycle.encounterGroups}
activeSessionId={lifecycle.activeSavedSessionId}
onClose={lifecycle.closeSessionsDialog}
onLoad={lifecycle.loadSavedSession}
onDelete={lifecycle.deleteSavedSession}
/>
<SessionResetConfirmDialog
open={lifecycle.resetDialogOpen}
onCancel={lifecycle.cancelReset}
onConfirmAndRecord={() => void lifecycle.confirmReset()}
onConfirmDeleteOnly={() => void lifecycle.confirmResetOnly()}
/>
<div className="review-session__tabs" role="tablist" aria-label="Chế độ xem lại phiên">
<div
className="review-session__tab-indicator"
style={{ transform: replay.mode === 'continuous' ? 'translateX(0%)' : 'translateX(100%)' }}
aria-hidden
/>
<button
type="button"
role="tab"
aria-selected={replay.mode === 'continuous'}
className={`review-session__tab ${replay.mode === 'continuous' ? 'review-session__tab--active' : ''}`}
onClick={() => replay.switchMode('continuous')}
>
<span className="review-session__tab-icon" aria-hidden></span>
Phát liên tục
</button>
<button
type="button"
role="tab"
aria-selected={replay.mode === 'carousel'}
className={`review-session__tab ${replay.mode === 'carousel' ? 'review-session__tab--active' : ''}`}
onClick={() => replay.switchMode('carousel')}
>
<span className="review-session__tab-icon" aria-hidden></span>
Từng bước hành đng
</button>
</div>
<label className="review-session__session-desc">
<span className="review-session__session-desc-label"> tả phiên chẩn đoán</span>
<textarea
rows={2}
value={sessionDescription}
placeholder="Ghi chú tổng quan: mục tiêu phiên, bối cảnh lâm sàng, điểm cần rà soát…"
onChange={(event) => persistSessionDescription(event.target.value)}
/>
</label>
{isSessionEnded && (
<RecordedSessionActionsList
events={replay.events}
activeEventId={activeEventId}
onSelectEvent={(_eventId, index) => replay.goToStep(index)}
/>
)}
<div className="review-session__stage">
{!hasRecordedSession ? (
<div className="review-session__empty" role="status">
<p className="review-session__empty-title">Chưa phiên ghi</p>
<p className="review-session__empty-detail">
Nhấn <strong>Ghi</strong> trên thanh công cụ đ bắt đu ghi lại các thao tác chẩn đoán.
Sau khi kết thúc phiên, thanh thời gian phát lại sẽ hiển thị tại đây.
</p>
</div>
) : (
<>
<label className="review-session__trace-toggle">
<input
type="checkbox"
checked={showPointerTrace}
onChange={(event) => {
const enabled = event.target.checked;
setShowPointerTrace(enabled);
saveShowPointerTracePreference(patientId, enabled);
}}
/>
<span>Hiển thị vết chuột (comet)</span>
</label>
{replay.mode === 'continuous' ? (
<div className="review-session__continuous">
<div
className="review-session__timeline-wrap"
onMouseMove={(event) => updateTimelineHover(event.clientX)}
onMouseLeave={() => setHoveredEventId(null)}
>
<div className="review-session__timeline-tooltip-slot" aria-hidden={!hoveredEvent}>
{hoveredEvent && (
<div
className="review-session__timeline-tooltip"
role="tooltip"
style={{ left: `${hoveredTickLeft}%` }}
>
{tooltipForEvent(hoveredEvent)}
</div>
)}
</div>
<div
ref={timelineRef}
className={`review-session__timeline ${isScrubbing ? 'review-session__timeline--scrubbing' : ''} ${replay.isPlaying ? 'review-session__timeline--playing' : ''}`}
role="slider"
aria-valuemin={0}
aria-valuemax={replay.totalDuration}
aria-valuenow={Math.round(replay.elapsedSeconds)}
aria-label="Thanh thời gian phát lại — kéo để chọn vị trí xem"
onPointerDown={onTimelinePointerDown}
onPointerMove={onTimelinePointerMove}
onPointerUp={onTimelinePointerUp}
onPointerCancel={onTimelinePointerUp}
>
<div
className="review-session__timeline-fill"
style={{ width: `${progressPct}%` }}
/>
<div
className="review-session__timeline-thumb"
style={{ left: `${progressPct}%` }}
aria-hidden
/>
{replay.events.map((event) => {
const left = Math.min(
98,
Math.max(2, (event.elapsedSeconds / replay.totalDuration) * 100),
);
return (
<button
key={event.id}
type="button"
className="review-session__timeline-tick"
style={{ left: `${left}%`, background: eventKindColor(event.kind) }}
aria-label={tooltipForEvent(event)}
onPointerDown={(pointerEvent) => pointerEvent.stopPropagation()}
onClick={() => replay.seek(event.elapsedSeconds)}
/>
);
})}
</div>
<div className="review-session__timeline-times">
<span className="tnum">{formatReplayClock(replay.elapsedSeconds)}</span>
<span className="tnum">{formatReplayClock(replay.totalDuration)}</span>
</div>
</div>
<div className="review-session__deck">
<button type="button" className="review-session__deck-btn" aria-label="Lùi 10 giây" onClick={() => replay.skip(-10)}>
<SkipBackIcon />
</button>
<button type="button" className="review-session__deck-btn review-session__deck-btn--primary" aria-label={replay.isPlaying ? 'Tạm dừng' : 'Phát'} onClick={replay.togglePlay}>
{replay.isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button type="button" className="review-session__deck-btn" aria-label="Tiến 10 giây" onClick={() => replay.skip(10)}>
<SkipForwardIcon />
</button>
<div className="review-session__speed">
<button
type="button"
className="review-session__speed-btn tnum"
aria-expanded={speedMenuOpen}
onClick={() => setSpeedMenuOpen((open) => !open)}
>
{replay.speed}x
</button>
{speedMenuOpen && (
<ul className="review-session__speed-menu" role="menu">
{replay.speeds.map((value) => (
<li key={value}>
<button
type="button"
role="menuitem"
className={value === replay.speed ? 'review-session__speed-option--active' : ''}
onClick={() => {
replay.setSpeed(value as PlaybackSpeed);
setSpeedMenuOpen(false);
}}
>
{value}x
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
) : (
<div className="review-session__carousel-block">
<div className="review-session__carousel">
<button
type="button"
className="review-session__carousel-nav"
aria-label="Bước trước"
disabled={replay.carouselIndex <= 0}
onClick={() => replay.goCarousel(-1)}
>
</button>
<article className="review-session__event-card">
{replay.activeEvent && (
<>
<p className="review-session__event-step-label">
Hành đng {replay.carouselIndex + 1} / {replay.totalSteps}
</p>
<label className="review-session__event-field">
<span className="review-session__event-field-label">Tên hành đng</span>
<input
key={`${activeEventId}-title`}
type="text"
className="review-session__event-title-input"
value={activeTitleValue}
onChange={(event) => persistEventEdit(activeEventId, 'title', event.target.value)}
/>
</label>
<label className="review-session__event-field">
<span className="review-session__event-field-label">Chi tiết</span>
<textarea
key={`${activeEventId}-detail`}
rows={2}
className="review-session__event-detail-input"
value={activeDetailValue}
onChange={(event) => persistEventEdit(activeEventId, 'detail', event.target.value)}
/>
</label>
<p className="review-session__event-time tnum">
Ghi nhận lúc: {formatRecordedTime(replay.activeEvent.recordedAt)}
</p>
</>
)}
</article>
<button
type="button"
className="review-session__carousel-nav"
aria-label="Bước sau"
disabled={replay.carouselIndex >= replay.totalSteps - 1}
onClick={() => replay.goCarousel(1)}
>
</button>
</div>
<label className="review-session__step-note">
<span className="review-session__step-note-label"> tả bước này (tự ghi trong phiên)</span>
<textarea
key={activeEventId}
rows={3}
value={activeStepNote}
placeholder="Ví dụ: Bác sĩ xác nhận vùng tràn dịch suprapatellar và so sánh với ca trước…"
onChange={(event) => persistStepNote(activeEventId, event.target.value)}
/>
</label>
</div>
)}
</>
)}
</div>
<style>{`
.review-session {
height: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: rgba(30, 41, 59, 0.98);
color: #e2e8f0;
}
.review-session__header {
flex-shrink: 0;
padding-bottom: 8px;
}
.review-session__title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;
color: #ffffff;
}
.review-session__tabs {
flex-shrink: 0;
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
border-radius: 10px;
background: rgba(15, 23, 42, 0.65);
}
.review-session__tab-indicator {
position: absolute;
top: 4px;
left: 4px;
width: calc(50% - 4px);
height: calc(100% - 8px);
border-radius: 8px;
background: #1a5f60;
transition: transform 0.25s ease;
z-index: 0;
}
.review-session__tab {
position: relative;
z-index: 1;
border: none;
background: transparent;
color: #94a3b8;
font-size: 12px;
font-weight: 600;
padding: 10px 8px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.review-session__tab--active {
color: #ffffff;
}
.review-session__tab-icon {
font-size: 11px;
opacity: 0.85;
}
.review-session__session-desc {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.review-session__session-desc-label,
.review-session__step-note-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.review-session__session-desc textarea,
.review-session__step-note textarea {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(15, 23, 42, 0.72);
color: #f8fafc;
padding: 8px 10px;
font-size: 13px;
line-height: 1.45;
resize: vertical;
}
.review-session__session-desc textarea:focus,
.review-session__step-note textarea:focus {
outline: none;
border-color: #76c8b1;
box-shadow: 0 0 0 2px rgba(118, 200, 177, 0.25);
}
.review-session__stage {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 10px;
padding: 8px 0;
}
.review-session__empty {
text-align: center;
padding: 24px 16px;
border-radius: 10px;
border: 1px dashed rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.45);
}
.review-session__empty-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #e2e8f0;
}
.review-session__empty-detail {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #94a3b8;
}
.review-session__empty-detail strong {
color: #76c8b1;
font-weight: 600;
}
.review-session__trace-toggle {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 8px;
align-self: flex-start;
font-size: 12px;
color: #cbd5e1;
cursor: pointer;
user-select: none;
}
.review-session__trace-toggle input {
width: 14px;
height: 14px;
accent-color: #76c8b1;
cursor: pointer;
}
.review-session__continuous {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.review-session__timeline-wrap {
width: 100%;
position: relative;
padding: 4px 0 14px;
}
.review-session__timeline-tooltip-slot {
position: relative;
height: 20px;
margin-bottom: 6px;
}
.review-session__timeline {
position: relative;
height: 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.25);
overflow: visible;
cursor: pointer;
touch-action: none;
}
.review-session__timeline--scrubbing {
cursor: grabbing;
}
.review-session__timeline-fill {
position: absolute;
inset: 0 auto 0 0;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #1a5f60, #76c8b1);
pointer-events: none;
transition: width 0.08s linear;
}
.review-session__timeline--scrubbing .review-session__timeline-fill {
transition: none;
}
.review-session__timeline--playing .review-session__timeline-fill,
.review-session__timeline--playing .review-session__timeline-thumb {
transition: none;
}
.review-session__timeline-thumb {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffffff;
border: 2px solid #1a5f60;
box-shadow: 0 0 0 4px rgba(26, 95, 96, 0.25);
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 3;
transition: left 0.08s linear, transform 0.12s ease;
}
.review-session__timeline--scrubbing .review-session__timeline-thumb {
transition: none;
transform: translate(-50%, -50%) scale(1.12);
}
.review-session__timeline-tick {
position: absolute;
top: 50%;
width: 3px;
height: 14px;
transform: translate(-50%, -50%);
border: none;
border-radius: 2px;
cursor: pointer;
padding: 0;
z-index: 2;
}
.review-session__timeline-tooltip {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 11px;
color: #cbd5e1;
text-align: center;
white-space: nowrap;
pointer-events: none;
max-width: min(280px, 90vw);
overflow: hidden;
text-overflow: ellipsis;
}
.review-session__timeline-times {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: #94a3b8;
}
.review-session__deck {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.review-session__deck-btn {
width: 40px;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.review-session__deck-btn--primary {
width: 48px;
height: 48px;
background: #1a5f60;
border-color: #1a5f60;
color: #fff;
}
.review-session__speed {
position: relative;
margin-left: 4px;
}
.review-session__speed-btn {
min-width: 52px;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
font-weight: 700;
cursor: pointer;
}
.review-session__speed-menu {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
list-style: none;
margin: 0;
padding: 6px;
border-radius: 10px;
background: #0f172a;
border: 1px solid rgba(255, 255, 255, 0.1);
min-width: 72px;
z-index: 5;
}
.review-session__speed-menu button {
width: 100%;
border: none;
background: transparent;
color: #cbd5e1;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
}
.review-session__speed-menu button:hover,
.review-session__speed-option--active {
background: rgba(26, 95, 96, 0.35);
color: #fff;
}
.review-session__carousel-block {
width: 100%;
display: flex;
flex-direction: column;
gap: 14px;
}
.review-session__carousel {
display: grid;
grid-template-columns: 40px 1fr 40px;
gap: 8px;
align-items: stretch;
}
.review-session__carousel-nav {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
font-size: 18px;
cursor: pointer;
}
.review-session__carousel-nav:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.review-session__event-card {
border-radius: 12px;
border: 1px solid rgba(118, 200, 177, 0.25);
background: rgba(15, 23, 42, 0.72);
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.review-session__event-step-label {
margin: 0;
font-size: 11px;
font-weight: 700;
color: #76c8b1;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.review-session__event-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.review-session__event-field-label {
font-size: 10px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.review-session__event-title-input,
.review-session__event-detail-input {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(15, 23, 42, 0.72);
color: #f8fafc;
padding: 8px 10px;
font-size: 13px;
line-height: 1.45;
}
.review-session__event-title-input {
font-weight: 600;
color: #76c8b1;
}
.review-session__event-detail-input {
resize: vertical;
min-height: 52px;
}
.review-session__event-title-input:focus,
.review-session__event-detail-input:focus {
outline: none;
border-color: #76c8b1;
box-shadow: 0 0 0 2px rgba(118, 200, 177, 0.25);
}
.review-session__event-time {
margin: 0;
font-size: 12px;
color: #94a3b8;
}
.review-session__step-note {
display: flex;
flex-direction: column;
gap: 6px;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,263 @@
import { useState } from 'react';
/** Local copy — originals commented out in mockData.ts (overlay not mounted in App). */
const SOCRATIC_PROMPTS = [
'Vùng dày echo-free ở hố suprapatellar — bạn xác nhận đây là tràn dịch nhẹ hay mô pannus?',
'Tăng sinh mạch Doppler grade 2 có phù hợp với viêm màng hoạt dịch độ 2 không?',
'Có cấu trúc giả âm (acoustic shadow) nào cần loại trừ khỏi chấm điểm không?',
];
const MORPHOLOGY_TEMPLATE = [
{ id: 'effusion', label: 'Tràn dịch khớp', checked: false },
{ id: 'synovium', label: 'Dày màng hoạt dịch', checked: true },
{ id: 'pannus', label: 'Mô pannus', checked: false },
{ id: 'erosion', label: 'Xói mòn xương', checked: false },
{ id: 'shadow', label: 'Bóng âm thanh giả', checked: false },
{ id: 'difference', label: 'Khác', checked: false },
];
interface SafetyEscalationOverlayProps {
onDismiss?: () => void;
}
export default function SafetyEscalationOverlay({ onDismiss }: SafetyEscalationOverlayProps) {
const [activePrompt, setActivePrompt] = useState(0);
const [response, setResponse] = useState('');
const [morphology, setMorphology] = useState(MORPHOLOGY_TEMPLATE);
const toggleMorphology = (id: string) => {
setMorphology((prev) =>
prev.map((item) => (item.id === id ? { ...item, checked: !item.checked } : item)),
);
};
return (
<div className="safety-overlay" role="dialog" aria-modal="true" aria-labelledby="safety-title">
<div className="safety-overlay__backdrop" />
<div className="safety-overlay__panel glass-elevated">
<header className="safety-overlay__header">
<div>
{/* <span className="chip chip-urgent">Chế độ Leo thang An toàn</span> */}
<h2 id="safety-title">Đi thoại Socratic Circuit Breaker</h2>
<p>Đ xuất AI bị n đ tránh thiên kiến xác nhận</p>
</div>
{onDismiss && (
<button type="button" className="icon-btn" onClick={onDismiss} aria-label="Đóng">
</button>
)}
</header>
<div className="safety-overlay__body">
<section className="safety-overlay__dialogue">
<h3>Hội thoại Socratic</h3>
<div className="safety-overlay__prompt glass">
<span className="safety-overlay__prompt-label">LLM Explainer</span>
<p>{SOCRATIC_PROMPTS[activePrompt]}</p>
</div>
<textarea
className="safety-overlay__input"
placeholder="Mô tả quan sát lâm sàng của bạn..."
value={response}
onChange={(e) => setResponse(e.target.value)}
rows={3}
/>
<div className="safety-overlay__prompt-nav">
{SOCRATIC_PROMPTS.map((_, i) => (
<button
key={i}
type="button"
className={`safety-overlay__dot ${i === activePrompt ? 'safety-overlay__dot--active' : ''}`}
onClick={() => setActivePrompt(i)}
aria-label={`Câu hỏi ${i + 1}`}
/>
))}
</div>
</section>
<section className="safety-overlay__morphology">
<h3>Đánh giá giải phẫu thủ công</h3>
<p className="safety-overlay__morph-desc">
Liêụ mẫu những đc điểm cấu trúc như sau không - Nếu , hãy chọn các dấu hiệu giải phẫu tương ng?
</p>
<ul className="safety-overlay__checklist">
{morphology.map((item) => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.checked}
onChange={() => toggleMorphology(item.id)}
/>
{item.label}
</label>
</li>
))}
</ul>
</section>
</div>
<footer className="safety-overlay__footer">
<button type="button" className="btn btn-ghost" disabled>
&amp; Hoàn tất (bị khóa)
</button>
<button type="button" className="btn btn-primary">
Xác nhận đánh giá thủ công
</button>
</footer>
</div>
<style>{`
.safety-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
}
.safety-overlay__backdrop {
position: absolute;
inset: 0;
background: rgba(23, 29, 28, 0.45);
backdrop-filter: blur(4px);
}
.safety-overlay__panel {
position: relative;
width: min(720px, 100%);
max-height: 90vh;
overflow-y: auto;
border-radius: var(--radius-lg);
padding: var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.safety-overlay__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--space-md);
}
.safety-overlay__header h2 {
font-size: 20px;
margin: 8px 0 4px;
}
.safety-overlay__header p {
margin: 0;
font-size: 13px;
color: var(--color-on-surface-variant);
}
.safety-overlay__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-lg);
}
.safety-overlay__dialogue h3,
.safety-overlay__morphology h3 {
font-size: 14px;
color: var(--color-secondary);
margin: 0 0 10px;
}
.safety-overlay__morphology {
display: flex;
flex-direction: column;
min-height: 0;
}
.safety-overlay__prompt {
padding: var(--space-md);
border-radius: var(--radius-md);
margin-bottom: 10px;
}
.safety-overlay__prompt-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-tertiary);
}
.safety-overlay__prompt p {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.5;
}
.safety-overlay__input {
width: 100%;
padding: 10px 12px;
border-radius: var(--radius-md);
border: 1px solid var(--color-outline-variant);
background: rgba(255, 255, 255, 0.7);
resize: vertical;
}
.safety-overlay__input:focus {
outline: none;
border-color: var(--color-primary);
}
.safety-overlay__prompt-nav {
display: flex;
gap: 8px;
margin-top: 10px;
}
.safety-overlay__dot {
width: 10px;
height: 10px;
border-radius: 50%;
border: none;
background: var(--color-outline-variant);
cursor: pointer;
padding: 0;
}
.safety-overlay__dot--active {
background: var(--color-primary);
}
.safety-overlay__morph-desc {
font-size: 13px;
color: var(--color-on-surface-variant);
margin: 0 0 12px;
}
.safety-overlay__checklist {
list-style: none;
margin: 0;
padding: 0 4px 0 0;
display: flex;
flex-direction: column;
gap: 10px;
max-height: 220px;
overflow-y: auto;
overscroll-behavior: contain;
}
.safety-overlay__checklist::-webkit-scrollbar {
width: 6px;
}
.safety-overlay__checklist::-webkit-scrollbar-thumb {
background: var(--color-outline-variant);
border-radius: 3px;
}
.safety-overlay__checklist label {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
cursor: pointer;
}
.safety-overlay__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
border-top: 1px solid var(--glass-border);
padding-top: var(--space-md);
}
.safety-overlay__footer .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 640px) {
.safety-overlay__body {
grid-template-columns: 1fr;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AngleClassificationResult } from '../../data/angleClassification';
import type { InflammationClassificationResult } from '../../data/inflammationClassification';
import type { SynovitisSeverityResult } from '../../data/synovitisSeverity';
import {
loadCalibrationUserConfig,
saveCalibrationUserConfig,
type CalibrationUserConfig,
} from '../../lib/calibrationEngine';
import AngleClassificationBlob from '../molecules/AngleClassificationBlob';
import CalibrationControls from '../molecules/CalibrationControls';
import ClinicalChatPanel from '../molecules/ClinicalChatPanel';
import SeverityBadge from '../molecules/SeverityBadge';
import { ANGLE_CLASS_META } from '../../data/angleClassification';
import DiagnosticFormDrawer from './DiagnosticFormDrawer';
import { EMPTY_DIAGNOSTIC_FORM_DRAFT, type DiagnosticFormDraft } from '../../data/diagnosticForm';
import {
hasDiagnosticFormDraft,
loadDiagnosticFormDraft,
saveDiagnosticFormDraft,
} from '../../lib/diagnosticFormDraftStore';
interface SideNavBarProps {
severity: SynovitisSeverityResult | null;
severityLoading?: boolean;
inflammation: InflammationClassificationResult | null;
inflammationLoading?: boolean;
angleClassification?: AngleClassificationResult | null;
patientName: string;
patientMrn: string;
patientJoint?: string;
chatGrade?: number | null;
onCaptureDiagnosisSnapshot?: () => Promise<string | null>;
draftRevision?: number;
}
const EMPTY_DRAFT = EMPTY_DIAGNOSTIC_FORM_DRAFT;
export default function SideNavBar({
severity,
severityLoading = false,
inflammation,
inflammationLoading = false,
angleClassification,
patientName,
patientMrn,
patientJoint,
chatGrade = null,
onCaptureDiagnosisSnapshot,
draftRevision = 0,
}: SideNavBarProps) {
const [userConfig, setUserConfig] = useState<CalibrationUserConfig>(loadCalibrationUserConfig);
const [formOpen, setFormOpen] = useState(false);
const [formDraft, setFormDraft] = useState<DiagnosticFormDraft>(EMPTY_DRAFT);
const [hasStoredDraft, setHasStoredDraft] = useState(false);
const persistDraftTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
saveCalibrationUserConfig(userConfig);
}, [userConfig]);
useEffect(() => {
let cancelled = false;
void hasDiagnosticFormDraft(patientMrn).then((exists) => {
if (!cancelled) setHasStoredDraft(exists);
});
return () => {
cancelled = true;
};
}, [patientMrn, draftRevision]);
const persistDraft = useCallback(
async (draft: DiagnosticFormDraft) => {
const saved = await saveDiagnosticFormDraft(patientMrn, draft);
setHasStoredDraft(saved);
},
[patientMrn],
);
const schedulePersistDraft = useCallback(
(draft: DiagnosticFormDraft) => {
if (persistDraftTimeoutRef.current) clearTimeout(persistDraftTimeoutRef.current);
persistDraftTimeoutRef.current = setTimeout(() => {
persistDraftTimeoutRef.current = null;
void persistDraft(draft);
}, 400);
},
[persistDraft],
);
useEffect(() => {
return () => {
if (persistDraftTimeoutRef.current) clearTimeout(persistDraftTimeoutRef.current);
};
}, []);
const handleDraftChange = useCallback(
(draft: DiagnosticFormDraft) => {
setFormDraft(draft);
if (formOpen) schedulePersistDraft(draft);
},
[formOpen, schedulePersistDraft],
);
const hasCalibratableOutput = Boolean(
inflammation?.calibration?.raw_logits?.length ||
angleClassification?.calibration?.raw_logits?.length,
);
const angleLabel = angleClassification
? ANGLE_CLASS_META[angleClassification.class].labelVi
: null;
const openDiagnosticForm = async () => {
const stored = await loadDiagnosticFormDraft(patientMrn);
const base = stored ?? EMPTY_DRAFT;
let snapshot = base.diagnosisWorkImageDataUrl;
if (!snapshot && onCaptureDiagnosisSnapshot) {
snapshot = await onCaptureDiagnosisSnapshot();
}
setFormDraft({ ...base, diagnosisWorkImageDataUrl: snapshot });
setFormOpen(true);
};
const closeDiagnosticForm = () => {
if (persistDraftTimeoutRef.current) {
clearTimeout(persistDraftTimeoutRef.current);
persistDraftTimeoutRef.current = null;
}
void persistDraft(formDraft);
setFormOpen(false);
};
const saveDiagnosticForm = () => {
if (persistDraftTimeoutRef.current) {
clearTimeout(persistDraftTimeoutRef.current);
persistDraftTimeoutRef.current = null;
}
void persistDraft(formDraft);
setFormOpen(false);
};
const refreshDiagnosisSnapshot = async (): Promise<boolean> => {
const snapshot = onCaptureDiagnosisSnapshot
? await onCaptureDiagnosisSnapshot()
: null;
if (snapshot) {
setFormDraft((prev) => ({ ...prev, diagnosisWorkImageDataUrl: snapshot }));
return true;
}
return false;
};
return (
<>
<aside className="side-nav glass-elevated">
<section className="side-nav__section">
<h3>Đ xuất AI</h3>
{hasCalibratableOutput && (
<CalibrationControls config={userConfig} onChange={setUserConfig} />
)}
<SeverityBadge
severity={severity}
severityLoading={severityLoading}
inflammation={inflammation}
inflammationLoading={inflammationLoading}
userConfig={userConfig}
/>
<AngleClassificationBlob
classification={angleClassification ?? null}
userConfig={userConfig}
/>
</section>
<section className="side-nav__section side-nav__section--chat">
<h3>Thảo luận kết quả</h3>
<p className="side-nav__intro">
Nếu đ xuất AI chưa thuyết phục hoặc cần làm , trao đi trực tiếp tại đây không cần dừng quy trình.
</p>
<ClinicalChatPanel
key={patientMrn}
grade={chatGrade}
angleClass={angleClassification?.class ?? null}
severityLoading={severityLoading}
/>
</section>
<section className="side-nav__section">
<h3>Thao tác nhanh</h3>
<div className="side-nav__actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => void openDiagnosticForm()}
>
{hasStoredDraft ? 'Sửa phiếu' : 'Lập phiếu'}
</button>
</div>
<p className="side-nav__canvas-hint">
Đ đánh dấu hoặc bao vùng trên nh siêu âm, dùng thanh công cụ nổi trên khung hình (Vẽ, vùng khép kín, Tẩy).
</p>
</section>
<style>{`
.side-nav {
flex: 1;
min-height: 100%;
border-radius: var(--radius-lg);
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-lg);
overflow-y: auto;
font-size: 1em;
background: rgba(255, 255, 255, 0.96);
}
.side-nav__section h3 {
font-size: 0.875em;
font-weight: 600;
color: var(--color-secondary);
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.side-nav__intro {
font-size: 0.8125em;
color: var(--color-on-surface-variant);
margin: 0 0 10px;
}
.side-nav__section--chat {
flex-shrink: 0;
}
.side-nav__actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.side-nav__actions .btn {
width: 100%;
}
.side-nav__actions .btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.side-nav__canvas-hint {
margin: 10px 0 0;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-on-surface-variant);
}
`}</style>
</aside>
<DiagnosticFormDrawer
open={formOpen}
patientName={patientName}
patientMrn={patientMrn}
jointLabel={patientJoint?.toLowerCase() ?? 'khớp gối'}
aiGrade={severity?.level}
angleLabel={angleLabel}
draft={formDraft}
onDraftChange={handleDraftChange}
onClose={closeDiagnosticForm}
onSave={saveDiagnosticForm}
onRefreshSnapshot={refreshDiagnosisSnapshot}
/>
</>
);
}

View File

@@ -0,0 +1,199 @@
import { type ReactNode, useState } from 'react';
export type SidebarLayerId = 'review' | 'diagnosis';
interface SidebarLayerCarouselProps {
reviewLayer: ReactNode;
diagnosisLayer: ReactNode;
activeLayer?: SidebarLayerId;
onLayerChange?: (layer: SidebarLayerId) => void;
}
function ChevronUpIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
<path d="m18 15-6-6-6 6" />
</svg>
);
}
function ChevronDownIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" aria-hidden>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
export default function SidebarLayerCarousel({
reviewLayer,
diagnosisLayer,
activeLayer: controlledLayer,
onLayerChange,
}: SidebarLayerCarouselProps) {
const [internalLayer, setInternalLayer] = useState<SidebarLayerId>('diagnosis');
const activeLayer = controlledLayer ?? internalLayer;
const setLayer = (layer: SidebarLayerId) => {
if (onLayerChange) onLayerChange(layer);
else setInternalLayer(layer);
};
const rotateForward = () => setLayer(activeLayer === 'review' ? 'diagnosis' : 'review');
const backgroundLayer: SidebarLayerId = activeLayer === 'review' ? 'diagnosis' : 'review';
const backgroundLabel =
backgroundLayer === 'review' ? 'Xem lại phiên chẩn đoán' : 'Thông tin chẩn đoán';
return (
<div className="sidebar-carousel">
<div className="sidebar-carousel__nav">
<button type="button" className="sidebar-carousel__nav-btn" aria-label="Xoay lớp lên" onClick={rotateForward}>
<ChevronUpIcon />
</button>
<button type="button" className="sidebar-carousel__nav-btn" aria-label="Xoay lớp xuống" onClick={rotateForward}>
<ChevronDownIcon />
</button>
</div>
<div className="sidebar-carousel__stack">
<button
type="button"
className={`sidebar-carousel__peek sidebar-carousel__peek--${backgroundLayer}`}
aria-label={`Chuyển sang ${backgroundLabel}`}
onClick={() => setLayer(backgroundLayer)}
>
<span className="sidebar-carousel__peek-label">{backgroundLabel}</span>
</button>
<div
className={`sidebar-carousel__foreground sidebar-carousel__foreground--${activeLayer}`}
>
<div className="sidebar-carousel__foreground-inner">
{/* Keep both layers mounted so heavy sub-trees (e.g. local LLM chat) are not torn down on card switch. */}
<div
className={`sidebar-carousel__layer${activeLayer === 'review' ? ' sidebar-carousel__layer--active' : ''}`}
aria-hidden={activeLayer !== 'review'}
>
{reviewLayer}
</div>
<div
className={`sidebar-carousel__layer${activeLayer === 'diagnosis' ? ' sidebar-carousel__layer--active' : ''}`}
aria-hidden={activeLayer !== 'diagnosis'}
>
{diagnosisLayer}
</div>
</div>
</div>
</div>
<style>{`
.sidebar-carousel {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.sidebar-carousel__nav {
position: absolute;
top: 36px;
right: 8px;
z-index: 30;
display: flex;
gap: 4px;
}
.sidebar-carousel__nav-btn {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--color-outline-variant);
background: rgba(255, 255, 255, 0.92);
color: var(--color-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
backdrop-filter: blur(8px);
}
.sidebar-carousel__nav-btn:hover {
border-color: var(--color-primary);
}
.sidebar-carousel__stack {
position: relative;
flex: 1;
min-height: 420px;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: var(--radius-lg);
border: 1px solid rgba(5, 107, 88, 0.18);
}
.sidebar-carousel__peek {
flex: 0 0 28px;
width: 100%;
border: none;
padding: 0 12px;
display: flex;
align-items: center;
cursor: pointer;
z-index: 5;
}
.sidebar-carousel__peek--review {
background: rgba(30, 41, 59, 0.55);
color: #cbd5e1;
}
.sidebar-carousel__peek--diagnosis {
background: rgba(255, 255, 255, 0.45);
color: var(--color-on-surface-variant);
border-bottom: 1px solid rgba(5, 107, 88, 0.12);
}
.sidebar-carousel__peek-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-carousel__foreground {
flex: 1;
min-height: 0;
position: relative;
z-index: 20;
overflow: hidden;
box-shadow: 0 -4px 20px rgba(15, 23, 42, 0.12);
}
.sidebar-carousel__foreground--review {
background: rgba(30, 41, 59, 0.98);
}
.sidebar-carousel__foreground--diagnosis {
background: rgba(255, 255, 255, 0.96);
}
.sidebar-carousel__foreground-inner {
position: relative;
height: 100%;
min-height: 100%;
overflow: hidden;
}
.sidebar-carousel__layer {
display: none;
height: 100%;
min-height: 100%;
overflow: auto;
}
.sidebar-carousel__layer--active {
display: block;
}
.sidebar-carousel__foreground--diagnosis .side-nav {
border: none;
box-shadow: none;
border-radius: 0;
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,307 @@
export const PATIENT_PROFILE_MODAL_STYLES = `
.patient-profile-modal {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.patient-profile-modal__backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
cursor: pointer;
}
.patient-profile-modal__sheet {
position: relative;
width: min(640px, 100%);
max-height: min(90vh, 880px);
overflow: hidden;
display: flex;
flex-direction: column;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.08);
}
.patient-profile-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 32px 32px 0;
}
.patient-profile-modal__title {
font-size: 20px;
font-weight: 600;
color: var(--color-on-surface);
line-height: 1.3;
}
.patient-profile-modal__close {
flex-shrink: 0;
width: 36px;
height: 36px;
}
.patient-profile-modal__body {
padding: 24px 32px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.patient-profile-modal__grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.patient-profile-modal__field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.patient-profile-modal__field--full {
grid-column: 1 / -1;
}
.patient-profile-modal__label {
font-size: 13px;
font-weight: 600;
color: var(--color-on-surface-variant);
}
.patient-profile-modal__field input,
.patient-profile-modal__field select {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--color-outline-variant);
background: #fff;
color: var(--color-on-surface);
}
.patient-profile-modal__field input:focus,
.patient-profile-modal__field select:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(5, 107, 88, 0.15);
}
.patient-profile-modal__status-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.patient-profile-modal__status-pill {
cursor: pointer;
border: 2px solid transparent;
opacity: 0.72;
transition: opacity 0.15s, border-color 0.15s, transform 0.15s;
}
.patient-profile-modal__status-pill:hover:not(:disabled) {
opacity: 1;
}
.patient-profile-modal__status-pill--active {
opacity: 1;
border-color: currentColor;
transform: translateY(-1px);
}
.patient-profile-modal__dropzone {
border: 2px dashed #cbd5e1;
border-radius: 12px;
background: #f8fafc;
padding: 28px 20px;
text-align: center;
color: #64748b;
transition: border-color 0.15s, background 0.15s;
}
.patient-profile-modal__dropzone--active {
border-color: var(--color-primary);
background: rgba(5, 107, 88, 0.06);
color: var(--color-secondary);
}
.patient-profile-modal__dropzone-primary {
margin: 12px 0 4px;
font-weight: 600;
color: var(--color-on-surface);
}
.patient-profile-modal__dropzone-secondary {
margin: 0;
font-size: 14px;
}
.patient-profile-modal__browse-link {
border: none;
background: none;
padding: 0;
color: var(--color-primary);
font-weight: 600;
text-decoration: underline;
cursor: pointer;
}
.patient-profile-modal__browse-link:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.patient-profile-modal__dropzone-caption {
margin: 10px 0 0;
font-size: 12px;
color: var(--color-on-surface-variant);
max-width: 420px;
margin-inline: auto;
line-height: 1.45;
}
.patient-profile-modal__file-error {
margin: -8px 0 0;
font-size: 13px;
color: var(--color-error);
}
.patient-profile-modal__inventory-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 700;
color: var(--color-on-surface);
}
.patient-profile-modal__inventory-loading,
.patient-profile-modal__inventory-empty {
margin: 0;
font-size: 13px;
color: var(--color-on-surface-variant);
font-style: italic;
}
.patient-profile-modal__file-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.patient-profile-modal__file-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--color-outline-variant);
background: rgba(255, 255, 255, 0.8);
transition: opacity 0.15s;
}
.patient-profile-modal__file-item--deleted {
opacity: 0.4;
}
.patient-profile-modal__file-item--deleted .patient-profile-modal__file-name,
.patient-profile-modal__file-item--deleted .patient-profile-modal__file-size {
text-decoration: line-through;
}
.patient-profile-modal__file-item--new {
border-color: rgba(5, 107, 88, 0.35);
background: rgba(118, 200, 177, 0.12);
}
.patient-profile-modal__file-icon {
color: var(--color-secondary);
margin-top: 2px;
}
.patient-profile-modal__file-meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.patient-profile-modal__file-name {
font-size: 14px;
font-weight: 600;
word-break: break-all;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.patient-profile-modal__file-size {
font-size: 12px;
color: var(--color-on-surface-variant);
}
.patient-profile-modal__new-badge {
display: inline-flex;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 700;
background: var(--color-primary-container);
color: var(--color-on-primary-container);
text-decoration: none;
}
.patient-profile-modal__delete-badge {
color: var(--color-error);
font-weight: 600;
text-decoration: none;
}
.patient-profile-modal__progress {
height: 4px;
border-radius: 999px;
background: var(--color-surface-container-high);
overflow: hidden;
margin-top: 4px;
}
.patient-profile-modal__progress-bar {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--color-primary), var(--color-primary-container));
transition: width 0.12s ease-out;
}
.patient-profile-modal__file-remove,
.patient-profile-modal__trash-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
}
.patient-profile-modal__trash-btn {
color: var(--color-error);
border-color: rgba(186, 26, 26, 0.25);
}
.patient-profile-modal__trash-btn:hover:not(:disabled) {
background: var(--color-error-container);
border-color: var(--color-error);
}
.patient-profile-modal__undo-link {
border: none;
background: none;
padding: 4px 8px;
color: var(--color-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
.patient-profile-modal__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 0 32px 32px;
border-top: 1px solid var(--color-outline-variant);
padding-top: 20px;
margin-top: auto;
}
.patient-profile-modal__spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: patient-profile-modal-spin 0.7s linear infinite;
}
@keyframes patient-profile-modal-spin {
to { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.patient-profile-modal__grid {
grid-template-columns: 1fr;
}
.patient-profile-modal__header,
.patient-profile-modal__body,
.patient-profile-modal__footer {
padding-left: 20px;
padding-right: 20px;
}
}
`;

View File

@@ -0,0 +1,142 @@
import type { ReactNode } from 'react';
interface PatientListShellProps {
search: string;
onSearchChange: (value: string) => void;
filter: string;
onFilterChange: (value: string) => void;
summary: ReactNode;
children: ReactNode;
}
const FILTERS = [
{ value: 'all', label: 'Tất cả' },
{ value: 'urgent', label: 'Khẩn cấp' },
{ value: 'processing', label: 'Đang xử lý' },
{ value: 'followup', label: 'Theo dõi' },
];
export default function PatientListShell({
search,
onSearchChange,
filter,
onFilterChange,
summary,
children,
}: PatientListShellProps) {
return (
<div className="patient-list-shell">
<section className="patient-list-shell__hero">
<div>
<h2>Danh sách công việc</h2>
<p>Quản hàng đi lâm sàng khởi tạo phiên quét siêu âm</p>
</div>
</section>
<section className="patient-list-shell__summary">{summary}</section>
<section className="patient-list-shell__toolbar glass">
<input
type="search"
className="patient-list-shell__search"
placeholder="Tìm bệnh nhân, mã BN..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
aria-label="Tìm kiếm bệnh nhân"
/>
<div className="patient-list-shell__filters" role="tablist" aria-label="Bộ lọc trạng thái">
{FILTERS.map((f) => (
<button
key={f.value}
type="button"
role="tab"
aria-selected={filter === f.value}
className={`patient-list-shell__filter ${filter === f.value ? 'patient-list-shell__filter--active' : ''}`}
onClick={() => onFilterChange(f.value)}
>
{f.label}
</button>
))}
</div>
</section>
<section className="patient-list-shell__grid">{children}</section>
<style>{`
.patient-list-shell {
padding: var(--space-lg) var(--space-md);
max-width: 1280px;
margin: 0 auto;
width: 100%;
}
.patient-list-shell__hero h2 {
font-size: 28px;
font-weight: 700;
color: var(--color-secondary);
}
.patient-list-shell__hero p {
margin: 6px 0 0;
color: var(--color-on-surface-variant);
}
.patient-list-shell__summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
margin: var(--space-lg) 0;
}
.patient-list-shell__toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
align-items: center;
padding: var(--space-md);
border-radius: var(--radius-lg);
margin-bottom: var(--space-lg);
}
.patient-list-shell__search {
flex: 1;
min-width: 200px;
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--color-outline-variant);
background: rgba(255, 255, 255, 0.6);
}
.patient-list-shell__search:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(5, 107, 88, 0.15);
}
.patient-list-shell__filters {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.patient-list-shell__filter {
padding: 8px 14px;
border-radius: var(--radius-full);
border: 1px solid var(--color-outline-variant);
background: transparent;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: var(--color-on-surface-variant);
}
.patient-list-shell__filter--active {
background: var(--color-primary);
color: var(--color-on-primary);
border-color: var(--color-primary);
}
.patient-list-shell__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
}
@media (max-width: 768px) {
.patient-list-shell__summary {
grid-template-columns: 1fr;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,261 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
interface WorkspaceShellProps {
zoneA: ReactNode;
zoneB: ReactNode;
}
const STORAGE_KEY = 'lumina-workspace-zone-a-ratio';
const ZONE_A_DEFAULT = 0.6;
const ZONE_A_MIN = 0.48;
const ZONE_A_MAX = 0.78;
const DIVIDER_WIDTH_PX = 10;
const BASELINE_ZONE_B_RATIO = 1 - ZONE_A_DEFAULT;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function readStoredRatio(): number {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return ZONE_A_DEFAULT;
const parsed = Number.parseFloat(raw);
return Number.isFinite(parsed) ? clamp(parsed, ZONE_A_MIN, ZONE_A_MAX) : ZONE_A_DEFAULT;
} catch {
return ZONE_A_DEFAULT;
}
}
function panelScaleFromZoneARatio(zoneARatio: number): number {
const zoneBRatio = 1 - zoneARatio;
return clamp(zoneBRatio / BASELINE_ZONE_B_RATIO, 0.82, 1.15);
}
export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
const shellRef = useRef<HTMLDivElement>(null);
const [zoneARatio, setZoneARatio] = useState(readStoredRatio);
const [isDragging, setIsDragging] = useState(false);
const [isDividerHovered, setIsDividerHovered] = useState(false);
const [isWideLayout, setIsWideLayout] = useState(true);
const panelScale = panelScaleFromZoneARatio(zoneARatio);
const showBlob = isDividerHovered || isDragging;
useEffect(() => {
localStorage.setItem(STORAGE_KEY, String(zoneARatio));
}, [zoneARatio]);
useEffect(() => {
const media = window.matchMedia('(min-width: 1025px)');
const update = () => setIsWideLayout(media.matches);
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
const updateRatioFromPointer = useCallback((clientX: number) => {
const shell = shellRef.current;
if (!shell) return;
const rect = shell.getBoundingClientRect();
const padding = 16;
const innerWidth = rect.width - padding * 2;
const usable = innerWidth - DIVIDER_WIDTH_PX;
if (usable <= 0) return;
const pointerX = clientX - rect.left - padding;
setZoneARatio(clamp(pointerX / usable, ZONE_A_MIN, ZONE_A_MAX));
}, []);
const onBlobPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isWideLayout) return;
event.preventDefault();
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
setIsDragging(true);
updateRatioFromPointer(event.clientX);
};
const onBlobPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging) return;
updateRatioFromPointer(event.clientX);
};
const onBlobPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging) return;
event.currentTarget.releasePointerCapture(event.pointerId);
setIsDragging(false);
};
const onBlobDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
setZoneARatio(ZONE_A_DEFAULT);
};
const onDividerMouseLeave = () => {
if (!isDragging) {
setIsDividerHovered(false);
}
};
const shellStyle = {
'--workspace-zone-a-pct': `${(zoneARatio * 100).toFixed(2)}%`,
'--workspace-panel-scale': String(panelScale),
} as CSSProperties;
return (
<div
ref={shellRef}
className={`workspace-shell ${isDragging ? 'workspace-shell--dragging' : ''}`}
style={shellStyle}
>
<div className="workspace-shell__zone-a">{zoneA}</div>
{isWideLayout && (
<div
className="workspace-shell__divider"
role="separator"
aria-orientation="vertical"
aria-valuenow={Math.round(zoneARatio * 100)}
aria-valuemin={Math.round(ZONE_A_MIN * 100)}
aria-valuemax={Math.round(ZONE_A_MAX * 100)}
aria-label="Vùng chia khung siêu âm và bảng phụ"
onMouseEnter={() => setIsDividerHovered(true)}
onMouseLeave={onDividerMouseLeave}
>
{showBlob && (
<div
className="workspace-shell__divider-blob glass-elevated"
role="slider"
aria-label="Kéo để điều chỉnh độ rộng khung siêu âm và bảng phụ"
aria-valuenow={Math.round(zoneARatio * 100)}
aria-valuemin={Math.round(ZONE_A_MIN * 100)}
aria-valuemax={Math.round(ZONE_A_MAX * 100)}
onPointerDown={onBlobPointerDown}
onPointerMove={onBlobPointerMove}
onPointerUp={onBlobPointerUp}
onPointerCancel={onBlobPointerUp}
onDoubleClick={onBlobDoubleClick}
>
<span className="workspace-shell__divider-blob-grip" aria-hidden>
<span />
<span />
<span />
</span>
</div>
)}
</div>
)}
<div className="workspace-shell__zone-b">{zoneB}</div>
<style>{`
.workspace-shell {
display: flex;
flex: 1;
min-height: 0;
padding: var(--space-md);
user-select: ${isDragging ? 'none' : 'auto'};
}
.workspace-shell--dragging {
cursor: col-resize;
}
.workspace-shell__zone-a {
flex: 0 0 var(--workspace-zone-a-pct);
min-width: 0;
display: flex;
flex-direction: column;
transition: flex-basis 0.05s linear;
}
.workspace-shell--dragging .workspace-shell__zone-a {
transition: none;
}
.workspace-shell__zone-b {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
font-size: calc(1rem * var(--workspace-panel-scale, 1));
}
.workspace-shell__divider {
position: relative;
flex: 0 0 ${DIVIDER_WIDTH_PX}px;
margin: 0 2px;
cursor: default;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.workspace-shell__divider::before {
content: '';
position: absolute;
top: 8%;
bottom: 8%;
left: 50%;
width: 2px;
transform: translateX(-50%);
border-radius: 2px;
background: var(--color-outline-variant);
transition: background 0.15s, width 0.15s;
}
.workspace-shell__divider:hover::before,
.workspace-shell--dragging .workspace-shell__divider::before {
background: var(--color-secondary);
width: 3px;
}
.workspace-shell__divider-blob {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
padding: 12px 8px;
border-radius: var(--radius-full);
cursor: col-resize;
touch-action: none;
z-index: 3;
animation: workspace-divider-blob-in 0.15s ease-out;
}
@keyframes workspace-divider-blob-in {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.workspace-shell__divider-blob-grip {
display: flex;
flex-direction: column;
gap: 3px;
align-items: center;
}
.workspace-shell__divider-blob-grip span {
display: block;
width: 16px;
height: 2px;
border-radius: 2px;
background: var(--color-secondary);
opacity: 0.85;
}
@media (max-width: 1024px) {
.workspace-shell {
flex-direction: column;
gap: var(--space-md);
}
.workspace-shell__zone-a,
.workspace-shell__zone-b {
flex: 1 1 auto;
font-size: 1rem;
}
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,174 @@
import type { SegmentationAngleType } from './segmentationLegend';
import type { CalibratedPrediction, DecisionState } from '../types/calibratedPrediction';
import {
estimateMisclassificationRate,
formatRiskFramingVi,
} from '../types/calibratedPrediction';
/** Angle labels returned by the angle classifier (legacy API §1.1). */
export const ANGLE_CLASSES = ['med-lat', 'post-trans', 'sup-trans-flex', 'sup-up-long'] as const;
export type AngleClass = (typeof ANGLE_CLASSES)[number];
export interface AngleClassMeta {
class: AngleClass;
/** Clinician-facing English name (not the ML class slug). */
labelMedicalEn: string;
/** Clinician-facing Vietnamese name. */
labelVi: string;
/** Short plane description for legend switch guide (parenthetical). */
legendPlaneVi: string;
/** Legend catalog for this angle (Phụ lục A — SUP vs POST). */
segmentLegendType: SegmentationAngleType;
/** Whether thickness overlay notes apply (sup-up-long only per §1.1). */
supportsThicknessMeasurement: boolean;
}
export const ANGLE_CLASS_META: Record<AngleClass, AngleClassMeta> = {
'med-lat': {
class: 'med-lat',
labelMedicalEn: 'Medial Lat',
labelVi: 'Góc quét mặt trong mặt ngoài',
legendPlaneVi: 'Mặt cắt trong — ngoài',
segmentLegendType: 'sup',
supportsThicknessMeasurement: false,
},
'post-trans': {
class: 'post-trans',
labelMedicalEn: 'Posterior Transverse',
labelVi: 'Góc quét mặt sau ngang',
legendPlaneVi: 'Mặt cắt ngang vùng khoeo / mặt sau',
segmentLegendType: 'post',
supportsThicknessMeasurement: false,
},
'sup-trans-flex': {
class: 'sup-trans-flex',
labelMedicalEn: 'Suprapatellar Transverse Flexed',
labelVi: 'Góc quét cắt ngang trên xương bánh chè ở tư thế gấp gối',
legendPlaneVi: 'Mặt cắt ngang trên bánh chè — tư thế gấp gối',
segmentLegendType: 'sup',
supportsThicknessMeasurement: false,
},
'sup-up-long': {
class: 'sup-up-long',
labelMedicalEn: 'Suprapatellar Upper Longitudinal',
labelVi: 'Góc quét cắt dọc phía trên xương bánh chè',
legendPlaneVi: 'Mặt cắt dọc phía trên bánh chè',
segmentLegendType: 'sup',
supportsThicknessMeasurement: true,
},
};
export interface AngleClassificationResult {
class: AngleClass;
/** Primary class probability in 01 (legacy compat). */
confidence: number;
calibration?: CalibratedPrediction;
}
function shannonEntropy(probs: number[]): number {
return -probs.reduce((sum, p) => sum + (p > 0 ? p * Math.log(p) : 0), 0);
}
/** Demo calibration until validation-set error bins are wired from data science. */
export function buildMockAngleCalibration(
winner: AngleClass,
confidence: number,
): CalibratedPrediction {
const others = ANGLE_CLASSES.filter((c) => c !== winner);
const remainder = Math.max(0, 1 - confidence);
const perOther = remainder / others.length;
const rawProbs = ANGLE_CLASSES.map((c) => (c === winner ? confidence : perOther));
const normEntropy = shannonEntropy(rawProbs) / Math.log(ANGLE_CLASSES.length);
const classProbabilities = Object.fromEntries(
ANGLE_CLASSES.map((c, i) => [c, Math.round(rawProbs[i]! * 10000) / 100]),
);
const ambiguousSet = ANGLE_CLASSES.filter((c) => {
const p = rawProbs[ANGLE_CLASSES.indexOf(c)]!;
return p >= confidence - 0.05;
});
let decisionState: DecisionState = 'confident';
if (normEntropy >= 0.88) {
decisionState = 'ood_warning';
} else if (ambiguousSet.length > 1) {
decisionState = 'ambiguous';
}
const errorRate = estimateMisclassificationRate(confidence);
const meta = getAngleMeta(winner);
const winnerLogit = Math.log(confidence / (1 - confidence + 1e-6));
const otherLogit = Math.log((perOther / (1 - perOther + 1e-6)) + 1e-6);
return {
raw_logits: ANGLE_CLASSES.map((c) => (c === winner ? winnerLogit : otherLogit)),
temperature: 1.4,
base_temperature: 1,
mode: 'standard',
clinical_suspicion: 0,
alpha_margin: 0.05,
class_probabilities: classProbabilities,
entropy: shannonEntropy(rawProbs),
normalized_entropy: normEntropy,
ambiguous_set: ambiguousSet,
decision_state: decisionState,
predicted_error_rate: errorRate,
risk_framing_vi: formatRiskFramingVi(formatAngleClinicalLabel(meta), confidence, decisionState),
};
}
// --- Detached mock angle map (prototyping uses /api/test/analyze/batch only) ---
// function withMockCalibration(result: Omit<AngleClassificationResult, 'calibration'>): AngleClassificationResult {
// return { ...result, calibration: buildMockAngleCalibration(result.class, result.confidence) };
// }
// export const MOCK_ANGLE_BY_FRAME: Record<string, AngleClassificationResult> = {
// 'med-lat-1': withMockCalibration({ class: 'med-lat', confidence: 0.94 }),
// 'med-lat-2': withMockCalibration({ class: 'med-lat', confidence: 0.91 }),
// 'trans-flex': withMockCalibration({ class: 'sup-trans-flex', confidence: 0.88 }),
// };
// export function getMockAngleForFrame(frameId: string): AngleClassificationResult | null {
// return MOCK_ANGLE_BY_FRAME[frameId] ?? null;
// }
export function isAngleClass(value: string): value is AngleClass {
return (ANGLE_CLASSES as readonly string[]).includes(value);
}
export function getAngleMeta(angleClass: AngleClass): AngleClassMeta {
return ANGLE_CLASS_META[angleClass];
}
/** UI label: `{English medical name} : {Vietnamese clinical name}` */
export function formatAngleClinicalLabel(meta: AngleClassMeta): string {
return `${meta.labelMedicalEn} : ${meta.labelVi}`;
}
export function getSegmentLegendTypeForAngle(angleClass: AngleClass): SegmentationAngleType {
return getAngleMeta(angleClass).segmentLegendType;
}
export function angleSupportsThicknessMeasurement(angleClass: AngleClass): boolean {
return getAngleMeta(angleClass).supportsThicknessMeasurement;
}
export function normalizeBackendAngle(
raw:
| {
class: string;
confidence: number | null;
calibration?: CalibratedPrediction;
}
| undefined,
): AngleClassificationResult | null {
if (raw?.class && isAngleClass(raw.class)) {
const confidence =
raw.confidence != null
? raw.confidence > 1
? raw.confidence / 100
: raw.confidence
: 0;
return {
class: raw.class,
confidence,
calibration: raw.calibration,
};
}
return null;
}

View File

@@ -0,0 +1,78 @@
/** Clinician-facing help — Feynman narrative + 5 core points + citations. */
export const CALIBRATION_METRIC_HELP_TRIGGER = 'Giải thích cách tính';
export const CALIBRATION_METRIC_HELP_NARRATIVE_TITLE = 'Câu chuyện ẩn dụ';
export const CALIBRATION_METRIC_HELP_NARRATIVE = [
'Hãy tưởng tượng mô hình AI giống như một vị bác sĩ nội trú trẻ tuổi, thiên tài nhưng vô cùng ngạo mạn. Vị bác sĩ này đọc sách rất nhiều, ghi nhớ chuẩn xác các triệu chứng viêm nhiễm (độ chính xác cao), nhưng có một nhược điểm lớn: luôn nói quá lên độ tự tin của mình. Khi thấy một ca bệnh hơi giống viêm nhiễm, thay vì bảo “Tôi nghĩ có 70% khả năng viêm”, anh ta luôn dõng dạc tuyên bố “Tôi chắc chắn 99,9%!”. Sự tự tin thái quá này cực kỳ nguy hiểm trong lâm sàng, vì nó khiến chúng ta mất cảnh giác ở những ca ranh giới.',
'Để kiểm soát sự kiêu ngạo này, bệnh viện cần một bác sĩ trưởng khoa trưởng thành và điềm đạm — chính là tham số Nhiệt độ (T). Bác sĩ trưởng khoa không thay đổi chẩn đoán hay kiến thức của bác sĩ trẻ, ông chỉ lắng nghe những “lập luận thô” (logits) của anh ta, rồi chủ động “làm dịu” (scale bằng T) sự hung hăng đó lại. Nhờ đó, con số phần trăm cuối cùng trả về cho bạn sẽ phản ánh đúng thực tế lâm sàng: ca nào rõ ràng thì vẫn là 99%, nhưng ca nào mập mờ sẽ bị kéo về đúng mức 70% hoặc rơi vào “Vùng lưỡng lự” để con người nhảy vào hội chẩn.',
] as const;
export const CALIBRATION_METRIC_HELP_KEY_POINTS_TITLE =
'Năm điểm cốt lõi — con số trên màn hình đến từ đâu';
export interface CalibrationKeyPoint {
title: string;
body: string;
subPoints?: readonly string[];
}
export const CALIBRATION_METRIC_HELP_KEY_POINTS: readonly CalibrationKeyPoint[] = [
{
title: 'Điểm số thô không phải là phần trăm',
body:
'AI không trực tiếp nghĩ ra con số 90% hay 99%. Đầu tiên, nó chấm điểm toán học thô (logits) cho từng khả năng — ví dụ Viêm được 8 điểm, Không viêm được 2 điểm.',
},
{
title: 'Cơ chế bóp méo của AI hiện đại',
body:
'Các mạng càng sâu và thông minh thì xu hướng phân tách các điểm thô càng xa (ví dụ 20 điểm và 1 điểm). Khi quy đổi, khoảng cách quá lớn này bị ép thành xác suất cực đoan như 99,9% — hiện tượng “quá tự tin” (over-confidence).',
},
{
title: 'Bác sĩ trưởng khoa hạ hỏa (tham số T)',
body:
'Trước khi chuyển điểm thô thành phần trăm, ta chia tất cả các điểm thô cho hệ số T (Nhiệt độ). Nếu T > 1, khoảng cách phóng đại giữa các điểm thô sẽ bị thu hẹp lại một cách toán học.',
},
{
title: 'Quy đổi ra xác suất thực (Softmax)',
body:
'Sau khi được làm dịu bởi T, các điểm thô mới được chuẩn hóa bằng Softmax — quy đổi thành tỷ lệ phần trăm có tổng bằng 100%. Đây mới là con số hiển thị trên màn hình của bạn.',
},
{
title: 'Điều chỉnh theo bối cảnh lâm sàng',
body: 'Thanh trượt T và ba bậc hiệu chuẩn phía trên cho phép bạn chọn mức “dịu” hay “sắc” phù hợp bối cảnh:',
subPoints: [
'T cao (làm mịn): mô hình thận trọng hơn, kéo phần trăm ảo tưởng xuống gần thực tế — phù hợp khi kiểm tra chéo khắt khe.',
'T thấp (làm sắc): mô hình nhạy bén, quyết đoán hơn — hữu ích khi sàng lọc diện rộng để không bỏ sót dấu hiệu nghi ngờ.',
],
},
];
export const CALIBRATION_METRIC_HELP_FOUNDATION =
'Guo và cộng sự (2017), trong bài On Calibration of Modern Neural Networks (ICML), cho thấy mạng sâu hiện đại thường quá tự tin và đề xuất temperature scaling — hiệu chỉnh bằng một tham số T trên logits trước khi chuyển sang xác suất. Khung đánh giá mức khớp giữa xác suất báo cáo và tần suất đúng trên dữ liệu quan sát được trình bày trong tài liệu về phân loại và hiệu chuẩn độ tin cậy của Lei (Đại học Carnegie Mellon).';
export const CALIBRATION_METRIC_HELP_READING_TITLE = 'Đọc thêm';
export const CALIBRATION_METRIC_HELP_READING = [
{
label: 'Guo et al. (2017) — temperature scaling',
href: 'https://arxiv.org/abs/1706.04599',
},
{
label: 'Lei — confidence & classification calibration (CMU)',
href: 'https://www.stat.cmu.edu/~jinglei/conf_class_R2.pdf',
},
] as const;
export const CALIBRATION_METRIC_HELP_REFERENCES_TITLE = 'Nền tảng khoa học & đọc thêm';
/** One-line: why this section + who should open it (verb/noun phrase, not content summary). */
export const CALIBRATION_METRIC_HELP_NARRATIVE_TEASER =
'Ẩn dụ để nắm bản chất toán học — cho bác sĩ cần trực giác trước khi đọc số';
export const CALIBRATION_METRIC_HELP_KEY_POINTS_TEASER =
'Lộ trình sinh ra % trên màn hình — cho người chỉnh T và đối chiếu kết quả hàng ngày';
export const CALIBRATION_METRIC_HELP_REFERENCES_TEASER =
'Căn cứ khoa học để tra cứu — cho trưởng khoa / nghiên cứu cần trích dẫn';

View File

@@ -0,0 +1,82 @@
/** Clinical temperature tiers — T applied directly as softmax(logits ÷ T). */
export type CalibrationTierId = 'aggressive' | 'standard' | 'conservative';
/** @deprecated Use CalibrationTierId */
export type CalibrationMode = CalibrationTierId;
export interface CalibrationTier {
id: CalibrationTierId;
tier: 1 | 2 | 3;
labelVi: string;
labelEn: string;
triggerVi: string;
ruleVi: string;
recommendedT: number;
uiEffectVi: string;
}
export const CALIBRATION_TIERS: CalibrationTier[] = [
{
id: 'aggressive',
tier: 1,
labelVi: 'Nhạy cao / Sàng lọc',
labelEn: 'Aggressive / Screening',
triggerVi: 'Bác sĩ nghi ngờ viêm cao từ triệu chứng, chưa thấy trên ảnh.',
ruleVi: 'T = 0.7 (Sharpening)',
recommendedT: 0.7,
uiEffectVi:
'Đẩy xác suất biên lên — hạ ngưỡng cảnh báo viêm để không bỏ sót ca ranh giới.',
},
{
id: 'standard',
tier: 2,
labelVi: 'Chuẩn / Mặc định',
labelEn: 'Standard Baseline',
triggerVi: 'Vận hành mặc định — chưa có prior lâm sàng từ người dùng.',
ruleVi: 'T = 1.4 (Smoothing)',
recommendedT: 1.4,
uiEffectVi:
'Giảm overconfidence của mạng — phân bố thực tế, cân bằng toán học.',
},
{
id: 'conservative',
tier: 3,
labelVi: 'Bảo thủ / Hoài nghi',
labelEn: 'Conservative / Skeptical',
triggerVi: 'Bác sĩ tin bệnh nhân khỏe; dùng AI chỉ để kiểm tra lại.',
ruleVi: 'T = 2.2 (Heavy flattening)',
recommendedT: 2.2,
uiEffectVi:
'Làm phẳng phân bố — chỉ báo dương khi tín hiệu mô hình cực kỳ mạnh.',
},
];
/** Midpoints between recommended T anchors — used to light up tier while dragging. */
export const TIER_TEMPERATURE_BOUNDARIES = {
/** T ≤ this → Tier 1 (between 0.7 and 1.4) */
aggressiveMax: (0.7 + 1.4) / 2,
/** T ≤ this → Tier 2 (between 1.4 and 2.2) */
standardMax: (1.4 + 2.2) / 2,
} as const;
export const TEMPERATURE_SLIDER = {
min: 0.5,
max: 2.5,
step: 0.05,
default: 1.4,
} as const;
export function resolveTierFromTemperature(temperature: number): CalibrationTierId {
if (temperature <= TIER_TEMPERATURE_BOUNDARIES.aggressiveMax) return 'aggressive';
if (temperature <= TIER_TEMPERATURE_BOUNDARIES.standardMax) return 'standard';
return 'conservative';
}
export function getCalibrationTier(id: CalibrationTierId): CalibrationTier {
return CALIBRATION_TIERS.find((t) => t.id === id) ?? CALIBRATION_TIERS[1]!;
}
export function recommendedTemperatureForTier(id: CalibrationTierId): number {
return getCalibrationTier(id).recommendedT;
}

View File

@@ -0,0 +1,115 @@
import { formatAngleClinicalLabel, getAngleMeta, type AngleClass } from './angleClassification';
import { GRADE_LABELS } from './mockData';
import type { ChatReasoningLevel } from '../lib/llm/chatReasoningLevel';
import { DEFAULT_CHAT_REASONING_LEVEL, getChatReasoningLevelMeta } from '../lib/llm/chatReasoningLevel';
import type { InferenceMode } from '../lib/llm/clinicalChatModes';
import { getInferenceModeMeta } from '../lib/llm/clinicalChatModes';
import type { ClinicalChatMessage } from '../types/clinicalChat';
let messageCounter = 0;
export function createChatMessageId(): string {
messageCounter += 1;
return `chat-${Date.now()}-${messageCounter}`;
}
export function buildInitialAssistantMessage(
grade: number | null,
angleClass?: AngleClass | null,
inferenceMode: InferenceMode = 'chat',
severityLoading = false,
chatReasoningLevel: ChatReasoningLevel = DEFAULT_CHAT_REASONING_LEVEL,
): ClinicalChatMessage {
const modeHint = getInferenceModeMeta(inferenceMode);
const levelHint =
inferenceMode === 'chat' ? getChatReasoningLevelMeta(chatReasoningLevel) : null;
const angleLine = angleClass
? ` Góc quét hiện tại: ${formatAngleClinicalLabel(getAngleMeta(angleClass))}.`
: '';
let caseLine: string;
if (severityLoading) {
caseLine =
'AI đang phân tích mức viêm trên khung hình hiện tại — tôi chưa có đề xuất cụ thể để thảo luận.';
} else if (grade !== null) {
const gradeLabel = GRADE_LABELS[grade] ?? `Độ ${grade}`;
caseLine = `Khi bạn hỏi về đề xuất AI, tôi có thể thảo luận viêm màng hoạt dịch (mức ${grade}${gradeLabel}).${angleLine}`;
} else {
caseLine = `Chưa có đề xuất mức viêm từ AI trên khung hình này.${angleLine}`;
}
const levelPart =
levelHint !== null
? ` ${levelHint.welcomeHint} Chạm biểu tượng ${levelHint.emoji} trên thanh công cụ để đổi mức độ.`
: '';
return {
id: createChatMessageId(),
role: 'assistant',
content:
`Tôi là trợ lý AI lâm sàng (${modeHint.label} · ${modeHint.llmCallsHint}).${levelPart} ` +
`${caseLine} ` +
'Bạn có thể hỏi về overlay phân đoạn hoặc ghi nhận chỉnh sửa. Bạn muốn làm rõ điểm nào?',
timestamp: new Date(),
};
}
export function mockClinicalChatReply(
userText: string,
grade: number | null,
angleClass?: AngleClass | null,
): string {
const lower = userText.toLowerCase();
const gradeLabel = grade !== null ? (GRADE_LABELS[grade] ?? `Độ ${grade}`) : null;
if (/^(xin chào|chào|hello|hi|hey)[\s!.?]*$/i.test(lower.trim())) {
return 'Chào bạn. Tôi sẵn sàng hỗ trợ khi bạn muốn thảo luận đề xuất AI, overlay phân đoạn, hoặc ghi nhận chỉnh sửa.';
}
if (/hoài nghi|nghi ngờ|không tin|chưa thuyết phục|skeptic|doubt|lạ|odd|không đồng ý|không phù hợp/.test(lower)) {
return (
'Đã ghi nhận mối lo ngại của bạn. Hãy nêu cụ thể phần nào chưa phù hợp — ví dụ: mức viêm, vùng overlay, hoặc góc quét. ' +
'Tôi sẽ giải thích căn cứ AI và hỗ trợ ghi nhận chỉnh sửa có kiểm soát, không cần tạm dừng quy trình khám.'
);
}
if (/sửa|ghi đè|chỉnh|điều chỉnh|override|correct/.test(lower)) {
return (
'Đã ghi nhận yêu cầu chỉnh sửa. Mô tả mức độ hoặc vùng giải phẫu bạn muốn cập nhật — ví dụ: «Giảm độ viêm xuống 1» hoặc «Bỏ qua dịch ở hố suprapatellar». ' +
'Trong bản triển khai đầy đủ, thay đổi sẽ được lưu vào audit trail và đồng bộ EMR.'
);
}
if (/góc|angle|overlay|màu|legend|phân đoạn/.test(lower)) {
if (angleClass) {
const meta = getAngleMeta(angleClass);
const appendix = meta.segmentLegendType === 'post' ? 'A.2' : 'A.1';
return (
`${meta.labelMedicalEn}, đọc overlay theo Phụ lục ${appendix} (${meta.legendPlaneVi}). ` +
'Bạn có thể bật/tắt mask bằng checkbox «Kết quả phân đoạn».'
);
}
return 'Chuyển khung siêu âm để tôi căn cứ góc quét cụ thể khi giải thích mã màu overlay.';
}
if (/viêm|độ|grade|mức|synovitis|severity/.test(lower)) {
if (grade === null || gradeLabel === null) {
return 'Chưa có đề xuất mức viêm từ AI trên khung hình hiện tại. Hãy đợi phân tích hoàn tất hoặc chuyển khung siêu âm.';
}
return (
`Đề xuất hiện tại: mức ${grade} (${gradeLabel}), dựa trên diện tích dịch khớp và màng hoạt dịch. ` +
'Nếu bạn không đồng ý, nêu mức độ lâm sàng mong muốn — tôi sẽ hỗ trợ ghi đè có kiểm soát.'
);
}
if (/đo|thickness|mm|độ dày/.test(lower)) {
return angleClass === 'sup-up-long'
? 'Độ dày (dịch + màng hoạt dịch) chỉ được đo tự động trên Suprapatellar Upper Longitudinal. Kiểm tra HUD góc dưới ảnh khi bật overlay.'
: 'Thước đo tự động không áp dụng cho góc quét này. Chỉ Suprapatellar Upper Longitudinal có đường đo trên overlay.';
}
return (
'Cảm ơn bạn. Tôi có thể giải thích đề xuất AI, hướng dẫn đọc overlay, hoặc ghi nhận chỉnh sửa phân loại. ' +
'Hãy nêu cụ thể vùng giải phẫu hoặc mức độ bạn muốn thảo luận.'
);
}

View File

@@ -0,0 +1,72 @@
export type DiseaseProgression = 'improved' | 'stable' | 'worse';
export type PowerDopplerScore = 0 | 1 | 2 | 3;
export type NextProcedure =
| 'joint_aspiration'
| 'intra_articular_injection'
| 'synovial_biopsy'
| 'mri_ct'
| 'other'
| 'none';
export interface DiagnosticFormDraft {
diseaseProgression: DiseaseProgression | null;
powerDopplerScore: PowerDopplerScore | null;
synovialThicknessMm: string;
synovialThicknessUnknown: boolean;
anatomyDescriptionRight: string;
anatomyDescriptionLeft: string;
/** PNG data URL of annotated viewport */
diagnosisWorkImageDataUrl: string | null;
nextProcedure: NextProcedure | null;
procedureReason: string;
procedureResponsibleName: string;
doctorConclusion: string;
}
export const EMPTY_DIAGNOSTIC_FORM_DRAFT: DiagnosticFormDraft = {
diseaseProgression: null,
powerDopplerScore: null,
synovialThicknessMm: '',
synovialThicknessUnknown: false,
anatomyDescriptionRight: '',
anatomyDescriptionLeft: '',
diagnosisWorkImageDataUrl: null,
nextProcedure: null,
procedureReason: '',
procedureResponsibleName: '',
doctorConclusion: '',
};
export const DISEASE_PROGRESSION_OPTIONS: {
value: DiseaseProgression;
label: string;
}[] = [
{ value: 'improved', label: 'Tốt lên' },
{ value: 'stable', label: 'Ổn định' },
{ value: 'worse', label: 'Xấu đi' },
];
export const POWER_DOPPLER_SCORES: PowerDopplerScore[] = [0, 1, 2, 3];
export const NEXT_PROCEDURE_OPTIONS: { value: NextProcedure; label: string }[] = [
{ value: 'joint_aspiration', label: 'Chọc hút dịch khớp (Joint Aspiration)' },
{ value: 'intra_articular_injection', label: 'Tiêm nội khớp (Corticosteroid/PRP)' },
{ value: 'synovial_biopsy', label: 'Sinh thiết màng hoạt dịch' },
{ value: 'mri_ct', label: 'Chụp MRI/CT đối chiếu' },
{ value: 'other', label: 'Chỉ định khác' },
{ value: 'none', label: 'Không có' },
];
/** Carousel steps: progression → measures → anatomy (R+L) → snapshot → procedure → conclusion */
export const DIAGNOSTIC_FORM_STEP_COUNT = 6;
export const DIAGNOSTIC_FORM_STEP_TITLES = [
'Tiến triển bệnh tình so với lần khám gần nhất',
'Số liệu đo lường',
'Mô tả giải phẫu',
'Ảnh siêu âm đã chú thích',
'Chỉ định thủ thuật tiếp theo',
'Kết luận của bác sĩ',
] as const;

View File

@@ -0,0 +1,333 @@
export type DiagnosticEventKind = 'file' | 'delete' | 'edit' | 'view' | 'ai' | 'form';
export interface FrameRef {
frameId: string;
frameIndex: number;
frameLabel: string;
contentHash?: string;
imageSrc?: string;
}
export interface DiagnosticSessionEvent {
id: string;
elapsedSeconds: number;
recordedAt: string;
title: string;
detail: string;
kind: DiagnosticEventKind;
cursor?: { x: number; y: number };
clickPulse?: boolean;
frame?: FrameRef;
}
export interface PointerTraceSample {
elapsedSeconds: number;
x: number;
y: number;
frameId: string;
}
export interface DiagnosticSessionLog {
patientId: string;
totalDurationSeconds: number;
events: DiagnosticSessionEvent[];
pointerTrace?: PointerTraceSample[];
segments?: import('../lib/sessionRecordingTypes').FrameSegment[];
overlaysByFrameId?: Record<string, import('../lib/sessionRecordingTypes').FrameOverlaySnapshot>;
primaryFrameId?: string;
/** Changes when a new recording session starts — used to reset replay playback position. */
sessionKey?: string;
uixEvents?: import('../lib/uixSessionEvents').UIXSessionEvent[];
}
/** How far back (seconds) the comet tail extends during replay. */
export const POINTER_TRACE_TRAIL_SECONDS = 2.5;
const EVENT_KIND_COLORS: Record<DiagnosticEventKind, string> = {
file: '#22c55e',
delete: '#ef4444',
edit: '#38bdf8',
view: '#a78bfa',
ai: '#f59e0b',
form: '#14b8a6',
};
export function eventKindColor(kind: DiagnosticEventKind): string {
return EVENT_KIND_COLORS[kind];
}
export function formatReplayClock(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.floor(totalSeconds % 60);
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
export function formatRecordedTime(isoLike: string): string {
return isoLike;
}
function buildMockEvents(patientName: string, mrn: string): DiagnosticSessionEvent[] {
return [
{
id: 'ev-1',
elapsedSeconds: 0,
recordedAt: '14:18:02',
title: 'Bắt đầu phiên chẩn đoán',
detail: `Mở workspace cho bệnh nhân ${patientName}.`,
kind: 'view',
cursor: { x: 12, y: 18 },
},
{
id: 'ev-2',
elapsedSeconds: 42,
recordedAt: '14:18:44',
title: 'Tải khung siêu âm đầu tiên',
detail: 'Chọn khung Sup dọc — viêm (+) · #1.',
kind: 'view',
cursor: { x: 34, y: 52 },
clickPulse: true,
},
{
id: 'ev-3',
elapsedSeconds: 98,
recordedAt: '14:19:40',
title: 'Bật overlay phân đoạn',
detail: 'Hiển thị mask màng hoạt dịch trên canvas.',
kind: 'view',
cursor: { x: 28, y: 74 },
clickPulse: true,
},
{
id: 'ev-4',
elapsedSeconds: 135,
recordedAt: '14:20:17',
title: 'Hoàn tất phân tích AI',
detail: 'Đề xuất viêm màng hoạt dịch Độ 2 — độ tin cậy 78%.',
kind: 'ai',
cursor: { x: 72, y: 36 },
},
{
id: 'ev-5',
elapsedSeconds: 162,
recordedAt: '14:20:44',
title: 'Cập nhật MRN',
detail: `Sửa Mã BN (MRN) sang '${mrn.replace(/\d{4}$/, '2026')}'.`,
kind: 'edit',
cursor: { x: 86, y: 22 },
clickPulse: true,
},
{
id: 'ev-6',
elapsedSeconds: 210,
recordedAt: '14:21:32',
title: 'Tải lên tệp quét mới',
detail: 'Tải lên thành công 3 tệp DICOM mới.',
kind: 'file',
cursor: { x: 55, y: 48 },
clickPulse: true,
},
{
id: 'ev-7',
elapsedSeconds: 248,
recordedAt: '14:22:10',
title: 'Xóa tệp quét cũ',
detail: 'Đánh dấu xóa scan_sagittal_02.dcm khỏi hồ sơ.',
kind: 'delete',
cursor: { x: 88, y: 58 },
clickPulse: true,
},
{
id: 'ev-8',
elapsedSeconds: 302,
recordedAt: '14:23:04',
title: 'Lưu nháp phiếu chẩn đoán',
detail: 'Ghi nhận synovial thickness 4.2 mm vào phiếu lâm sàng.',
kind: 'form',
cursor: { x: 78, y: 68 },
clickPulse: true,
},
{
id: 'ev-9',
elapsedSeconds: 348,
recordedAt: '14:23:50',
title: 'Thảo luận kết quả AI',
detail: 'Trao đổi về vùng tràn dịch suprapatellar với trợ lý lâm sàng.',
kind: 'ai',
cursor: { x: 82, y: 44 },
},
{
id: 'ev-10',
elapsedSeconds: 390,
recordedAt: '14:24:32',
title: 'Kết thúc phiên xem xét',
detail: 'Hoàn tất rà soát trước ký xác nhận.',
kind: 'view',
cursor: { x: 92, y: 12 },
},
];
}
/** Stable id for the current recording timeline (resets replay when this changes). */
export function sessionReplayKey(patientId: string, events: DiagnosticSessionEvent[]): string {
return `${patientId}:${events[0]?.id ?? 'empty'}`;
}
/** Empty log shown before the clinician records a real session. */
export function createEmptySessionLog(patientId: string): DiagnosticSessionLog {
return {
patientId,
events: [],
totalDurationSeconds: 0,
pointerTrace: [],
segments: [],
overlaysByFrameId: {},
sessionKey: sessionReplayKey(patientId, []),
};
}
/** Demo timeline for docs / manual QA — not used in live workspace replay. */
export function getDiagnosticSessionLog(patientId: string, patientName: string, mrn: string): DiagnosticSessionLog {
const events = buildMockEvents(patientName, mrn);
const totalDurationSeconds = events[events.length - 1]?.elapsedSeconds ?? 390;
const pointerTrace = buildSyntheticTraceFromEvents(events);
return { patientId, totalDurationSeconds, events, pointerTrace };
}
export function buildSyntheticTraceFromEvents(
events: DiagnosticSessionEvent[],
stepSeconds = 0.12,
): PointerTraceSample[] {
const withCursor = events.filter((event) => event.cursor);
if (withCursor.length === 0) return [];
const samples: PointerTraceSample[] = [];
for (let index = 0; index < withCursor.length; index += 1) {
const start = withCursor[index];
const end = withCursor[index + 1];
const t0 = start.elapsedSeconds;
const t1 = end?.elapsedSeconds ?? t0 + stepSeconds;
const c0 = start.cursor!;
const c1 = end?.cursor ?? c0;
const span = Math.max(t1 - t0, stepSeconds);
const steps = Math.max(1, Math.ceil(span / stepSeconds));
for (let step = 0; step <= steps; step += 1) {
const elapsedSeconds = Math.round((t0 + (step / steps) * span) * 10) / 10;
const ratio = span > 0 ? (elapsedSeconds - t0) / span : 0;
const frameId = start.frame?.frameId ?? end?.frame?.frameId ?? '';
samples.push({
elapsedSeconds,
x: c0.x + (c1.x - c0.x) * ratio,
y: c0.y + (c1.y - c0.y) * ratio,
frameId,
});
}
}
return samples;
}
export function interpolatePositionAtTime(
trace: PointerTraceSample[],
elapsedSeconds: number,
): { x: number; y: number } | null {
if (trace.length === 0) return null;
let before = trace[0];
let after = trace[trace.length - 1];
for (const sample of trace) {
if (sample.elapsedSeconds <= elapsedSeconds) before = sample;
if (sample.elapsedSeconds >= elapsedSeconds) {
after = sample;
break;
}
}
if (before.elapsedSeconds === after.elapsedSeconds) {
return { x: before.x, y: before.y };
}
const ratio =
(elapsedSeconds - before.elapsedSeconds) / (after.elapsedSeconds - before.elapsedSeconds);
return {
x: before.x + (after.x - before.x) * ratio,
y: before.y + (after.y - before.y) * ratio,
};
}
export function traceWindowAtTime(
trace: PointerTraceSample[],
elapsedSeconds: number,
windowSeconds = POINTER_TRACE_TRAIL_SECONDS,
activeFrameId?: string,
): PointerTraceSample[] {
const windowStart = Math.max(0, elapsedSeconds - windowSeconds);
return trace.filter((sample) => {
if (sample.elapsedSeconds < windowStart || sample.elapsedSeconds > elapsedSeconds) {
return false;
}
if (activeFrameId && sample.frameId && sample.frameId !== activeFrameId) {
return false;
}
return true;
});
}
export function eventAtTime(events: DiagnosticSessionEvent[], elapsedSeconds: number): DiagnosticSessionEvent {
let active = events[0];
for (const event of events) {
if (event.elapsedSeconds <= elapsedSeconds) active = event;
else break;
}
return active;
}
export function tooltipForEvent(event: DiagnosticSessionEvent): string {
return `${event.title}${formatReplayClock(event.elapsedSeconds)}`;
}
export function formatNowClock(date = new Date()): string {
return date.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function computeTotalDuration(events: DiagnosticSessionEvent[]): number {
if (events.length === 0) return 0;
return events[events.length - 1].elapsedSeconds;
}
export function computePlaybackDuration(
events: DiagnosticSessionEvent[],
pointerTrace?: PointerTraceSample[],
): number {
const eventEnd = computeTotalDuration(events);
const traceEnd =
pointerTrace && pointerTrace.length > 0
? pointerTrace[pointerTrace.length - 1].elapsedSeconds
: 0;
return Math.max(eventEnd, traceEnd, 1);
}
export function createSessionStartEvent(patientName: string): DiagnosticSessionEvent {
return {
id: crypto.randomUUID(),
elapsedSeconds: 0,
recordedAt: formatNowClock(),
title: 'Bắt đầu phiên chẩn đoán',
detail: `Mở workspace cho bệnh nhân ${patientName}.`,
kind: 'view',
cursor: { x: 12, y: 18 },
};
}
export function createSessionEndEvent(elapsedSeconds: number): DiagnosticSessionEvent {
return {
id: crypto.randomUUID(),
elapsedSeconds: Math.round(elapsedSeconds * 10) / 10,
recordedAt: formatNowClock(),
title: 'Kết thúc ghi phiên',
detail: 'Phiên ghi đã hoàn tất — xem lại danh sách hành động bên dưới.',
kind: 'view',
cursor: { x: 50, y: 50 },
};
}

View File

@@ -0,0 +1,104 @@
import type { CalibratedPrediction, DecisionState } from '../types/calibratedPrediction';
import {
estimateMisclassificationRate,
formatRiskFramingVi,
} from '../types/calibratedPrediction';
export const INFLAMMATION_CLASSES = ['no_inflammation', 'inflammation'] as const;
export type InflammationClass = (typeof INFLAMMATION_CLASSES)[number];
export const INFLAMMATION_LABELS: Record<InflammationClass, string> = {
no_inflammation: 'Không viêm',
inflammation: 'Có viêm',
};
export interface InflammationClassificationResult {
detected: boolean;
/** P(inflammation) in 01 */
confidence: number;
calibration?: CalibratedPrediction;
}
function shannonEntropy(probs: number[]): number {
return -probs.reduce((sum, p) => sum + (p > 0 ? p * Math.log(p) : 0), 0);
}
export function buildMockInflammationCalibration(
inflammationProb: number,
): CalibratedPrediction {
const pInflam = Math.min(0.99, Math.max(0.01, inflammationProb));
const pNo = 1 - pInflam;
const rawProbs = [pNo, pInflam];
const normEntropy = shannonEntropy(rawProbs) / Math.log(2);
const ambiguousSet = INFLAMMATION_CLASSES.filter((_, i) => rawProbs[i]! >= Math.max(...rawProbs) - 0.05);
let decisionState: DecisionState = 'confident';
if (normEntropy >= 0.88) decisionState = 'ood_warning';
else if (ambiguousSet.length > 1) decisionState = 'ambiguous';
const predictedClass: InflammationClass = pInflam >= pNo ? 'inflammation' : 'no_inflammation';
const errorRate = estimateMisclassificationRate(Math.max(pInflam, pNo));
const noLogit = Math.log(pNo / (pInflam + 1e-6));
const inflamLogit = Math.log(pInflam / (pNo + 1e-6));
return {
raw_logits: [noLogit, inflamLogit],
temperature: 1,
base_temperature: 1,
mode: 'standard',
clinical_suspicion: 0,
alpha_margin: 0.05,
class_probabilities: {
no_inflammation: Math.round(pNo * 10000) / 100,
inflammation: Math.round(pInflam * 10000) / 100,
},
entropy: shannonEntropy(rawProbs),
normalized_entropy: normEntropy,
ambiguous_set: ambiguousSet,
decision_state: decisionState,
predicted_error_rate: errorRate,
risk_framing_vi: formatRiskFramingVi(
INFLAMMATION_LABELS[predictedClass],
Math.max(pInflam, pNo),
decisionState,
),
};
}
export function buildInflammationFromConfidence(confidence: number): InflammationClassificationResult {
const inflammationProb = confidence > 1 ? confidence / 100 : confidence;
const detected = inflammationProb >= 0.5;
return {
detected,
confidence: inflammationProb,
calibration: buildMockInflammationCalibration(inflammationProb),
};
}
export function normalizeBackendInflammation(
raw:
| {
detected?: boolean;
confidence: number | null;
calibration?: CalibratedPrediction;
}
| undefined,
fallbackConfidence?: number,
): InflammationClassificationResult | null {
if (raw) {
const confidence =
raw.confidence != null
? raw.confidence > 1
? raw.confidence / 100
: raw.confidence
: 0.5;
return {
detected: raw.detected ?? confidence >= 0.5,
confidence,
calibration: raw.calibration,
};
}
if (fallbackConfidence !== undefined) {
return buildInflammationFromConfidence(fallbackConfidence);
}
return null;
}

View File

@@ -0,0 +1,136 @@
export type PatientStatus = 'urgent' | 'processing' | 'followup' | 'ready';
export interface PatientCase {
id: string;
name: string;
mrn: string;
joint: string;
status: PatientStatus;
statusLabel: string;
lastScan: string;
// UNUSED — was intended as mock ML fallback; app uses live inference + IndexedDB cache instead.
// synovitisGrade?: number;
// confidence?: number;
}
// ─── UNUSED: planned NFR dashboard (Profile / Bento) — no imports in src/ ───
// export interface NFRMetrics {
// bundleSizeMb: number;
// inferenceLatencyMs: number;
// memoryMb: number;
// offlineReady: boolean;
// }
// ─── UNUSED: planned EMR sync status widget — no imports in src/ ───
// export interface EMRStatus {
// synced: boolean;
// lastSync: string;
// pendingCount: number;
// }
export const GRADE_LABELS: Record<number, string> = {
0: 'Rất nhẹ',
1: 'Nhẹ',
2: 'Trung bình',
3: 'Nặng',
};
/** Placeholder attending physician for sign-off audit labels */
export const MOCK_ATTENDING_PHYSICIAN = 'BS. Trần Minh Khoa';
export const MOCK_PATIENTS: PatientCase[] = [
{
id: 'p-001',
name: 'Nguyễn Văn An',
mrn: 'BN-2024-1847',
joint: 'Khớp gối phải',
status: 'urgent',
statusLabel: 'Khẩn cấp',
lastScan: '09:42',
// synovitisGrade: 3,
// confidence: 0.62,
},
{
id: 'p-002',
name: 'Trần Thị Bích',
mrn: 'BN-2024-1923',
joint: 'Khớp gối trái',
status: 'processing',
statusLabel: 'Đang xử lý',
lastScan: '10:15',
// synovitisGrade: 2,
// confidence: 0.84,
},
{
id: 'p-003',
name: 'Lê Hoàng Minh',
mrn: 'BN-2024-2011',
joint: 'Khớp gối phải',
status: 'followup',
statusLabel: 'Theo dõi',
lastScan: 'Hôm qua',
// synovitisGrade: 1,
// confidence: 0.91,
},
{
id: 'p-004',
name: 'Phạm Thu Hà',
mrn: 'BN-2024-2088',
joint: 'Khớp gối trái',
status: 'ready', // used for card chip only; no dedicated worklist filter tab
statusLabel: 'Sẵn sàng',
lastScan: '08:30',
// synovitisGrade: 0,
// confidence: 0.96,
},
];
// ─── UNUSED: NFR metrics mock — no imports in src/ ───
// export const MOCK_NFR: NFRMetrics = {
// bundleSizeMb: 142,
// inferenceLatencyMs: 1240,
// memoryMb: 118,
// offlineReady: true,
// };
// ─── UNUSED: EMR sync mock — no imports in src/ ───
// export const MOCK_EMR: EMRStatus = {
// synced: true,
// lastSync: '10:28',
// pendingCount: 0,
// };
// ─── UNUSED: planned AI reasoning panel — no imports in src/ ───
// export const CLINICAL_REASONING = [
// {
// zone: 'Hố suprapatellar',
// focus: 'Tăng tín hiệu echo-free dày 4.2 mm',
// weight: 'Cao',
// },
// {
// zone: 'Màng hoạt dịch viền',
// focus: 'Tăng sinh mạch (Doppler grade 2)',
// weight: 'Trung bình',
// },
// {
// zone: 'Dây chằng bánh chè',
// focus: 'Không có dấu hiệu viêm',
// weight: 'Thấp',
// },
// ];
// ─── UNUSED: SafetyEscalationOverlay exists but is not mounted in App routes ───
// export const SOCRATIC_PROMPTS = [
// 'Vùng dày echo-free ở hố suprapatellar — bạn xác nhận đây là tràn dịch nhẹ hay mô pannus?',
// 'Tăng sinh mạch Doppler grade 2 có phù hợp với viêm màng hoạt dịch độ 2 không?',
// 'Có cấu trúc giả âm (acoustic shadow) nào cần loại trừ khỏi chấm điểm không?',
// ];
//
// export const MORPHOLOGY_TEMPLATE = [
// { id: 'effusion', label: 'Tràn dịch khớp', checked: false },
// { id: 'synovium', label: 'Dày màng hoạt dịch', checked: true },
// { id: 'pannus', label: 'Mô pannus', checked: false },
// { id: 'erosion', label: 'Xói mòn xương', checked: false },
// { id: 'shadow', label: 'Bóng âm thanh giả', checked: false },
// { id: 'difference', label: 'Khác', checked: false },
// ];

View File

@@ -0,0 +1,108 @@
import type { MockMaskPolygon } from '../types/segmentationAnalysis';
/**
* Organic mask contours (normalized 01) simulating backend pixel-mask boundaries.
* Dense vertices along wavy tissue layers — not ellipses or axis-aligned boxes.
*/
export const ORGANIC_MASK_MED_LAT_1: MockMaskPolygon[] = [
{
type: 'polygon',
classKey: 'fat',
points: [
[0.06, 0.08], [0.14, 0.06], [0.22, 0.09], [0.31, 0.07], [0.40, 0.10],
[0.50, 0.07], [0.60, 0.09], [0.70, 0.08], [0.80, 0.10], [0.90, 0.08],
[0.94, 0.14], [0.88, 0.19], [0.78, 0.17], [0.68, 0.20], [0.58, 0.18],
[0.48, 0.21], [0.38, 0.19], [0.28, 0.20], [0.18, 0.18], [0.08, 0.16],
],
},
{
type: 'polygon',
classKey: 'tendon',
points: [
[0.05, 0.19], [0.12, 0.21], [0.20, 0.20], [0.28, 0.23], [0.36, 0.21],
[0.44, 0.24], [0.52, 0.22], [0.60, 0.25], [0.68, 0.23], [0.76, 0.26],
[0.84, 0.24], [0.92, 0.27], [0.95, 0.33], [0.88, 0.36], [0.80, 0.34],
[0.72, 0.37], [0.64, 0.35], [0.56, 0.38], [0.48, 0.36], [0.40, 0.39],
[0.32, 0.37], [0.24, 0.38], [0.16, 0.36], [0.08, 0.37], [0.04, 0.32],
],
},
{
type: 'polygon',
classKey: 'effusion',
points: [
[0.10, 0.36], [0.18, 0.38], [0.26, 0.37], [0.34, 0.40], [0.42, 0.39],
[0.50, 0.41], [0.58, 0.40], [0.66, 0.42], [0.74, 0.41], [0.82, 0.43],
[0.90, 0.42], [0.93, 0.48], [0.86, 0.52], [0.78, 0.50], [0.70, 0.53],
[0.62, 0.51], [0.54, 0.54], [0.46, 0.52], [0.38, 0.55], [0.30, 0.53],
[0.22, 0.54], [0.14, 0.52], [0.08, 0.50], [0.06, 0.44],
],
},
{
type: 'polygon',
classKey: 'synovium',
points: [
[0.08, 0.50], [0.16, 0.52], [0.24, 0.51], [0.32, 0.54], [0.40, 0.53],
[0.48, 0.55], [0.56, 0.54], [0.64, 0.56], [0.72, 0.55], [0.80, 0.57],
[0.88, 0.56], [0.92, 0.62], [0.85, 0.66], [0.77, 0.64], [0.69, 0.67],
[0.61, 0.65], [0.53, 0.68], [0.45, 0.66], [0.37, 0.69], [0.29, 0.67],
[0.21, 0.68], [0.13, 0.66], [0.07, 0.63],
],
},
{
type: 'polygon',
classKey: 'fat-pat',
points: [
[0.06, 0.64], [0.14, 0.66], [0.22, 0.65], [0.30, 0.68], [0.38, 0.67],
[0.46, 0.70], [0.54, 0.69], [0.62, 0.71], [0.70, 0.70], [0.78, 0.72],
[0.86, 0.71], [0.93, 0.75], [0.90, 0.81], [0.82, 0.83], [0.74, 0.81],
[0.66, 0.84], [0.58, 0.82], [0.50, 0.85], [0.42, 0.83], [0.34, 0.84],
[0.26, 0.82], [0.18, 0.83], [0.10, 0.81], [0.05, 0.76],
],
},
{
type: 'polygon',
classKey: 'femur',
points: [
[0.04, 0.80], [0.12, 0.82], [0.20, 0.81], [0.28, 0.84], [0.36, 0.83],
[0.44, 0.86], [0.52, 0.85], [0.60, 0.87], [0.68, 0.86], [0.76, 0.88],
[0.84, 0.87], [0.92, 0.89], [0.96, 0.94], [0.90, 0.98], [0.82, 0.97],
[0.74, 0.99], [0.66, 0.97], [0.58, 0.99], [0.50, 0.97], [0.42, 0.99],
[0.34, 0.97], [0.26, 0.98], [0.18, 0.96], [0.10, 0.97], [0.04, 0.93],
],
},
// Small detached fragment (like reference blue speck on left)
{
type: 'polygon',
classKey: 'tendon',
points: [
[0.02, 0.24], [0.05, 0.23], [0.06, 0.27], [0.04, 0.29], [0.01, 0.28],
],
},
];
function shiftPolygon(poly: MockMaskPolygon, dx: number, dy: number): MockMaskPolygon {
return {
...poly,
points: poly.points.map(([x, y]) => [x + dx, y + dy] as [number, number]),
};
}
function scalePolygon(poly: MockMaskPolygon, sx: number, sy: number): MockMaskPolygon {
return {
...poly,
points: poly.points.map(([x, y]) => [x * sx, y * sy] as [number, number]),
};
}
export function getOrganicMasksForFrame(frameId: string): MockMaskPolygon[] {
switch (frameId) {
case 'med-lat-1':
return ORGANIC_MASK_MED_LAT_1;
case 'med-lat-2':
return ORGANIC_MASK_MED_LAT_1.map((poly) => shiftPolygon(poly, 0.02, 0.02));
case 'trans-flex':
return ORGANIC_MASK_MED_LAT_1.map((poly) => scalePolygon(shiftPolygon(poly, -0.01, 0.03), 0.96, 0.94));
default:
return ORGANIC_MASK_MED_LAT_1;
}
}

View File

@@ -0,0 +1,17 @@
import type { SegmentationAnalysisResult } from '../types/segmentationAnalysis';
// --- Detached mock segmentation (prototyping uses /api/test/analyze/batch only) ---
// import {
// angleSupportsThicknessMeasurement,
// getMockAngleForFrame,
// getSegmentLegendTypeForAngle,
// type AngleClass,
// } from './angleClassification';
// import { getOrganicMasksForFrame } from './mockOrganicMasks';
// import { SEGMENTATION_LEGEND_POST, SEGMENTATION_LEGEND_SUP } from './segmentationLegend';
// ... MOCK_SEGMENTATION_BY_FRAME built from canvas masks ...
/** @deprecated Mock path detached — always returns null. */
export function getMockSegmentationForFrame(_frameId: string): SegmentationAnalysisResult | null {
return null;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1,76 @@
import type { PatientStatus } from './mockData';
export interface PatientStatusOption {
value: PatientStatus;
label: string;
chipClass: string;
}
export const PATIENT_STATUS_OPTIONS: PatientStatusOption[] = [
{ value: 'urgent', label: 'Khẩn cấp', chipClass: 'chip-urgent' },
{ value: 'processing', label: 'Đang xử lý', chipClass: 'chip-processing' },
{ value: 'followup', label: 'Theo dõi', chipClass: 'chip-followup' },
{ value: 'ready', label: 'Sẵn sàng', chipClass: 'chip-success' },
];
export const JOINT_OPTIONS = [
'Khớp gối trái',
'Khớp gối phải',
'Khớp vai trái',
'Khớp vai phải',
'Khớp khuỷu trái',
'Khớp khuỷu phải',
'Khớp cổ tay trái',
'Khớp cổ tay phải',
] as const;
export function statusLabelFor(status: PatientStatus): string {
return PATIENT_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status;
}
export function formatLastScanFromDate(date: Date): string {
const now = new Date();
const sameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
if (sameDay) {
return date.toLocaleTimeString('vi-VN', { hour: '2-digit', minute: '2-digit', hour12: false });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
const isYesterday =
date.getFullYear() === yesterday.getFullYear() &&
date.getMonth() === yesterday.getMonth() &&
date.getDate() === yesterday.getDate();
if (isYesterday) return 'Hôm qua';
return date.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export function deriveLastScanFromFiles(files: File[]): string {
if (files.length === 0) return '—';
const latest = Math.max(...files.map((file) => file.lastModified));
return formatLastScanFromDate(new Date(latest));
}
const ACCEPTED_SCAN_EXTENSIONS = ['.dcm', '.dicom', '.png', '.jpg', '.jpeg'] as const;
const ACCEPTED_SCAN_MIME_TYPES = new Set([
'image/png',
'image/jpeg',
'application/dicom',
]);
export const ACCEPTED_SCAN_FILE_INPUT =
'.dcm,.dicom,.png,.jpg,.jpeg,image/png,image/jpeg,application/dicom';
export function isAcceptedScanFile(file: File): boolean {
const lower = file.name.toLowerCase();
const byExtension = ACCEPTED_SCAN_EXTENSIONS.some((ext) => lower.endsWith(ext));
const byMime = file.type.length > 0 && ACCEPTED_SCAN_MIME_TYPES.has(file.type);
return byExtension || byMime;
}

View File

@@ -0,0 +1,46 @@
/** Auto-generated by backend/tests/sample_patient_profiles.py — do not edit. */
import type { ScanFrame } from './scanFrames';
export interface PatientScanProfileFrame extends ScanFrame {
stratum: string;
expectedAngleClass?: string;
sourcePath: string;
}
export const PATIENT_SCAN_PROFILES: Record<string, PatientScanProfileFrame[]> = {
"p-001": [
{ id: "p-001-sup-long-pos-0", src: "/assets/patient-profiles/p-001/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png", },
{ id: "p-001-sup-long-neg-0", src: "/assets/patient-profiles/p-001/sup-long-neg-0.png", label: "Sup dọc — không viêm () · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png", },
{ id: "p-001-post-trans-pos-0", src: "/assets/patient-profiles/p-001/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-001-post-trans-neg-0", src: "/assets/patient-profiles/p-001/post-trans-neg-0.png", label: "Sau ngang — không viêm () · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-001-other-0", src: "/assets/patient-profiles/p-001/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "sup-trans-flex", sourcePath: "backend/tests/test_images/other_angle/trans_flex.png", },
],
"p-002": [
{ id: "p-002-sup-long-pos-0", src: "/assets/patient-profiles/p-002/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png", },
{ id: "p-002-sup-long-neg-0", src: "/assets/patient-profiles/p-002/sup-long-neg-0.png", label: "Sup dọc — không viêm () · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png", },
{ id: "p-002-post-trans-pos-0", src: "/assets/patient-profiles/p-002/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-002-post-trans-neg-0", src: "/assets/patient-profiles/p-002/post-trans-neg-0.png", label: "Sau ngang — không viêm () · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb322d-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-002-other-0", src: "/assets/patient-profiles/p-002/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "sup-trans-flex", sourcePath: "backend/tests/test_images/other_angle/trans_flex.png", },
],
"p-003": [
{ id: "p-003-sup-long-pos-0", src: "/assets/patient-profiles/p-003/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png", },
{ id: "p-003-sup-long-neg-0", src: "/assets/patient-profiles/p-003/sup-long-neg-0.png", label: "Sup dọc — không viêm () · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png", },
{ id: "p-003-post-trans-pos-0", src: "/assets/patient-profiles/p-003/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-003-post-trans-neg-0", src: "/assets/patient-profiles/p-003/post-trans-neg-0.png", label: "Sau ngang — không viêm () · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-003-other-0", src: "/assets/patient-profiles/p-003/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "med-lat", sourcePath: "backend/tests/test_images/other_angle/med-lat_1.png", },
],
"p-004": [
{ id: "p-004-sup-long-pos-0", src: "/assets/patient-profiles/p-004/sup-long-pos-0.png", label: "Sup dọc — viêm (+) · #1", stratum: "sup-up-long_positive", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_positive/72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png", },
{ id: "p-004-sup-long-neg-0", src: "/assets/patient-profiles/p-004/sup-long-neg-0.png", label: "Sup dọc — không viêm () · #1", stratum: "sup-up-long_negative", expectedAngleClass: "sup-up-long", sourcePath: "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png", },
{ id: "p-004-post-trans-pos-0", src: "/assets/patient-profiles/p-004/post-trans-pos-0.png", label: "Sau ngang — viêm (+) · #1", stratum: "post_trans_positive", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-004-post-trans-neg-0", src: "/assets/patient-profiles/p-004/post-trans-neg-0.png", label: "Sau ngang — không viêm () · #1", stratum: "post_trans_negative", expectedAngleClass: "post-trans", sourcePath: "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png", },
{ id: "p-004-other-0", src: "/assets/patient-profiles/p-004/other-0.png", label: "Góc khác (med-lat / sup-trans-flex) · #1", stratum: "other_angle", expectedAngleClass: "sup-trans-flex", sourcePath: "backend/tests/test_images/other_angle/trans_flex.png", },
],
};
export function getScanFramesForPatient(patientId: string): PatientScanProfileFrame[] {
return PATIENT_SCAN_PROFILES[patientId] ?? PATIENT_SCAN_PROFILES['p-001'] ?? [];
}
export const FRAMES_PER_PATIENT = 5;
export const IMAGES_PER_STRATUM = 1;

View File

@@ -0,0 +1,15 @@
import medLat1 from './other_angle/med-lat_1.png';
import medLat2 from './other_angle/med_lat_2.png';
import transFlex from './other_angle/trans_flex.png';
export interface ScanFrame {
id: string;
src: string;
label: string;
}
export const SCAN_FRAMES: ScanFrame[] = [
{ id: 'med-lat-1', src: medLat1, label: 'Thành trong — góc 1' },
{ id: 'med-lat-2', src: medLat2, label: 'Thành trong — góc 2' },
{ id: 'trans-flex', src: transFlex, label: 'Ngang — gấp' },
];

Some files were not shown because too many files have changed in this diff Show More