sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Dev image for the principal-investigator SPA — built within the npm workspace (context = repo root).
# In docker-compose the workspace dirs are bind-mounted and deps are reinstalled on start,
# so this image just needs Node + a warm install for the first run.
FROM node:22-alpine
WORKDIR /app
# Workspace manifests first (layer cache). All four are needed so the workspace install resolves.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY frontend_user/package.json ./frontend_user/
COPY frontend_admin/package.json ./frontend_admin/
COPY frontend_investigator/package.json ./frontend_investigator/
COPY frontend_publisher/package.json ./frontend_publisher/
RUN npm install
# Source (overridden by bind mounts in compose; baked so `docker run` also works).
COPY shared ./shared
COPY frontend_investigator ./frontend_investigator
EXPOSE 5175
CMD ["sh", "-c", "npm install && npm run dev -w frontend_investigator -- --host 0.0.0.0 --port 5175"]
+34
View File
@@ -0,0 +1,34 @@
# Production image for the principal-investigator SPA (frontend_investigator).
# Multi-stage: build the minified bundle in the npm workspace, then serve it with nginx.
# The runtime image holds ONLY static files — no Node, no source, no Vite dev server.
#
# Build context = repo ROOT (the npm workspace):
# docker build -f frontend_investigator/Dockerfile.prod -t frontend_investigator .
# ---- build stage ----
FROM node:22-alpine AS build
WORKDIR /app
# Workspace manifests first (layer cache); all four are needed for `npm ci` to resolve.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY frontend_user/package.json ./frontend_user/
COPY frontend_admin/package.json ./frontend_admin/
COPY frontend_investigator/package.json ./frontend_investigator/
COPY frontend_publisher/package.json ./frontend_publisher/
RUN npm ci
# Only the sources this app needs: the PI app + the shared kernel (consumed as source).
COPY shared ./shared
COPY frontend_investigator ./frontend_investigator
# Vite production build → /app/frontend_investigator/dist (minified, sourcemap:false).
RUN npm run build -w frontend_investigator
# ---- runtime stage (static only) ----
FROM nginx:1.27-alpine
RUN rm -rf /usr/share/nginx/html/*
COPY frontend_investigator/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/frontend_investigator/dist /usr/share/nginx/html
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UMP · Quản lý đề tài nghiên cứu</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=Inter:wght@400;500;600;700&family=Merriweather:wght@400;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
# Production SPA + API reverse proxy for the applicant app (frontend_user, port 8080).
# Serves the minified Vite build only — no dev server, no /src, no sourcemaps. TLS terminates
# on the host reverse proxy (Caddy/nginx); this listens HTTP inside the Docker network.
server {
listen 8080;
server_name _;
server_tokens off; # do not advertise the nginx version
root /usr/share/nginx/html;
index index.html;
# Defense-in-depth headers (inherited by locations that set no add_header of their own).
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# A Content-Security-Policy is recommended but must enumerate the app's real sources.
# The applicant PDF export currently pulls @react-pdf NotoSerif fonts from remote URLs —
# self-host those first, then enable a locked-down policy, e.g.:
# add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'" always;
client_max_body_size 50m;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# Same-origin API + submitted PDFs → backend (the browser never talks to be0 directly).
location /api/ {
proxy_pass http://be0:4402;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 300;
proxy_send_timeout 300;
proxy_read_timeout 300;
}
location /submitted-initiatives/ {
proxy_pass http://be0:4402;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Content-addressed build assets (hashed filenames) — safe to cache hard.
# Only `expires` here so the server-level security headers are still inherited.
location /assets/ {
expires 1y;
try_files $uri =404;
}
# SPA history fallback — unknown routes return index.html, never a listing or source file.
location / {
try_files $uri $uri/ /index.html;
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend_investigator",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@ump/shared": "*",
"@tanstack/react-query": "^5.83.0",
"axios": "^1.13.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"sonner": "^1.7.4"
},
"devDependencies": {
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.8.3",
"vite": "^5.4.19",
"vitest": "^3.2.6"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

+87
View File
@@ -0,0 +1,87 @@
import { lazy, Suspense, type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth, Toaster, ForgotPasswordPage, ResetPasswordPage, RegistrationWithOtp } from '@ump/shared';
import { LoginPage } from './pages/LoginPage';
import { DatasetsListPage } from './pages/DatasetsListPage';
import { DatasetCreatePage } from './pages/DatasetCreatePage';
import { DatasetDetailPage } from './pages/DatasetDetailPage';
import { DatasetSettingsPage } from './pages/DatasetSettingsPage';
import { DataPage } from './features/project-workflow/presentation/DataPage';
import { NnunetOrganizerPage } from './features/data-import/presentation/nnunet/NnunetOrganizerPage';
import { ProjectsListPage } from './pages/ProjectsListPage';
import { CockpitPage } from './pages/CockpitPage';
import { ProposalFormPage } from './pages/ProposalFormPage';
import { ComingSoonPage } from './pages/ComingSoonPage';
import { VideoViewerDemoPage } from './pages/VideoViewerDemoPage';
import { ImageSequenceDemoPage } from './pages/ImageSequenceDemoPage';
import { DashboardLayout } from './layouts/DashboardLayout';
// VTK-heavy; lazy so the viewer chunk loads only when a task is opened.
const AnnotationTool = lazy(() => import('./features/project-workflow/presentation/AnnotationTool'));
const queryClient = new QueryClient();
function RequireAuth({ children }: { children: ReactNode }) {
const { isAuthenticated, loading } = useAuth();
if (loading) return <div style={{ padding: 24 }}>Đang tải</div>;
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/register" element={<RegistrationWithOtp variant="applicant" loginPath="/login" />} />
{/* Authed PI shell: sidebar + header wrap the routed content. */}
<Route
element={
<RequireAuth>
<DashboardLayout />
</RequireAuth>
}
>
<Route path="/dashboard" element={<DatasetsListPage />} />
{/* Research-project journey: projects list → proposal → (admin approve) → cockpit. */}
<Route path="/dashboard/projects" element={<ProjectsListPage />} />
<Route path="/dashboard/projects/:id" element={<CockpitPage />} />
<Route path="/dashboard/proposals/new" element={<ProposalFormPage />} />
<Route path="/dashboard/proposals/:id" element={<ProposalFormPage />} />
<Route path="/dashboard/datasets/new" element={<DatasetCreatePage />} />
<Route path="/dashboard/datasets/:id" element={<DatasetDetailPage />} />
<Route path="/dashboard/datasets/:id/settings" element={<DatasetSettingsPage />} />
<Route path="/dashboard/datasets/:id/organize" element={<NnunetOrganizerPage />} />
<Route path="/dashboard/datasets/:id/tasks" element={<DataPage />} />
<Route
path="/dashboard/datasets/:id/tasks/:taskId"
element={
<Suspense fallback={<div style={{ padding: 24 }}>Đang tải trình chú thích</div>}>
<AnnotationTool />
</Suspense>
}
/>
{/* Sidebar targets whose feature pages are not built yet → ComingSoon. */}
<Route path="/dashboard/data/text" element={<ComingSoonPage />} />
<Route path="/dashboard/data/audio" element={<ComingSoonPage />} />
<Route path="/dashboard/video-viewer" element={<VideoViewerDemoPage />} />
<Route path="/dashboard/image-viewer" element={<ImageSequenceDemoPage />} />
<Route path="/dashboard/notifications" element={<ComingSoonPage />} />
<Route path="/dashboard/help" element={<ComingSoonPage />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
<Toaster />
</AuthProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,430 @@
import { useEffect, useMemo, useState } from 'react';
import { UnifiedQuadViewRenderer, loadNiftiImageData, labelValues, extractBinaryLabel } from '@ump/shared/viewer';
import type { Annotation, AnnotationTool, OrganMaskData } from '@ump/shared/viewer';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
TooltipProvider,
Button,
fileDownloadUrl,
fetchAsFile,
} from '@ump/shared';
import { MousePointer2, Square, Circle, PenLine, Brush, Hexagon, Trash2, Eye, EyeOff, Loader2, Pencil, Check, X } from 'lucide-react';
import { toast } from 'sonner';
import { organDisplayName } from './totalSegmentatorLabels';
/** A linked organ mask available to overlay on the parent image (Phase D). */
export interface AvailableMask {
id: string;
organLabel: string;
logicalPath: string;
/** When true, this is a single multi-label volume (e.g. TotalSegmentator): the viewer loads it
* once and expands it into one toggleable overlay per distinct integer label. */
multiLabel?: boolean;
}
/** One toggleable overlay row — a whole single-file mask, or one value of a multi-label volume. */
interface OrganEntry {
key: string;
name: string;
maskId: string;
/** Set only for a multi-label sub-organ: the integer label value to extract. */
value?: number;
}
interface DatasetFileViewerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
file: File | null;
fileName: string;
datasetId: string;
/** Organ masks linked to this image; each can be toggled as a coloured 3D overlay. */
masks?: AvailableMask[];
/** Per-dataset integer-label → name map; overrides the built-in TotalSegmentator names. */
labelMap?: Record<string, string>;
/** Persist a renamed multi-label organ (value → name). Enables the inline rename control. */
onRenameLabel?: (value: number, name: string) => void;
}
// Sensible soft-tissue defaults; the viewer detects DICOM vs NIfTI from the file name.
const DEFAULTS = { windowWidth: 400, windowLevel: 40, opacity: 0.8 };
// Distinct overlay colours (0-255 RGB), assigned to organs by list order.
const ORGAN_PALETTE: [number, number, number][] = [
[239, 68, 68], [34, 197, 94], [59, 130, 246], [234, 179, 8],
[168, 85, 247], [236, 72, 153], [20, 184, 166], [249, 115, 22],
];
const TOOLS: { id: AnnotationTool; label: string; Icon: typeof Square }[] = [
{ id: 'none', label: 'Chọn', Icon: MousePointer2 },
{ id: 'bbox', label: 'Khung', Icon: Square },
{ id: 'points', label: 'Điểm', Icon: Circle },
{ id: 'pen', label: 'Bút', Icon: PenLine },
{ id: 'brush', label: 'Cọ', Icon: Brush },
{ id: 'polygon', label: 'Đa giác', Icon: Hexagon },
];
const TOOL_LABEL: Record<string, string> = {
bbox: 'Khung', points: 'Điểm', pen: 'Bút', brush: 'Cọ', polygon: 'Đa giác',
};
const VIEW_LABEL: Record<string, string> = { axial: 'Axial', coronal: 'Coronal', sagittal: 'Sagittal' };
/**
* Lazy-loaded host for the ImageHub 3D viewer (statically imports `@ump/shared/viewer`
* → @kitware/vtk.js, so it must stay behind a React.lazy boundary). Full-screen
* layout: top control bar, then a body row of the quad-view + an annotation side panel.
*/
export default function DatasetFileViewerDialog({
open,
onOpenChange,
file,
fileName,
datasetId,
masks = [],
labelMap,
onRenameLabel,
}: DatasetFileViewerDialogProps) {
const [windowWidth, setWindowWidth] = useState(DEFAULTS.windowWidth);
const [windowLevel, setWindowLevel] = useState(DEFAULTS.windowLevel);
const [opacity, setOpacity] = useState(DEFAULTS.opacity);
const [tool, setTool] = useState<AnnotationTool>('none');
const [annotations, setAnnotations] = useState<Annotation[]>([]);
// Organ-mask overlays. A single-file mask is one overlay; a multi-label mask (multiLabel) is
// loaded once and expanded into one overlay per distinct integer label. Loaded vtkImageData is
// keyed by entry key, so toggling off→on is instant after the first load.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [organMaskById, setOrganMaskById] = useState<Record<string, OrganMaskData>>({});
const [loadingId, setLoadingId] = useState<string | null>(null);
const [labelVols, setLabelVols] = useState<Record<string, { imageData: any; values: number[] }>>({});
const [enumerating, setEnumerating] = useState(false);
// Inline rename of a multi-label organ: { value being edited, draft text }.
const [editing, setEditing] = useState<{ value: number; draft: string } | null>(null);
const colorFor = (i: number): [number, number, number] => ORGAN_PALETTE[i % ORGAN_PALETTE.length];
// Load each multi-label mask once to enumerate the organs (integer labels) it contains.
useEffect(() => {
const pending = masks.filter((m) => m.multiLabel && !labelVols[m.id]);
if (pending.length === 0) return;
let cancelled = false;
(async () => {
setEnumerating(true);
for (const m of pending) {
try {
const { url } = await fileDownloadUrl(datasetId, m.id);
const maskFile = await fetchAsFile(url, m.logicalPath);
const imageData = await loadNiftiImageData(maskFile);
const values = labelValues(imageData);
if (!cancelled) setLabelVols((prev) => ({ ...prev, [m.id]: { imageData, values } }));
} catch {
if (!cancelled) toast.error('Không tải được mặt nạ đa nhãn');
}
}
if (!cancelled) setEnumerating(false);
})();
return () => {
cancelled = true;
};
// labelVols omitted: the !labelVols[m.id] guard already prevents re-loading a cached volume.
}, [masks, datasetId]); // eslint-disable-line react-hooks/exhaustive-deps
// Flatten masks → toggleable organ entries (a multi-label mask expands per value), sorted by name.
const entries = useMemo<OrganEntry[]>(() => {
const out: OrganEntry[] = [];
for (const m of masks) {
if (m.multiLabel) {
const lv = labelVols[m.id];
if (lv) {
for (const v of lv.values) {
out.push({ key: `${m.id}:${v}`, name: labelMap?.[String(v)] || organDisplayName(v), maskId: m.id, value: v });
}
}
} else {
out.push({ key: m.id, name: m.organLabel || m.logicalPath, maskId: m.id });
}
}
out.sort((a, b) => a.name.localeCompare(b.name, 'vi'));
return out;
}, [masks, labelVols, labelMap]);
// The overlays handed to the renderer = the loaded vtkImageData for selected entries.
const organMasks = useMemo(
() =>
entries
.filter((e) => selectedIds.has(e.key))
.map((e) => organMaskById[e.key])
.filter(Boolean) as OrganMaskData[],
[entries, selectedIds, organMaskById],
);
const toggleOrgan = async (entry: OrganEntry, i: number) => {
if (selectedIds.has(entry.key)) {
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(entry.key);
return next;
});
return;
}
if (!organMaskById[entry.key]) {
setLoadingId(entry.key);
try {
let imageData: any;
if (entry.value != null) {
// Multi-label: extract this organ's binary submask from the cached label volume (sync).
const lv = labelVols[entry.maskId];
if (!lv) throw new Error('multi-label volume not loaded');
imageData = extractBinaryLabel(lv.imageData, entry.value);
} else {
const m = masks.find((x) => x.id === entry.maskId);
if (!m) throw new Error('mask not found');
const { url } = await fileDownloadUrl(datasetId, m.id);
const maskFile = await fetchAsFile(url, m.logicalPath);
imageData = await loadNiftiImageData(maskFile);
}
setOrganMaskById((prev) => ({
...prev,
[entry.key]: { id: entry.key, organName: entry.name, imageData, color: colorFor(i) },
}));
} catch {
toast.error('Không tải được mặt nạ cơ quan');
setLoadingId(null);
return;
}
setLoadingId(null);
}
setSelectedIds((prev) => new Set(prev).add(entry.key));
};
// Persist an inline organ rename (value → name) via the parent, then close the editor.
const commitRename = () => {
if (!editing || !onRenameLabel) return;
const name = editing.draft.trim();
if (name) onRenameLabel(editing.value, name);
setEditing(null);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="inset-0 flex h-full w-full max-w-none translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none border-0 p-0">
{/* Top control bar */}
<div className="flex shrink-0 flex-wrap items-center justify-between gap-x-6 gap-y-2 border-b bg-background px-4 py-3 pr-12">
<DialogTitle className="truncate font-mono text-sm">{fileName}</DialogTitle>
<DialogDescription className="sr-only">
Trình xem nh y khoa 3D (DICOM/NIfTI) cho tệp {fileName}
</DialogDescription>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 text-xs text-muted-foreground">
<label className="flex items-center gap-2">
<span>Đ rộng cửa sổ</span>
<input type="range" min={1} max={4000} step={1} value={windowWidth}
onChange={(e) => setWindowWidth(Number(e.target.value))} className="w-28 accent-primary" />
<span className="w-12 tabular-nums text-foreground">{windowWidth}</span>
</label>
<label className="flex items-center gap-2">
<span>Mức cửa sổ</span>
<input type="range" min={-1000} max={3000} step={1} value={windowLevel}
onChange={(e) => setWindowLevel(Number(e.target.value))} className="w-28 accent-primary" />
<span className="w-12 tabular-nums text-foreground">{windowLevel}</span>
</label>
<label className="flex items-center gap-2">
<span>Đ mờ 3D</span>
<input type="range" min={0} max={1} step={0.05} value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))} className="w-24 accent-primary" />
<span className="w-10 tabular-nums text-foreground">{opacity.toFixed(2)}</span>
</label>
</div>
</div>
{/* Body: viewer + annotation side panel */}
<div className="flex min-h-0 flex-1">
<TooltipProvider>
<div className="relative min-w-0 flex-1 bg-black">
{open && file && (
<UnifiedQuadViewRenderer
files={[file]}
windowWidth={windowWidth}
windowLevel={windowLevel}
opacity={opacity}
organMasks={organMasks}
annotationTool={tool}
annotations={annotations}
onAnnotationsChange={setAnnotations}
/>
)}
</div>
</TooltipProvider>
<aside className="flex w-72 shrink-0 flex-col border-l bg-background">
{masks.length > 0 && (
<div className="border-b">
<div className="px-4 py-3">
<h3 className="text-sm font-semibold text-foreground"> quan ({entries.length})</h3>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{enumerating ? 'Đang nạp danh sách cơ quan…' : 'Chọn cơ quan để hiển thị lớp phủ 3D.'}
</p>
</div>
<ul className="max-h-56 space-y-1 overflow-y-auto px-2 pb-2">
{entries.map((e, i) => {
const on = selectedIds.has(e.key);
const loading = loadingId === e.key;
const [r, g, b] = colorFor(i);
const canRename = !!onRenameLabel && e.value != null;
const isEditing = editing != null && e.value != null && editing.value === e.value;
if (isEditing) {
return (
<li key={e.key}>
<div className="flex items-center gap-1 rounded-md border border-primary px-2 py-1">
<span
className="h-3 w-3 shrink-0 rounded-sm border border-black/10"
style={{ background: `rgb(${r}, ${g}, ${b})` }}
/>
<input
autoFocus
value={editing.draft}
onChange={(ev) => setEditing({ value: editing.value, draft: ev.target.value })}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commitRename();
else if (ev.key === 'Escape') setEditing(null);
}}
placeholder={`Nhãn ${e.value}`}
className="min-w-0 flex-1 bg-transparent text-xs text-foreground outline-none"
/>
<button type="button" onClick={commitRename} title="Lưu"
className="shrink-0 text-primary hover:opacity-80">
<Check className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => setEditing(null)} title="Hủy"
className="shrink-0 text-muted-foreground hover:text-destructive">
<X className="h-3.5 w-3.5" />
</button>
</div>
</li>
);
}
return (
<li key={e.key} className="flex items-center gap-1">
<button
type="button"
onClick={() => toggleOrgan(e, i)}
disabled={loading}
title={e.name}
className={`flex min-w-0 flex-1 items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition ${
on ? 'border-primary bg-primary/10' : 'border-border hover:bg-accent'
}`}
>
<span
className="h-3 w-3 shrink-0 rounded-sm border border-black/10"
style={{ background: `rgb(${r}, ${g}, ${b})`, opacity: on ? 1 : 0.4 }}
/>
<span className="min-w-0 flex-1 truncate text-foreground">{e.name}</span>
{loading ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
) : on ? (
<Eye className="h-3.5 w-3.5 shrink-0 text-primary" />
) : (
<EyeOff className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
</button>
{canRename && (
<button
type="button"
onClick={() => setEditing({ value: e.value as number, draft: e.name })}
title="Đổi tên nhãn"
className="shrink-0 rounded-md border border-border p-1.5 text-muted-foreground transition hover:bg-accent hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
</li>
);
})}
</ul>
</div>
)}
<div className="border-b px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">Công cụ chú thích</h3>
<p className="mt-0.5 text-[11px] text-muted-foreground">
Vẽ trên các lát cắt 2D (axial / coronal / sagittal).
</p>
</div>
<div className="grid grid-cols-3 gap-2 p-3">
{TOOLS.map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setTool(id)}
className={`flex flex-col items-center gap-1 rounded-md border px-2 py-2 text-[11px] transition ${
tool === id
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent'
}`}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
<div className="flex items-center justify-between border-y px-4 py-2">
<span className="text-xs font-medium text-foreground">Chú thích ({annotations.length})</span>
{annotations.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setAnnotations([])}
>
<Trash2 className="mr-1 h-3.5 w-3.5" /> Xóa hết
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{annotations.length === 0 ? (
<p className="px-2 py-6 text-center text-[11px] leading-relaxed text-muted-foreground">
Chọn một công cụ rồi vẽ trên lát cắt.
<br />
(Đa giác: bấm từng đnh, nhấn đúp đ đóng.)
</p>
) : (
<ul className="space-y-1">
{annotations.map((a) => (
<li
key={a.id}
className="flex items-center justify-between gap-2 rounded border px-2 py-1 text-[11px]"
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: a.color }} />
<span className="truncate">
{TOOL_LABEL[a.tool]} · {VIEW_LABEL[a.view]} · lát {a.sliceIndex + 1}
</span>
</span>
<button
type="button"
onClick={() => setAnnotations(annotations.filter((x) => x.id !== a.id))}
className="shrink-0 text-muted-foreground hover:text-destructive"
title="Xóa"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
</aside>
</div>
{/* Footer hint */}
<div className="shrink-0 border-t bg-background px-4 py-1.5 text-center text-[11px] text-muted-foreground">
Chọn: cuộn chuyển lát · Ctrl/ + cuộn: thu phóng · Nhấn đúp khung: phóng to · Công cụ vẽ: kéo trên lát cắt
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,151 @@
import { useRef, useState, type ChangeEvent } from 'react';
import { Upload, X } from 'lucide-react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
Button,
Input,
uploadSegmentationMasks,
type SegmentationMaskInput,
} from '@ump/shared';
interface SegmentationUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
datasetId: string;
parentFileId: string;
parentName: string;
/** Called after a successful link so the caller can refetch the file list. */
onUploaded: () => void;
}
/** Default organ label from a mask filename: `liver.nii.gz` -> `liver`. */
function organFromFilename(name: string): string {
const base = (name.split('/').pop() ?? name).replace(/\.(nii\.gz|nii|dcm|dicom)$/i, '');
return base.replace(/[_-]+/g, ' ').trim();
}
/**
* Pick organ-mask `.nii.gz` files and link them to a parent image. Each mask carries
* an organ label (pre-filled from its filename, editable) — the one piece of metadata
* the bare file picker can't collect — and all are linked to `parentFileId` on submit.
*/
export function SegmentationUploadDialog({
open,
onOpenChange,
datasetId,
parentFileId,
parentName,
onUploaded,
}: SegmentationUploadDialogProps) {
const fileInput = useRef<HTMLInputElement>(null);
const [items, setItems] = useState<{ file: File; organ: string }[]>([]);
const [busy, setBusy] = useState(false);
const addFiles = (e: ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
setItems((prev) => [...prev, ...picked.map((file) => ({ file, organ: organFromFilename(file.name) }))]);
e.target.value = '';
};
const setOrgan = (i: number, organ: string) =>
setItems((prev) => prev.map((it, j) => (j === i ? { ...it, organ } : it)));
const removeItem = (i: number) => setItems((prev) => prev.filter((_, j) => j !== i));
const submit = async () => {
if (!items.length) return;
if (items.some((it) => !it.organ.trim())) {
toast.error('Hãy đặt tên cơ quan cho mỗi mặt nạ.');
return;
}
setBusy(true);
try {
const res = await uploadSegmentationMasks(datasetId, parentFileId, items as SegmentationMaskInput[]);
toast.success(`Đã gắn ${res.masks.length} mặt nạ phân vùng`);
setItems([]);
onOpenChange(false);
onUploaded();
} catch {
toast.error('Gắn mặt nạ thất bại');
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={(o) => { if (!busy) onOpenChange(o); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Gắn mặt nạ phân vùng</DialogTitle>
<DialogDescription>
Tải các tệp mặt nạ (.nii.gz) theo từng quan liên kết với{' '}
<span className="font-mono text-foreground">{parentName}</span>.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
ref={fileInput}
type="file"
accept=".nii,.nii.gz,application/gzip"
multiple
className="hidden"
onChange={addFiles}
/>
<Button variant="outline" size="sm" onClick={() => fileInput.current?.click()} disabled={busy}>
<Upload className="mr-2 h-4 w-4" /> Chọn tệp mặt nạ
</Button>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">Chưa chọn tệp nào.</p>
) : (
<div className="max-h-72 space-y-2 overflow-y-auto">
{items.map((it, i) => (
<div key={`${it.file.name}-${i}`} className="flex items-center gap-2">
<span
className="w-40 shrink-0 truncate font-mono text-xs text-muted-foreground"
title={it.file.name}
>
{it.file.name}
</span>
<Input
value={it.organ}
onChange={(e) => setOrgan(i, e.target.value)}
placeholder="Tên cơ quan"
className="h-8"
aria-label={`Tên cơ quan cho ${it.file.name}`}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => removeItem(i)}
disabled={busy}
title="Bỏ tệp này"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={busy}>
Hủy
</Button>
<Button onClick={submit} disabled={busy || items.length === 0}>
{busy ? 'Đang tải…' : `Gắn ${items.length || ''} mặt nạ`.trim()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default SegmentationUploadDialog;
@@ -0,0 +1,30 @@
import { Link } from "react-router-dom";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
export interface MenuItemDescriptor {
title: string;
icon: React.ComponentType<{ className?: string }>;
url: string;
}
interface ApplicantSidebarMenuItemProps {
item: MenuItemDescriptor;
pathname: string;
}
export function ApplicantSidebarMenuItem({ item, pathname }: ApplicantSidebarMenuItemProps) {
const Icon = item.icon;
const active = pathname === item.url;
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={active} tooltip={item.title}>
<Link to={item.url} className="flex mt-4 w-full items-center gap-2">
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
@@ -0,0 +1,174 @@
import { useState } from "react";
import {
AudioLines,
Bell,
ChevronRight,
Database,
FileText,
FlaskConical,
HelpCircle,
Image as ImageIcon,
Images,
LogOut,
Plus,
Video,
} from "lucide-react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth, cn } from "@ump/shared";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarSeparator,
useSidebar,
} from "@/components/ui/sidebar";
import {
ApplicantSidebarMenuItem,
type MenuItemDescriptor,
} from "@/components/applicant/ApplicantSidebarMenuItem";
const LOGO_SRC = "/logo.png";
// The journey entry — research projects.
const projectItem: MenuItemDescriptor = {
title: "Đề tài của tôi",
icon: FlaskConical,
url: "/dashboard/projects",
};
// "Dữ liệu" group — one entry per data modality + the create action.
// Văn bản / Âm thanh are placeholders (ComingSoon) until those modalities land.
const dataItems: MenuItemDescriptor[] = [
{ title: "Dữ liệu hình ảnh", icon: ImageIcon, url: "/dashboard" },
{ title: "Dữ liệu văn bản", icon: FileText, url: "/dashboard/data/text" },
{ title: "Dữ liệu âm thanh", icon: AudioLines, url: "/dashboard/data/audio" },
{ title: "Dữ liệu video", icon: Video, url: "/dashboard/video-viewer" },
{ title: "Chuỗi ảnh 2D", icon: Images, url: "/dashboard/image-viewer" },
{ title: "Tạo dữ liệu mới", icon: Plus, url: "/dashboard/datasets/new" },
];
const systemItems: MenuItemDescriptor[] = [
{ title: "Thông báo", icon: Bell, url: "/dashboard/notifications" },
{ title: "Trợ giúp", icon: HelpCircle, url: "/dashboard/help" },
];
export function ApplicantDashboardSidebar() {
const location = useLocation();
const navigate = useNavigate();
const { state } = useSidebar();
const { logout } = useAuth();
const isCollapsed = state === "collapsed";
const [dataOpen, setDataOpen] = useState(true);
const handleLogout = () => {
logout();
navigate("/login");
};
return (
<Sidebar collapsible="icon">
<SidebarHeader className="h-16 justify-center border-b border-sidebar-border px-4">
<Link to="/dashboard" className="flex items-center gap-2">
<img
src={LOGO_SRC}
alt="Đại học Y Dược Thành phố Hồ Chí Minh"
className="h-9 w-9 object-contain flex-shrink-0"
/>
{!isCollapsed && (
<div className="leading-tight">
<div className="font-serif text-sm font-semibold text-sidebar-foreground">
Đi học Y Dược
</div>
<div className="text-[11px] text-muted-foreground">Thành phố Hồ Chí Minh</div>
</div>
)}
</Link>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<ApplicantSidebarMenuItem item={projectItem} pathname={location.pathname} />
{/* "Dữ liệu" — collapsible group of data modalities. */}
<SidebarMenuItem>
<SidebarMenuButton
onClick={() => setDataOpen((o) => !o)}
isActive={dataItems.some((d) => d.url === location.pathname)}
tooltip="Dữ liệu"
className="mt-4"
>
<Database className="h-4 w-4 shrink-0" />
<span className="truncate">Dữ liệu</span>
{!isCollapsed && (
<ChevronRight
className={cn(
"ml-auto h-4 w-4 transition-transform",
dataOpen && "rotate-90",
)}
/>
)}
</SidebarMenuButton>
{dataOpen && !isCollapsed && (
<SidebarMenuSub>
{dataItems.map((d) => {
const Icon = d.icon;
return (
<SidebarMenuSubItem key={d.url}>
<SidebarMenuSubButton asChild isActive={location.pathname === d.url}>
<Link to={d.url}>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{d.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
)}
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator />
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
{systemItems.map((item) => (
<ApplicantSidebarMenuItem key={item.url} item={item} pathname={location.pathname} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter className="border-t border-sidebar-border p-2">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
onClick={handleLogout}
tooltip="Đăng xuất"
className="text-destructive text-bg-black hover:text-destructive cursor-pointer"
>
<LogOut className="h-4 w-4" />
<span>Đăng xuất</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
@@ -0,0 +1,180 @@
// Admin-detail tab content for the cockpit: a numbered section panel (read view) plus a
// per-section editor dialog that persists via updateProjectDetail() (shallow content merge).
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Pencil } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
updateProjectDetail,
} from '@ump/shared';
import {
displayValue,
readContent,
type DetailField,
type DetailSection,
} from './detailConfig';
import { Field, FieldGrid, SectionPanel } from './DetailPrimitives';
type Content = Record<string, unknown>;
/** One administrative section, read view + an inline ✎ edit affordance (owner/admin only). */
export function DetailSectionPanel({
section,
content,
projectId,
canEdit,
}: {
section: DetailSection;
content: Content;
projectId: string;
canEdit: boolean;
}) {
const [editing, setEditing] = useState(false);
return (
<>
<SectionPanel
index={section.index}
title={section.title}
action={
canEdit ? (
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-primary" onClick={() => setEditing(true)}>
<Pencil size={14} /> Chỉnh sửa
</Button>
) : undefined
}
>
<FieldGrid>
{section.fields.map((f) => (
<Field
key={f.key}
label={f.label}
full={f.full}
value={displayValue(f, readContent(content, f.key))}
/>
))}
</FieldGrid>
</SectionPanel>
{editing && (
<DetailEditorDialog
section={section}
content={content}
projectId={projectId}
onClose={() => setEditing(false)}
/>
)}
</>
);
}
/** Coerce a draft string back to the type the BE/JSONB should hold. */
function coerce(field: DetailField, raw: string): unknown {
if (field.type === 'money' || field.type === 'number' || field.type === 'months') {
const n = Number(raw);
return raw.trim() === '' || Number.isNaN(n) ? '' : n;
}
return raw;
}
export function DetailEditorDialog({
section,
content,
projectId,
onClose,
}: {
section: DetailSection;
content: Content;
projectId: string;
onClose: () => void;
}) {
const qc = useQueryClient();
const [draft, setDraft] = useState<Record<string, string>>(() => {
const d: Record<string, string> = {};
for (const f of section.fields) {
const v = readContent(content, f.key);
d[f.key] = v === undefined || v === null ? '' : String(v);
}
return d;
});
const save = useMutation({
mutationFn: () => {
const patch: Record<string, unknown> = {};
for (const f of section.fields) patch[f.key] = coerce(f, draft[f.key] ?? '');
return updateProjectDetail(projectId, patch);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['cockpit', projectId] });
toast.success('Đã lưu.');
onClose();
},
onError: () => toast.error('Lưu thất bại.'),
});
const set = (k: string, v: string) => setDraft((d) => ({ ...d, [k]: v }));
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="font-serif">
{section.index}. {section.title}
</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 gap-3.5 py-2 sm:grid-cols-2">
{section.fields.map((f) => (
<div key={f.key} className={f.full || f.type === 'textarea' ? 'sm:col-span-2' : undefined}>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">{f.label}</Label>
{f.type === 'textarea' ? (
<Textarea className="min-h-20" value={draft[f.key] ?? ''} onChange={(e) => set(f.key, e.target.value)} />
) : f.type === 'select' ? (
<Select value={draft[f.key] || undefined} onValueChange={(v) => set(f.key, v)}>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
{f.options?.map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={f.type === 'date' ? 'date' : f.type === 'money' || f.type === 'months' ? 'number' : 'text'}
value={draft[f.key] ?? ''}
onChange={(e) => set(f.key, e.target.value)}
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={save.isPending}>
Hủy
</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Lưu
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,147 @@
// Left metadata rail for the cockpit, mirroring the institutional "đề tài" detail sheet:
// project identity + workflow progress + labelled meta rows + PI/member avatars.
import {
Building2,
Coins,
FileText,
FlaskConical,
Hash,
Tag,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { ReactNode } from 'react';
import type { CockpitBundle } from '@ump/shared';
import { asNum, asStr } from './cockpitConfig';
import { fmtMoney, readContent } from './detailConfig';
import { Bar, StatusBadge } from './CockpitWidgets';
/** Initials from a Vietnamese name: last 12 significant words (skips titles like ThS./DS.). */
function initials(name: string): string {
const parts = name
.replace(/\b(ThS|TS|PGS|GS|BS|DS|SVD\d*|CN|KS|ThS\.)\.?\b/gi, '')
.trim()
.split(/\s+/)
.filter(Boolean);
if (parts.length === 0) return '?';
const last = parts[parts.length - 1];
return last.slice(0, 1).toUpperCase();
}
const AVATAR_TONES = [
'bg-sky-100 text-sky-700',
'bg-emerald-100 text-emerald-700',
'bg-violet-100 text-violet-700',
'bg-amber-100 text-amber-700',
'bg-rose-100 text-rose-700',
];
function Avatar({ name, i = 0, ring }: { name: string; i?: number; ring?: boolean }) {
return (
<span
title={name}
className={
'inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold ' +
AVATAR_TONES[i % AVATAR_TONES.length] +
(ring ? ' ring-2 ring-card' : '')
}
>
{initials(name)}
</span>
);
}
function MetaRow({ icon: Icon, label, children }: { icon: LucideIcon; label: string; children: ReactNode }) {
return (
<div className="flex items-start gap-2.5 py-2">
<Icon size={15} className="mt-0.5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="font-sans text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-0.5 text-sm text-foreground">{children}</div>
</div>
</div>
);
}
export function CockpitSidebar({ bundle, datasetNav }: { bundle: CockpitBundle; datasetNav?: ReactNode }) {
const { project, members, milestones } = bundle;
const content = (project.content ?? {}) as Record<string, unknown>;
const code = project.code || asStr(readContent(content, 'maSo')) || '(chưa có mã số)';
const contractNo = asStr(readContent(content, 'soHopDong'));
const budget = project.budgetTotal ?? (Number(readContent(content, 'tongKinhPhi')) || 0);
const workflowPct = milestones.length
? Math.round(milestones.reduce((s, m) => s + asNum(m.progress), 0) / milestones.length)
: 0;
const status = asStr(readContent(content, 'trangThaiTienDo')) || 'Chưa có';
return (
<aside className="space-y-5 rounded-2xl border border-sidebar-border bg-sidebar p-5">
{/* identity */}
<div className="flex items-start gap-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<FlaskConical size={20} />
</span>
<div className="min-w-0">
<h2 className="font-serif text-sm font-semibold leading-snug text-foreground">
{project.title || '(Chưa đặt tên)'}
</h2>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{code} · {project.piName || '—'}
{contractNo ? ` · ${contractNo}` : ''}
</p>
</div>
</div>
{/* workflow progress */}
<div>
<div className="mb-1.5 flex items-center justify-between text-xs">
<span className="font-sans font-medium text-muted-foreground">Tiến đ quy trình</span>
<span className="tabular-nums font-semibold text-primary">{workflowPct}%</span>
</div>
<Bar value={workflowPct} />
</div>
{/* meta rows */}
<div className="divide-y divide-sidebar-border border-y border-sidebar-border">
<MetaRow icon={Hash} label="Mã đề tài">{code}</MetaRow>
<MetaRow icon={FileText} label="Số hợp đồng">{contractNo || '—'}</MetaRow>
<MetaRow icon={Building2} label="Khoa / Đơn vị">{asStr(readContent(content, 'khoaDonVi')) || '—'}</MetaRow>
<MetaRow icon={Tag} label="Loại đề tài">
{asStr(readContent(content, 'capDeTai')) || project.level || '—'}
</MetaRow>
<MetaRow icon={Coins} label="Tổng kinh phí">{fmtMoney(budget) || '—'}</MetaRow>
</div>
{/* dataset navigator — only rendered on the imaging-data tab */}
{datasetNav}
{/* status + assignee */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="font-sans text-xs font-medium text-muted-foreground">Tiến đ thực hiện</span>
<StatusBadge value={status} />
</div>
{members.length > 0 && (
<div>
<div className="mb-1.5 font-sans text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Thành viên nghiên cứu
</div>
<div className="flex -space-x-2">
{members.slice(0, 6).map((m, i) => (
<Avatar key={m.id} name={asStr(m.name)} i={i} ring />
))}
{members.length > 6 && (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground ring-2 ring-card">
+{members.length - 6}
</span>
)}
</div>
</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,226 @@
import { Pencil, ShieldCheck, Trash2, type LucideIcon } from 'lucide-react';
import {
Button,
Card,
CardContent,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
type EntityRow,
} from '@ump/shared';
import { asNum, asStr, fmt, pct, toneClass, type EntityConfig } from './cockpitConfig';
export function StatusBadge({ value }: { value: unknown }) {
if (value === undefined || value === null || value === '') return null;
return (
<span className={'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ' + toneClass(value)}>
{asStr(value)}
</span>
);
}
export function Stat({
icon: Icon,
label,
value,
sub,
accent,
}: {
icon: LucideIcon;
label: string;
value: string;
sub?: string;
accent?: string;
}) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Icon size={15} className={accent} /> {label}
</div>
<div className="mt-2 text-2xl font-semibold tabular-nums text-foreground">{value}</div>
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</CardContent>
</Card>
);
}
export function Bar({ value, tone = 'bg-primary' }: { value: unknown; tone?: string }) {
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div className={'h-full rounded-full ' + tone} style={{ width: pct(value) + '%' }} />
</div>
);
}
export function EntityCard({
config,
item,
canEdit,
onEdit,
onDelete,
}: {
config: EntityConfig;
item: EntityRow;
canEdit: boolean;
onEdit: () => void;
onDelete: () => void;
}) {
const isNumField = (k: string) => config.fields.some((f) => f.key === k && f.type === 'number');
const sub = config.secondary
.map(([k, sfx]) => {
const v = item[k];
if (v === undefined || v === '' || v === null) return null;
return (isNumField(k) ? fmt(v) : asStr(v)) + (sfx || '');
})
.filter(Boolean);
const progressTone =
item.status === 'Trễ hạn' ? 'bg-rose-500' : item.status === 'Hoàn thành' ? 'bg-emerald-500' : 'bg-primary';
return (
<Card className="card-hover">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold leading-snug text-foreground">{asStr(item[config.primary]) || '—'}</div>
{sub.length > 0 && <div className="mt-0.5 text-xs text-muted-foreground">{sub.join(' · ')}</div>}
</div>
{config.badge && <StatusBadge value={item[config.badge]} />}
</div>
{config.accessKey && item[config.accessKey] ? (
<div className="mt-2 inline-flex items-center gap-1 rounded-md border border-violet-200 bg-violet-50 px-2 py-0.5 text-xs text-violet-700">
<ShieldCheck size={12} /> {asStr(item[config.accessKey])}
</div>
) : null}
{config.metrics && item.status !== 'Kế hoạch' && (
<div className="mt-3 flex flex-wrap gap-1.5">
{config.metrics.map(([k, lbl]) =>
asNum(item[k]) > 0 ? (
<span key={k} className="rounded-md border border-border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
{lbl} <b className="tabular-nums text-foreground">{asNum(item[k]).toFixed(2)}</b>
</span>
) : null,
)}
</div>
)}
{config.progress && (
<div className="mt-3">
<div className="mb-1 flex justify-between text-xs text-muted-foreground">
<span className="truncate pr-2">{asStr(item.deliverable)}</span>
<span className="tabular-nums">{pct(item[config.progress])}%</span>
</div>
<Bar value={item[config.progress]} tone={progressTone} />
</div>
)}
<div className="mt-3 flex justify-end gap-1 border-t border-border pt-2">
<button
onClick={onEdit}
disabled={!canEdit}
title={canEdit ? 'Sửa' : 'Không có quyền'}
className={'p-1 ' + (canEdit ? 'text-muted-foreground hover:text-primary' : 'cursor-not-allowed text-muted-foreground/40')}
>
<Pencil size={15} />
</button>
<button
onClick={onDelete}
disabled={!canEdit}
title={canEdit ? 'Xóa' : 'Không có quyền'}
className={'p-1 ' + (canEdit ? 'text-muted-foreground hover:text-destructive' : 'cursor-not-allowed text-muted-foreground/40')}
>
<Trash2 size={15} />
</button>
</div>
</CardContent>
</Card>
);
}
export function EntityDrawer({
open,
isNew,
config,
draft,
onChange,
onSave,
onClose,
saving,
}: {
open: boolean;
isNew: boolean;
config: EntityConfig;
draft: Record<string, string>;
onChange: (key: string, value: string) => void;
onSave: () => void;
onClose: () => void;
saving: boolean;
}) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="font-serif">
{isNew ? 'Thêm ' : 'Sửa '}
{config.singular}
</DialogTitle>
</DialogHeader>
<div className="space-y-3.5 py-2">
{config.fields.map((f) => (
<div key={f.key}>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">{f.label}</Label>
{f.type === 'textarea' ? (
<Textarea className="min-h-20" value={draft[f.key] ?? ''} onChange={(e) => onChange(f.key, e.target.value)} />
) : f.type === 'select' ? (
<Select value={draft[f.key] || undefined} onValueChange={(v) => onChange(f.key, v)}>
<SelectTrigger>
<SelectValue placeholder="—" />
</SelectTrigger>
<SelectContent>
{f.options?.map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={f.type === 'number' ? 'number' : 'text'}
min={f.min}
max={f.max}
step={f.step}
value={draft[f.key] ?? ''}
onChange={(e) => onChange(f.key, e.target.value)}
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>
Hủy
</Button>
<Button onClick={onSave} disabled={saving}>
Lưu
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,67 @@
// Presentational primitives for the project-detail layout (Thông tin chung / Hợp đồng /
// Kinh phí / Thời gian / Nghiệm thu). Mirrors the admin detail page's visual language:
// a numbered section panel with a left accent bar, and a grid of small uppercase grey
// LABEL over VALUE fields. Pure presentation — no data fetching, no content keys.
import type { ReactNode } from 'react';
/** A numbered section panel: "I. Thông tin chung" with a primary accent bar, then content. */
export function SectionPanel({
index,
title,
action,
children,
}: {
index: string;
title: string;
action?: ReactNode;
children: ReactNode;
}) {
return (
<section className="rounded-2xl border border-border bg-card p-5 sm:p-6">
<div className="mb-5 flex items-start justify-between gap-3">
<h2 className="flex items-center gap-2.5 font-serif text-base font-semibold text-foreground">
<span className="inline-block h-5 w-1 rounded-full bg-primary" aria-hidden />
{index}. {title}
</h2>
{action}
</div>
{children}
</section>
);
}
/** Responsive grid that lays fields out 1→2→4 columns like the admin page. */
export function FieldGrid({ children }: { children: ReactNode }) {
return <div className="grid grid-cols-1 gap-x-8 gap-y-5 sm:grid-cols-2 lg:grid-cols-4">{children}</div>;
}
/** A single LABEL / VALUE pair. Label is forced to sans + uppercase to beat the global serif. */
export function Field({
label,
value,
full,
mono,
}: {
label: string;
value?: ReactNode;
full?: boolean;
mono?: boolean;
}) {
const empty = value === undefined || value === null || value === '';
return (
<div className={full ? 'sm:col-span-2 lg:col-span-4' : undefined}>
<div className="font-sans text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div
className={
'mt-1 text-sm leading-relaxed ' +
(empty ? 'text-muted-foreground/60' : 'text-foreground') +
(mono ? ' font-mono' : '')
}
>
{empty ? '---' : value}
</div>
</div>
);
}
@@ -0,0 +1,454 @@
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Crown, Mail, MoreHorizontal, Pencil, Search, Shield, ShieldCheck, Trash2, UserCheck, UserPlus, Users, X } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
cn,
createEntity,
deleteEntity,
updateEntity,
type CockpitEntity,
type EntityRow,
} from '@ump/shared';
import { ENTITIES, asStr } from './cockpitConfig';
import { EntityDrawer, StatusBadge } from './CockpitWidgets';
// Status vocabulary (the research_project_members.status values from cockpitConfig).
const STATUS = { active: 'Đang hoạt động', pending: 'Chờ xác nhận', paused: 'Tạm dừng' } as const;
const memberField = (key: string) => ENTITIES.members.fields.find((f) => f.key === key);
const ROLE_OPTIONS = memberField('role')?.options ?? [];
const ACCESS_OPTIONS = memberField('access')?.options ?? [];
const validEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
const AVATAR_COLORS = ['bg-rose-500', 'bg-emerald-500', 'bg-sky-500', 'bg-violet-500', 'bg-amber-500', 'bg-teal-500'];
function avatar(seed: string): { initials: string; color: string } {
const s = seed.trim() || '?';
const parts = s.split(/\s+/).filter(Boolean);
const initials = (parts.length >= 2 ? parts[0][0] + parts[parts.length - 1][0] : s.slice(0, 2)).toUpperCase();
let h = 0;
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return { initials, color: AVATAR_COLORS[h % AVATAR_COLORS.length] };
}
/** "PI" access (Chủ nhiệm) gets the crown; everyone else a shield. */
const isPiAccess = (access: string) => /Chủ nhiệm|PI/i.test(access);
const TABS: Array<{ key: keyof typeof STATUS; label: string }> = [
{ key: 'active', label: 'Đang hoạt động' },
{ key: 'pending', label: 'Chờ xác nhận' },
{ key: 'paused', label: 'Tạm dừng' },
];
/**
* Team-management view for a research project (cockpit "Thành viên" tab) — TeamManagement.jsx
* reskinned to the UMP system. FE-only over the existing research_project_members entity CRUD:
* invite = a member row with status "Chờ xác nhận"; RBAC = the `access` field; who-can-edit is
* gated by canEdit (project owner / admin). No schema/backend change.
*/
export function TeamManagementView({
members,
projectId,
canEdit,
}: {
members: EntityRow[];
projectId: string;
canEdit: boolean;
}) {
const qc = useQueryClient();
const [tab, setTab] = useState<keyof typeof STATUS>('active');
const [query, setQuery] = useState('');
const [inviteOpen, setInviteOpen] = useState(false);
const [editing, setEditing] = useState<EntityRow | null>(null);
const [draft, setDraft] = useState<Record<string, string>>({});
const invalidate = () => qc.invalidateQueries({ queryKey: ['cockpit', projectId] });
const saveMut = useMutation({
mutationFn: (vars: { id?: string; data: Record<string, string> }) =>
vars.id
? updateEntity(projectId, 'members' as CockpitEntity, vars.id, vars.data)
: createEntity(projectId, 'members' as CockpitEntity, vars.data),
onSuccess: () => invalidate(),
onError: (e: unknown) => toast.error((e as { message?: string })?.message || 'Lưu thất bại'),
});
const delMut = useMutation({
mutationFn: (itemId: string) => deleteEntity(projectId, 'members' as CockpitEntity, itemId),
onSuccess: () => invalidate(),
onError: () => toast.error('Xóa thất bại'),
});
const patch = (id: string, data: Record<string, string>) => saveMut.mutate({ id, data });
const counts = useMemo(() => {
const c = { active: 0, pending: 0, paused: 0 } as Record<keyof typeof STATUS, number>;
for (const m of members) {
const s = asStr(m.status);
if (s === STATUS.active) c.active += 1;
else if (s === STATUS.pending) c.pending += 1;
else if (s === STATUS.paused) c.paused += 1;
}
return c;
}, [members]);
const q = query.trim().toLowerCase();
const visible = useMemo(
() =>
members
.filter((m) => asStr(m.status) === STATUS[tab])
.filter(
(m) =>
!q ||
asStr(m.name).toLowerCase().includes(q) ||
asStr(m.email).toLowerCase().includes(q) ||
asStr(m.role).toLowerCase().includes(q),
),
[members, tab, q],
);
const openEdit = (m: EntityRow) => {
setDraft(Object.fromEntries(ENTITIES.members.fields.map((f) => [f.key, asStr(m[f.key])])));
setEditing(m);
};
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<div>
<h2 className="font-serif text-lg font-semibold text-foreground">Thành viên nhóm</h2>
<p className="text-sm text-muted-foreground">Quản thành viên, vai trò quyền truy cập của đ tài.</p>
</div>
</div>
<Button onClick={() => setInviteOpen(true)} disabled={!canEdit} title={canEdit ? '' : 'Chỉ chủ nhiệm / quản trị mới mời được'}>
<UserPlus className="mr-1.5 h-4 w-4" /> Mời thành viên
</Button>
</div>
{/* Tabs + search */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
<div className="flex gap-1">
{TABS.map((tb) => (
<button
key={tb.key}
type="button"
onClick={() => setTab(tb.key)}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition',
tab === tb.key ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-muted',
)}
>
{tb.label}
<span className={cn('rounded-full px-1.5 text-xs', tab === tb.key ? 'bg-background/20' : 'bg-muted text-muted-foreground')}>
{counts[tb.key]}
</span>
</button>
))}
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Tìm tên, email, vai trò" className="h-9 w-60 pl-8" />
</div>
</div>
{/* Table */}
{visible.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-14 text-center">
<Users className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">{query ? 'Không tìm thấy' : `Chưa có ${TABS.find((t) => t.key === tab)?.label.toLowerCase()}`}</p>
{!query && tab === 'pending' && canEdit && (
<Button variant="outline" size="sm" className="mt-3" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-1.5 h-4 w-4" /> Mời thành viên
</Button>
)}
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Thành viên</TableHead>
<TableHead>Vai trò</TableHead>
<TableHead>Quyền truy cập (RBAC)</TableHead>
<TableHead>Trạng thái</TableHead>
<TableHead>Nhiệm vụ</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{visible.map((m) => {
const name = asStr(m.name) || asStr(m.email) || '(chưa đặt tên)';
const email = asStr(m.email);
const role = asStr(m.role);
const access = asStr(m.access);
const status = asStr(m.status);
const av = avatar(name);
return (
<TableRow key={m.id}>
<TableCell>
<div className="flex items-center gap-2.5">
<span className={cn('inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-semibold text-white', av.color)}>
{av.initials}
</span>
<div className="min-w-0">
<div className="truncate font-medium text-foreground">{name}</div>
{email && <div className="truncate text-xs text-muted-foreground">{email}</div>}
</div>
</div>
</TableCell>
{/* Role — inline editable when permitted */}
<TableCell>
{canEdit ? (
<InlineSelect value={role} options={ROLE_OPTIONS} onChange={(v) => patch(m.id, { role: v })} placeholder="Vai trò" />
) : (
<span className="text-sm text-foreground">{role || '—'}</span>
)}
</TableCell>
{/* Access (RBAC) — the headline assignment */}
<TableCell>
{canEdit ? (
<InlineSelect value={access} options={ACCESS_OPTIONS} onChange={(v) => patch(m.id, { access: v })} placeholder="Quyền" icon />
) : (
<span className="inline-flex items-center gap-1 rounded-md border border-violet-200 bg-violet-50 px-2 py-0.5 text-xs text-violet-700">
{isPiAccess(access) ? <Crown size={12} /> : <ShieldCheck size={12} />} {access || '—'}
</span>
)}
</TableCell>
<TableCell>
<StatusBadge value={status} />
</TableCell>
<TableCell className="max-w-[220px]">
<span className="line-clamp-2 text-sm text-muted-foreground" title={asStr(m.tasks)}>
{asStr(m.tasks) || '—'}
</span>
</TableCell>
<TableCell className="text-right">
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{status !== STATUS.active && (
<DropdownMenuItem onClick={() => patch(m.id, { status: STATUS.active })}>
<UserCheck className="mr-2 h-4 w-4" /> {status === STATUS.pending ? 'Xác nhận tham gia' : 'Kích hoạt lại'}
</DropdownMenuItem>
)}
{status === STATUS.active && (
<DropdownMenuItem onClick={() => patch(m.id, { status: STATUS.paused })}>
<X className="mr-2 h-4 w-4" /> Tạm dừng
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => openEdit(m)}>
<Pencil className="mr-2 h-4 w-4" /> Sửa đy đ
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => delMut.mutate(m.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {status === STATUS.pending ? 'Hủy lời mời' : 'Xóa khỏi nhóm'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{!canEdit && (
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Shield className="h-3.5 w-3.5" /> Chỉ chủ nhiệm đ tài hoặc quản trị viên mới chỉnh sửa đưc nhóm.
</p>
)}
<InviteDialog
open={inviteOpen}
saving={saveMut.isPending}
existingEmails={members.map((m) => asStr(m.email).toLowerCase()).filter(Boolean)}
onClose={() => setInviteOpen(false)}
onSend={(data) => {
saveMut.mutate(
{ data: { ...data, status: STATUS.pending } },
{
onSuccess: () => {
toast.success(`Đã mời ${data.email}`);
setInviteOpen(false);
setTab('pending');
},
},
);
}}
/>
{editing && (
<EntityDrawer
open
isNew={false}
config={ENTITIES.members}
draft={draft}
saving={saveMut.isPending}
onChange={(key, value) => setDraft((d) => ({ ...d, [key]: value }))}
onClose={() => setEditing(null)}
onSave={() =>
saveMut.mutate({ id: editing.id, data: draft }, { onSuccess: () => { toast.success('Đã lưu'); setEditing(null); } })
}
/>
)}
</div>
);
}
/** A compact inline select for editing a row's role / access in place. */
function InlineSelect({
value,
options,
onChange,
placeholder,
icon,
}: {
value: string;
options: string[];
onChange: (v: string) => void;
placeholder: string;
icon?: boolean;
}) {
const opts = value && !options.includes(value) ? [value, ...options] : options;
return (
<Select value={value || undefined} onValueChange={onChange}>
<SelectTrigger className="h-8 w-44 gap-1.5">
{icon && (isPiAccess(value) ? <Crown className="h-3.5 w-3.5 text-violet-600" /> : <ShieldCheck className="h-3.5 w-3.5 text-violet-600" />)}
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{opts.map((o) => (
<SelectItem key={o} value={o}>
{o}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** Invite-a-member modal — name + email + role + access; creates a "Chờ xác nhận" member row. */
function InviteDialog({
open,
saving,
existingEmails,
onClose,
onSend,
}: {
open: boolean;
saving: boolean;
existingEmails: string[];
onClose: () => void;
onSend: (data: { name: string; email: string; role: string; access: string }) => void;
}) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState(ROLE_OPTIONS[2] ?? ROLE_OPTIONS[0] ?? '');
const [access, setAccess] = useState(ACCESS_OPTIONS[ACCESS_OPTIONS.length - 1] ?? '');
const [err, setErr] = useState('');
const submit = () => {
const e = email.trim().toLowerCase();
if (!name.trim()) return setErr('Vui lòng nhập họ tên.');
if (!validEmail(e)) return setErr('Email không hợp lệ.');
if (existingEmails.includes(e)) return setErr('Email này đã có trong nhóm hoặc đang chờ xác nhận.');
onSend({ name: name.trim(), email: e, role, access });
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) onClose();
else {
setName('');
setEmail('');
setRole(ROLE_OPTIONS[2] ?? ROLE_OPTIONS[0] ?? '');
setAccess(ACCESS_OPTIONS[ACCESS_OPTIONS.length - 1] ?? '');
setErr('');
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-serif">Mời thành viên</DialogTitle>
</DialogHeader>
<div className="space-y-3.5 py-1">
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Họ tên</Label>
<Input value={name} onChange={(e) => { setName(e.target.value); setErr(''); }} placeholder="VD: TS. Nguyễn Văn A" />
</div>
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Email</Label>
<div className="flex items-center gap-2 rounded-md border border-input px-2.5">
<Mail className="h-4 w-4 text-muted-foreground" />
<Input
value={email}
onChange={(e) => { setEmail(e.target.value); setErr(''); }}
onKeyDown={(e) => e.key === 'Enter' && submit()}
placeholder="name@ump.edu.vn"
className="border-0 px-0 shadow-none focus-visible:ring-0"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Vai trò</Label>
<Select value={role} onValueChange={setRole}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{ROLE_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="mb-1 block text-xs font-semibold text-muted-foreground">Quyền truy cập</Label>
<Select value={access} onValueChange={setAccess}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{ACCESS_OPTIONS.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
{err && <p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">{err}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>Hủy</Button>
<Button onClick={submit} disabled={saving}>
<UserPlus className="mr-1.5 h-4 w-4" /> Gửi lời mời
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default TeamManagementView;
@@ -0,0 +1,208 @@
// Cockpit entity configuration — ported from the PIProjectCockpit artifact, field keys
// aligned to the be0 research_project_* tables. Drives the entity cards + the editor dialog.
import {
Boxes,
CalendarClock,
Cpu,
Database,
Users,
type LucideIcon,
} from 'lucide-react';
export type CockpitFieldType = 'text' | 'textarea' | 'number' | 'select';
export interface CockpitField {
key: string;
label: string;
type: CockpitFieldType;
options?: string[];
default?: string;
min?: number;
max?: number;
step?: number;
}
export interface EntityConfig {
icon: LucideIcon;
title: string;
singular: string;
accent: string;
primary: string;
secondary: [string, string][];
badge: string;
accessKey?: string;
metrics?: [string, string][];
progress?: string;
fields: CockpitField[];
}
const sel = (key: string, label: string, options: string[], def?: string): CockpitField => ({
key,
label,
type: 'select',
options,
default: def,
});
const txt = (key: string, label: string): CockpitField => ({ key, label, type: 'text' });
const area = (key: string, label: string): CockpitField => ({ key, label, type: 'textarea' });
const num = (key: string, label: string, extra: Partial<CockpitField> = {}): CockpitField => ({
key,
label,
type: 'number',
...extra,
});
const ACCESS_LABELS = ['Chủ nhiệm (PI)', 'Điều phối / Thư ký', 'Kỹ sư ML', 'CTV lâm sàng', 'Quan sát'];
export const ENTITIES: Record<string, EntityConfig> = {
members: {
icon: Users,
title: 'Thành viên',
singular: 'thành viên',
accent: 'text-sky-600',
primary: 'name',
secondary: [
['role', ''],
['org', ''],
],
badge: 'status',
accessKey: 'access',
fields: [
txt('name', 'Họ và tên, học vị'),
sel('role', 'Vai trò trong đề tài', ['Chủ nhiệm đề tài', 'Thư ký khoa học', 'Thành viên chính', 'Kỹ thuật viên', 'Cộng tác viên'], 'Thành viên chính'),
sel('access', 'Quyền truy cập (RBAC)', ACCESS_LABELS, 'Quan sát'),
txt('org', 'Tổ chức công tác'),
txt('email', 'E-mail'),
num('months', 'Thời gian tham gia (tháng)', { min: 0 }),
area('tasks', 'Nhiệm vụ phụ trách'),
sel('status', 'Trạng thái', ['Đang hoạt động', 'Chờ xác nhận', 'Tạm dừng'], 'Đang hoạt động'),
],
},
datasets: {
icon: Database,
title: 'Dữ liệu',
singular: 'bộ dữ liệu',
accent: 'text-teal-600',
primary: 'name',
secondary: [
['type', ''],
['records', ' bản ghi'],
],
badge: 'status',
fields: [
txt('name', 'Tên bộ dữ liệu'),
sel('type', 'Loại dữ liệu', ['Ảnh CLVT', 'Nhãn phân đoạn', 'Dữ liệu lâm sàng', 'Bảng số liệu'], 'Ảnh CLVT'),
num('records', 'Số lượng bản ghi', { min: 0 }),
txt('source', 'Nguồn / nơi thu thập'),
sel('sensitivity', 'Mức nhạy cảm', ['PII', 'Đã ẩn danh', 'Công khai'], 'Đã ẩn danh'),
sel('ethics', 'Phê duyệt y đức', ['Đã duyệt', 'Chờ duyệt', 'Không yêu cầu'], 'Chờ duyệt'),
txt('owner', 'Người phụ trách'),
sel('status', 'Trạng thái', ['Đang thu thập', 'Đang làm sạch', 'Sẵn sàng', 'Khóa'], 'Đang thu thập'),
],
},
models: {
icon: Cpu,
title: 'Mô hình',
singular: 'mô hình',
accent: 'text-violet-600',
primary: 'name',
secondary: [
['task', ''],
['version', ''],
],
badge: 'status',
metrics: [
['auc', 'AUC'],
['sensitivity', 'Độ nhạy'],
['specificity', 'Độ đặc hiệu'],
['accuracy', 'Độ chính xác'],
],
fields: [
txt('name', 'Tên mô hình'),
txt('task', 'Bài toán'),
sel('framework', 'Framework', ['PyTorch', 'TensorFlow', 'MONAI', 'nnU-Net', 'scikit-learn'], 'PyTorch'),
txt('version', 'Phiên bản'),
txt('dataset', 'Bộ dữ liệu sử dụng'),
num('auc', 'AUC', { min: 0, max: 1, step: 0.01 }),
num('sensitivity', 'Độ nhạy', { min: 0, max: 1, step: 0.01 }),
num('specificity', 'Độ đặc hiệu', { min: 0, max: 1, step: 0.01 }),
num('accuracy', 'Độ chính xác', { min: 0, max: 1, step: 0.01 }),
txt('owner', 'Người phụ trách'),
area('notes', 'Ghi chú'),
sel('status', 'Trạng thái', ['Kế hoạch', 'Đang huấn luyện', 'Đang đánh giá', 'Đã triển khai'], 'Kế hoạch'),
],
},
assets: {
icon: Boxes,
title: 'Tài sản',
singular: 'tài sản',
accent: 'text-amber-600',
primary: 'name',
secondary: [
['category', ''],
['acquisition', ''],
],
badge: 'status',
fields: [
txt('name', 'Tên tài sản'),
sel('category', 'Phân loại', ['Thiết bị', 'Phần mềm', 'Giấy phép', 'Sản phẩm bàn giao'], 'Thiết bị'),
sel('acquisition', 'Hình thức', ['Hiện có', 'Điều chuyển', 'Thuê', 'Mua mới'], 'Hiện có'),
num('value', 'Giá trị (triệu đồng)', { min: 0 }),
txt('owner', 'Người quản lý'),
area('notes', 'Thông số / ghi chú'),
sel('status', 'Trạng thái', ['Đang dùng', 'Đặt mua', 'Bảo trì', 'Ngừng'], 'Đang dùng'),
],
},
milestones: {
icon: CalendarClock,
title: 'Tiến độ',
singular: 'mốc tiến độ',
accent: 'text-rose-600',
primary: 'title',
secondary: [
['start', ''],
['end', ''],
],
badge: 'status',
progress: 'progress',
fields: [
txt('title', 'Nội dung / công việc'),
area('deliverable', 'Kết quả phải đạt'),
txt('start', 'Bắt đầu (tháng/năm)'),
txt('end', 'Kết thúc (tháng/năm)'),
txt('owner', 'Chủ trì'),
num('budget', 'Kinh phí (triệu đồng)', { min: 0 }),
num('progress', 'Tiến độ (%)', { min: 0, max: 100, step: 5 }),
sel('status', 'Trạng thái', ['Chưa bắt đầu', 'Đang thực hiện', 'Hoàn thành', 'Trễ hạn'], 'Chưa bắt đầu'),
],
},
};
export const ENTITY_ORDER = ['members', 'datasets', 'models', 'assets', 'milestones'] as const;
export type CockpitEntityKey = (typeof ENTITY_ORDER)[number];
// Status → tone (semantic colors for the dashboard badges).
export type Tone = 'emerald' | 'blue' | 'violet' | 'amber' | 'rose' | 'slate';
const STATUS_TONE: Record<string, Tone> = {
'Sẵn sàng': 'emerald', 'Đã triển khai': 'emerald', 'Hoàn thành': 'emerald', 'Đã duyệt': 'emerald',
'Đang dùng': 'emerald', 'Đang hoạt động': 'emerald',
'Đang đánh giá': 'blue', 'Đang thực hiện': 'blue',
'Đang huấn luyện': 'violet',
'Đang thu thập': 'amber', 'Đang làm sạch': 'amber', 'Chờ duyệt': 'amber', 'Đặt mua': 'amber',
'Bảo trì': 'amber', 'Chưa bắt đầu': 'amber', 'Kế hoạch': 'amber', 'Chờ xác nhận': 'amber', 'Thuê': 'amber',
'Trễ hạn': 'rose', 'Khóa': 'rose', 'PII': 'rose', 'Ngừng': 'rose', 'Tạm dừng': 'rose',
};
const TONE_CLASS: Record<Tone, string> = {
emerald: 'bg-emerald-100 text-emerald-700 border-emerald-200',
blue: 'bg-blue-100 text-blue-700 border-blue-200',
violet: 'bg-violet-100 text-violet-700 border-violet-200',
amber: 'bg-amber-100 text-amber-700 border-amber-200',
rose: 'bg-rose-100 text-rose-700 border-rose-200',
slate: 'bg-slate-100 text-slate-600 border-slate-200',
};
export const toneClass = (v: unknown): string => TONE_CLASS[STATUS_TONE[String(v)] ?? 'slate'];
export const fmt = (n: unknown): string => (Number(n) || 0).toLocaleString('vi-VN');
export const pct = (n: unknown): number => (n == null ? 0 : Math.max(0, Math.min(100, Number(n))));
export const asNum = (n: unknown): number => Number(n) || 0;
export const asStr = (v: unknown): string => (v == null ? '' : String(v));
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
DETAIL_SECTIONS,
SECTION_BY_TAB,
displayValue,
fmtMoney,
readContent,
type DetailField,
} from './detailConfig';
describe('detailConfig schema', () => {
it('has the five administrative sections in order with unique tab ids', () => {
expect(DETAIL_SECTIONS.map((s) => s.tab)).toEqual([
'general',
'contract',
'funding',
'timeline',
'acceptance',
]);
expect(DETAIL_SECTIONS.map((s) => s.index)).toEqual(['I', 'II', 'III', 'IV', 'V']);
});
it('SECTION_BY_TAB resolves every section', () => {
for (const s of DETAIL_SECTIONS) expect(SECTION_BY_TAB[s.tab]).toBe(s);
});
it('field keys are globally unique (no content-key collisions across sections)', () => {
const keys = DETAIL_SECTIONS.flatMap((s) => s.fields.map((f) => f.key));
expect(new Set(keys).size).toBe(keys.length);
});
it('every select field declares options', () => {
const selects = DETAIL_SECTIONS.flatMap((s) => s.fields).filter((f) => f.type === 'select');
expect(selects.length).toBeGreaterThan(0);
for (const f of selects) expect(f.options && f.options.length).toBeTruthy();
});
it('reuses the proposal keys that the BE promotes to columns', () => {
const keys = new Set(DETAIL_SECTIONS.flatMap((s) => s.fields.map((f) => f.key)));
for (const k of ['tenDeTai', 'chuNhiem.hoTen', 'tongKinhPhi', 'thoiGianThucHienThang', 'capDeTai']) {
expect(keys.has(k)).toBe(true);
}
});
});
describe('display helpers', () => {
const money: DetailField = { key: 'x', label: '', type: 'money' };
const months: DetailField = { key: 'x', label: '', type: 'months' };
const text: DetailField = { key: 'x', label: '', type: 'text' };
it('fmtMoney groups vi-VN and appends VNĐ', () => {
expect(fmtMoney(29900000)).toBe('29.900.000 VNĐ');
expect(fmtMoney('25600000')).toBe('25.600.000 VNĐ');
expect(fmtMoney('')).toBe('');
expect(fmtMoney(0)).toBe('');
expect(fmtMoney('abc')).toBe('');
});
it('displayValue formats by type and returns empty string for blanks', () => {
expect(displayValue(money, 29900000)).toBe('29.900.000 VNĐ');
expect(displayValue(months, 24)).toBe('24 tháng');
expect(displayValue(text, 'Dược')).toBe('Dược');
expect(displayValue(text, '')).toBe('');
expect(displayValue(text, null)).toBe('');
expect(displayValue(text, undefined)).toBe('');
});
});
describe('readContent', () => {
it('reads flat dotted keys directly', () => {
const content = { tenDeTai: 'A', 'chuNhiem.hoTen': 'Lâm', tongKinhPhi: 300 };
expect(readContent(content, 'chuNhiem.hoTen')).toBe('Lâm');
expect(readContent(content, 'tongKinhPhi')).toBe(300);
expect(readContent(content, 'missing')).toBeUndefined();
expect(readContent(undefined, 'x')).toBeUndefined();
});
});
@@ -0,0 +1,149 @@
// Administrative-detail schema for the project cockpit — mirrors the institutional
// "đề tài" detail sheet (Thông tin chung / Hợp đồng / Kinh phí / Thời gian / Nghiệm thu).
// One config drives BOTH the read-only section panels AND the edit dialog.
//
// Storage: every `key` is a flat (optionally dotted) key inside ResearchProject.content
// JSONB. Some keys are REUSED from the proposal form (tenDeTai, chuNhiem.hoTen, tongKinhPhi,
// kinhPhiKhoan, kinhPhiKhongKhoan, thoiGianThucHienThang, capDeTai, loaiHinhDeTai, maSo);
// the rest are net-new administrative fields filled in at cockpit time. Edits are persisted
// via updateProjectDetail() which shallow-merges, so the proposal keys are never clobbered.
export type DetailFieldType = 'text' | 'textarea' | 'number' | 'money' | 'months' | 'date' | 'select';
export interface DetailField {
key: string;
label: string;
type: DetailFieldType;
options?: string[];
full?: boolean;
}
export interface DetailSection {
index: string; // roman numeral shown in the panel header
tab: string; // tab id this section renders under
title: string; // panel + tab title
fields: DetailField[];
}
const STATUS_OPTIONS = ['Chưa có', 'Đang thực hiện', 'Tạm dừng', 'Hoàn thành', 'Trễ hạn'];
const GRADE_OPTIONS = ['Xuất sắc', 'Tốt', 'Khá', 'Đạt', 'Không đạt'];
/** Section → tab id. Keep in sync with the cockpit tab list. */
export const DETAIL_TABS = {
general: 'general',
contract: 'contract',
funding: 'funding',
timeline: 'timeline',
acceptance: 'acceptance',
} as const;
export const DETAIL_SECTIONS: DetailSection[] = [
{
index: 'I',
tab: 'general',
title: 'Thông tin chung',
fields: [
{ key: 'tenDeTai', label: 'Tên đề tài', type: 'text', full: true },
{ key: 'chuNhiem.hoTen', label: 'Chủ nhiệm', type: 'text' },
{ key: 'chuNhiem.gioiTinh', label: 'Giới tính', type: 'text' },
{ key: 'chuNhiem.namSinh', label: 'Năm sinh', type: 'text' },
{ key: 'khoaDonVi', label: 'Khoa / Đơn vị', type: 'text' },
{ key: 'boMon', label: 'Bộ môn', type: 'text' },
{ key: 'linhVucNC', label: 'Lĩnh vực NC', type: 'text' },
{ key: 'loaiHinhDeTai', label: 'Loại hình NC', type: 'text' },
{ key: 'capDeTai', label: 'Loại đề tài', type: 'text' },
{ key: 'thanhVienNC', label: 'Thành viên NC', type: 'textarea', full: true },
],
},
{
index: 'II',
tab: 'contract',
title: 'Hợp đồng & Quyết định',
fields: [
{ key: 'soHopDong', label: 'Số hợp đồng', type: 'text' },
{ key: 'phuLucHopDong', label: 'Phụ lục hợp đồng', type: 'text' },
{ key: 'ngayKyHopDong', label: 'Ngày ký HĐ', type: 'date' },
{ key: 'qdXetDuyet', label: 'QĐ xét duyệt', type: 'text' },
{ key: 'qdPheDuyet', label: 'QĐ phê duyệt', type: 'text' },
{ key: 'soGCNKetQua', label: 'Số GCN kết quả', type: 'text' },
{ key: 'ngayCapGCN', label: 'Ngày cấp GCN', type: 'date' },
{ key: 'coQuanCapGCN', label: 'Cơ quan cấp GCN', type: 'text' },
],
},
{
index: 'III',
tab: 'funding',
title: 'Kinh phí & Phân bổ',
fields: [
{ key: 'tongKinhPhi', label: 'Tổng kinh phí', type: 'money' },
{ key: 'kinhPhiKhoan', label: 'Kinh phí khoán', type: 'money' },
{ key: 'kinhPhiKhongKhoan', label: 'Kinh phí không khoán', type: 'money' },
{ key: 'nguonKhac', label: 'Nguồn khác', type: 'money' },
{ key: 'capDot1', label: 'Cấp đợt 1', type: 'money' },
{ key: 'capDot2', label: 'Cấp đợt 2', type: 'money' },
{ key: 'capDot3', label: 'Cấp đợt 3', type: 'money' },
],
},
{
index: 'IV',
tab: 'timeline',
title: 'Thời gian & Tiến độ',
fields: [
{ key: 'thoiGianThucHienThang', label: 'Thời gian TH', type: 'months' },
{ key: 'thoiGianBatDau', label: 'Bắt đầu', type: 'date' },
{ key: 'thoiGianKetThuc', label: 'Kết thúc', type: 'date' },
{ key: 'giaHan', label: 'Gia hạn', type: 'text' },
{ key: 'trangThaiTienDo', label: 'Tiến độ thực hiện', type: 'select', options: STATUS_OPTIONS },
{ key: 'baoCaoGiamDinh', label: 'Báo cáo giám định', type: 'date' },
{ key: 'bcTienDo1', label: 'BC tiến độ 1', type: 'date' },
{ key: 'bcTienDo2', label: 'BC tiến độ 2', type: 'date' },
{ key: 'bcTienDo3', label: 'BC tiến độ 3', type: 'date' },
{ key: 'bcTienDo4', label: 'BC tiến độ 4', type: 'date' },
{ key: 'ngayNhac', label: 'Ngày nhắc', type: 'date' },
{ key: 'ghiChuBaoCao', label: 'Ghi chú báo cáo', type: 'textarea', full: true },
],
},
{
index: 'V',
tab: 'acceptance',
title: 'Nghiệm thu',
fields: [
{ key: 'ngayNghiemThu', label: 'Ngày nghiệm thu', type: 'date' },
{ key: 'soQDNghiemThu', label: 'Số QĐ nghiệm thu', type: 'text' },
{ key: 'xepLoaiNghiemThu', label: 'Xếp loại', type: 'select', options: GRADE_OPTIONS },
{ key: 'hoiDongNghiemThu', label: 'Hội đồng nghiệm thu', type: 'textarea', full: true },
{ key: 'ketQuaNghiemThu', label: 'Kết quả nghiệm thu', type: 'textarea', full: true },
{ key: 'ghiChuNghiemThu', label: 'Ghi chú', type: 'textarea', full: true },
],
},
];
export const SECTION_BY_TAB: Record<string, DetailSection> = Object.fromEntries(
DETAIL_SECTIONS.map((s) => [s.tab, s]),
);
/** Read a (possibly dotted) content key. Keys in content are flat strings, so a direct lookup. */
export function readContent(content: Record<string, unknown> | undefined, key: string): unknown {
if (!content) return undefined;
return content[key];
}
/** Money in triệu/đồng → grouped string. Accepts number or numeric string. */
export function fmtMoney(v: unknown): string {
const n = Number(v);
if (!v || Number.isNaN(n)) return '';
return n.toLocaleString('vi-VN') + ' VNĐ';
}
/** Format a field value for the READ panel per its type. Returns '' when empty (panel shows ---). */
export function displayValue(field: DetailField, raw: unknown): string {
if (raw === undefined || raw === null || raw === '') return '';
switch (field.type) {
case 'money':
return fmtMoney(raw);
case 'months':
return `${raw} tháng`;
default:
return String(raw);
}
}
@@ -0,0 +1,262 @@
import type { ReactNode } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { Button, Checkbox, Input, Label, RadioGroup, RadioGroupItem, Textarea } from '@ump/shared';
import {
emptyRow,
shouldShow,
type ColumnDef,
type FieldDef,
type FormValues,
type RepeatableDef,
type RepeatableRow,
} from './proposalSchema';
type ChangeFn = (key: string, value: string | string[]) => void;
function ReqStar({ required }: { required?: boolean }) {
return required ? <span className="ml-0.5 text-destructive">*</span> : null;
}
export function FieldControl({
field,
fullKey,
value,
error,
onChange,
}: {
field: FieldDef;
fullKey: string;
value: string | string[] | undefined;
error?: boolean;
onChange: ChangeFn;
}) {
const id = 'f_' + fullKey.replace(/\W/g, '_');
if (field.type === 'radio') {
return (
<div className="mb-4">
<Label className="mb-1.5 block text-sm font-semibold">
{field.label}
<ReqStar required={field.required} />
</Label>
{field.hint && <p className="mb-1.5 text-xs text-muted-foreground">{field.hint}</p>}
<RadioGroup
className="gap-2"
value={typeof value === 'string' ? value : ''}
onValueChange={(v) => onChange(fullKey, v)}
>
{field.options?.map((o) => (
<div key={o.value} className="flex items-center gap-2">
<RadioGroupItem value={o.value} id={`${id}_${o.value}`} />
<Label htmlFor={`${id}_${o.value}`} className="cursor-pointer text-sm font-normal">
{o.label}
</Label>
</div>
))}
</RadioGroup>
</div>
);
}
if (field.type === 'checkboxGroup') {
const arr = Array.isArray(value) ? value : [];
return (
<div className="mb-4">
<Label className="mb-1.5 block text-sm font-semibold">
{field.label}
<ReqStar required={field.required} />
</Label>
<div className="flex flex-col gap-2">
{field.options?.map((o) => (
<label key={o.value} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={arr.includes(o.value)}
onCheckedChange={(c) =>
onChange(fullKey, c === true ? [...arr, o.value] : arr.filter((x) => x !== o.value))
}
/>
<span>{o.label}</span>
</label>
))}
</div>
</div>
);
}
const labelEl = (
<Label htmlFor={id} className="mb-1 block text-sm font-semibold">
{field.label}
<ReqStar required={field.required} />
</Label>
);
const text = typeof value === 'string' ? value : '';
if (field.type === 'textarea') {
return (
<div className="mb-4">
{labelEl}
{field.hint && <p className="mb-1.5 text-xs text-muted-foreground">{field.hint}</p>}
<Textarea
id={id}
className={'min-h-24' + (error ? ' border-destructive' : '')}
value={text}
onChange={(e) => onChange(fullKey, e.target.value)}
/>
{error && <p className="mt-1 text-xs text-destructive">Bắt buộc nhập.</p>}
</div>
);
}
const htmlType =
field.type === 'number'
? 'number'
: field.type === 'email'
? 'email'
: field.type === 'tel'
? 'tel'
: field.type === 'date'
? 'date'
: 'text';
return (
<div className="mb-4">
{labelEl}
{field.hint && <p className="mb-1.5 text-xs text-muted-foreground">{field.hint}</p>}
<Input
id={id}
type={htmlType}
min={field.min}
className={error ? 'border-destructive' : ''}
value={text}
onChange={(e) => onChange(fullKey, e.target.value)}
/>
{error && <p className="mt-1 text-xs text-destructive">Bắt buộc nhập.</p>}
</div>
);
}
/** Render a flat list of fields, grouping consecutive `inline` fields onto one row. */
export function FieldList({
fields,
prefix,
values,
errors,
onChange,
}: {
fields: FieldDef[];
prefix?: string;
values: FormValues;
errors: Set<string>;
onChange: ChangeFn;
}) {
const out: ReactNode[] = [];
let buf: FieldDef[] = [];
const renderOne = (f: FieldDef) => {
const k = prefix ? `${prefix}.${f.key}` : f.key;
const v = values[k];
return (
<FieldControl
key={k}
field={f}
fullKey={k}
value={Array.isArray(v) ? (v as string[]) : (v as string | undefined)}
error={errors.has(k)}
onChange={onChange}
/>
);
};
const flush = () => {
if (!buf.length) return;
out.push(
<div className="flex flex-wrap gap-4" key={`row_${buf[0].key}`}>
{buf.map((f) => (
<div className="min-w-40 flex-1" key={f.key}>
{renderOne(f)}
</div>
))}
</div>,
);
buf = [];
};
for (const f of fields) {
if (!shouldShow(f, values)) continue;
if (f.inline) buf.push(f);
else {
flush();
out.push(renderOne(f));
}
}
flush();
return <>{out}</>;
}
export function Repeatable({
rep,
rows,
onRows,
}: {
rep: RepeatableDef;
rows: RepeatableRow[];
onRows: (key: string, rows: RepeatableRow[]) => void;
}) {
const update = (i: number, ck: string, val: string) =>
onRows(rep.key, rows.map((r, idx) => (idx === i ? { ...r, [ck]: val } : r)));
const add = () => onRows(rep.key, [...rows, emptyRow(rep.columns)]);
const remove = (i: number) => onRows(rep.key, rows.filter((_, idx) => idx !== i));
return (
<div className="my-5">
<h3 className="mb-2.5 text-sm font-semibold text-foreground">{rep.title}</h3>
{rows.map((row, i) => (
<div key={i} className="mb-3 rounded-lg border border-border bg-muted/30 p-4">
<div className="mb-2.5 flex items-center justify-between text-xs font-semibold text-muted-foreground">
<span>
{rep.rowLabel} {i + 1}
</span>
{rows.length > rep.minRows && (
<button
type="button"
className="inline-flex items-center gap-1 text-destructive hover:underline disabled:opacity-50"
onClick={() => remove(i)}
>
<Trash2 className="h-3.5 w-3.5" /> Xóa
</button>
)}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{rep.columns.map((c: ColumnDef) => {
const cid = `r_${rep.key}_${i}_${c.key}`;
return (
<div key={c.key}>
<Label htmlFor={cid} className="mb-1 block text-sm font-medium">
{c.label}
</Label>
{c.type === 'textarea' ? (
<Textarea
id={cid}
className="min-h-20"
value={row[c.key] ?? ''}
onChange={(e) => update(i, c.key, e.target.value)}
/>
) : (
<Input
id={cid}
type={c.type === 'number' ? 'number' : 'text'}
value={row[c.key] ?? ''}
onChange={(e) => update(i, c.key, e.target.value)}
/>
)}
</div>
);
})}
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={add}>
<Plus className="mr-1.5 h-4 w-4" /> {rep.addLabel}
</Button>
</div>
);
}
@@ -0,0 +1,504 @@
// Digital "Thuyết minh đề tài nghiên cứu ứng dụng" (Mẫu III.06-TM.ĐTUD, 09/2024/TT-BKHCN).
// Schema + form-state helpers, ported from the design artifact. The renderer
// (ProposalFormFields.tsx) restyles these definitions with the shared design system.
export type FieldType =
| 'text'
| 'textarea'
| 'number'
| 'radio'
| 'checkboxGroup'
| 'tel'
| 'email'
| 'date';
export interface FieldDef {
key: string;
label: string;
type: FieldType;
required?: boolean;
placeholder?: string;
hint?: string;
default?: string;
inline?: boolean;
min?: number;
options?: { value: string; label: string }[];
showIf?: { field: string; equals?: string; contains?: string };
}
export interface SubsectionDef {
id: string;
title: string;
keyPrefix: string;
fields: FieldDef[];
}
export interface ColumnDef {
key: string;
label: string;
type: FieldType;
default?: string;
}
export interface RepeatableDef {
key: string;
title: string;
minRows: number;
addLabel: string;
rowLabel: string;
columns: ColumnDef[];
seedRows?: Record<string, string>[];
}
export interface SectionDef {
id: string;
title: string;
fields: FieldDef[];
subsections?: SubsectionDef[];
repeatables?: RepeatableDef[];
}
export interface ProposalSchema {
meta: { formCode: string; circular: string; title: string; subtitleField: string; language: string };
topFields: FieldDef[];
sections: SectionDef[];
signature: Record<string, FieldDef>;
}
export type RepeatableRow = Record<string, string>;
export type FormValues = Record<string, string | string[] | RepeatableRow[]>;
export const schema: ProposalSchema = {
meta: {
formCode: 'Mẫu III.06-TM.ĐTUD',
circular: '09/2024/TT-BKHCN',
title: 'THUYẾT MINH ĐỀ TÀI NGHIÊN CỨU ỨNG DỤNG VÀ PHÁT TRIỂN CÔNG NGHỆ',
subtitleField: 'capDeTai',
language: 'vi',
},
topFields: [
{
key: 'capDeTai',
label: 'Cấp đề tài',
type: 'text',
placeholder: '{{capDeTai}}',
hint: 'Điền vào chỗ trống của tiêu đề: "... CẤP ..." (ví dụ: cấp Thành phố, cấp Cơ sở).',
},
],
sections: [
{
id: 'I',
title: 'THÔNG TIN CHUNG VỀ ĐỀ TÀI',
fields: [
{ key: 'tenDeTai', label: '1. Tên đề tài', type: 'textarea', required: true, placeholder: '{{tenDeTai}}' },
{ key: 'maSo', label: 'Mã số', type: 'text', placeholder: '{{maSo}}', hint: 'Được cấp khi hồ sơ trúng tuyển — thường để trống khi nộp.' },
{ key: 'thoiGianThucHienThang', label: '2. Thời gian thực hiện (tháng)', type: 'number', required: true, placeholder: '{{thoiGianThucHienThang}}', min: 1 },
{ key: 'thoiGianTuThang', label: 'Từ tháng', type: 'text', placeholder: '{{thoiGianTuThang}}', inline: true },
{ key: 'thoiGianTuNam', label: 'Năm', type: 'text', placeholder: '{{thoiGianTuNam}}', inline: true },
{ key: 'thoiGianDenThang', label: 'Đến tháng', type: 'text', placeholder: '{{thoiGianDenThang}}', inline: true },
{ key: 'thoiGianDenNam', label: 'Năm', type: 'text', placeholder: '{{thoiGianDenNam}}', inline: true },
{ key: 'capQuanLy', label: '3. Cấp quản lý', type: 'text', default: 'Sở Khoa học Công nghệ - Tp Hồ Chí Minh', placeholder: '{{capQuanLy}}' },
{ key: 'tongKinhPhi', label: '4. Tổng kinh phí thực hiện (triệu đồng)', type: 'number', placeholder: '{{tongKinhPhi}}', min: 0 },
{ key: 'kinhPhiNganSach', label: 'Từ ngân sách nhà nước (triệu đồng)', type: 'number', placeholder: '{{kinhPhiNganSach}}', inline: true, min: 0 },
{ key: 'kinhPhiNgoaiNganSach', label: 'Từ nguồn ngoài ngân sách (triệu đồng)', type: 'number', placeholder: '{{kinhPhiNgoaiNganSach}}', inline: true, min: 0 },
{
key: 'phuongThucKhoan',
label: '5. Đề nghị phương thức khoán chi',
type: 'radio',
placeholder: '{{phuongThucKhoan}}',
options: [
{ value: 'cuoiCung', label: 'Khoán đến sản phẩm cuối cùng' },
{ value: 'tungPhan', label: 'Khoán từng phần' },
],
},
{ key: 'kinhPhiKhoan', label: 'Kinh phí khoán (triệu đồng)', type: 'number', placeholder: '{{kinhPhiKhoan}}', inline: true, showIf: { field: 'phuongThucKhoan', equals: 'tungPhan' } },
{ key: 'kinhPhiKhongKhoan', label: 'Kinh phí không khoán (triệu đồng)', type: 'number', placeholder: '{{kinhPhiKhongKhoan}}', inline: true, showIf: { field: 'phuongThucKhoan', equals: 'tungPhan' } },
{ key: 'loaiHinhDeTai', label: '6. Loại hình đề tài', type: 'text', placeholder: '{{loaiHinhDeTai}}' },
{
key: 'linhVuc',
label: '7. Lĩnh vực',
type: 'checkboxGroup',
placeholder: '{{linhVuc}}',
options: [
{ value: 'tuNhien', label: 'Tự nhiên' },
{ value: 'nongNghiep', label: 'Nông nghiệp' },
{ value: 'kyThuatCongNghe', label: 'Kỹ thuật và công nghệ' },
{ value: 'yDuoc', label: 'Y, dược' },
{ value: 'khac', label: 'Lĩnh vực khác' },
],
},
{ key: 'linhVucKhac', label: 'Ghi rõ lĩnh vực khác', type: 'text', placeholder: '{{linhVucKhac}}', showIf: { field: 'linhVuc', contains: 'khac' } },
],
subsections: [
{
id: '8',
title: '8. Chủ nhiệm đề tài',
keyPrefix: 'chuNhiem',
fields: [
{ key: 'hoTen', label: 'Họ và tên', type: 'text', required: true },
{ key: 'soDinhDanh', label: 'Số định danh cá nhân', type: 'text' },
{ key: 'ngaySinh', label: 'Ngày, tháng, năm sinh', type: 'text' },
{ key: 'gioiTinh', label: 'Giới tính', type: 'radio', options: [{ value: 'nam', label: 'Nam' }, { value: 'nu', label: 'Nữ' }] },
{ key: 'hocVi', label: 'Học hàm, học vị / Trình độ chuyên môn', type: 'text' },
{ key: 'chucDanhNgheNghiep', label: 'Chức danh nghề nghiệp', type: 'text' },
{ key: 'chucVu', label: 'Chức vụ', type: 'text' },
{ key: 'dienThoai', label: 'Điện thoại', type: 'tel' },
{ key: 'email', label: 'E-mail', type: 'email' },
{ key: 'tenToChuc', label: 'Tên tổ chức đang công tác', type: 'text' },
{ key: 'diaChiToChuc', label: 'Địa chỉ tổ chức', type: 'text' },
],
},
{
id: '9',
title: '9. Thư ký khoa học của đề tài',
keyPrefix: 'thuKy',
fields: [
{ key: 'hoTen', label: 'Họ và tên', type: 'text' },
{ key: 'soDinhDanh', label: 'Số định danh cá nhân', type: 'text' },
{ key: 'ngaySinh', label: 'Ngày, tháng, năm sinh', type: 'text' },
{ key: 'gioiTinh', label: 'Giới tính', type: 'radio', options: [{ value: 'nam', label: 'Nam' }, { value: 'nu', label: 'Nữ' }] },
{ key: 'hocVi', label: 'Học hàm, học vị / Trình độ chuyên môn', type: 'text' },
{ key: 'chucDanhNgheNghiep', label: 'Chức danh nghề nghiệp', type: 'text' },
{ key: 'chucVu', label: 'Chức vụ', type: 'text' },
{ key: 'dienThoai', label: 'Điện thoại', type: 'tel' },
{ key: 'email', label: 'E-mail', type: 'email' },
{ key: 'tenToChuc', label: 'Tên tổ chức đang công tác', type: 'text' },
{ key: 'diaChiToChuc', label: 'Địa chỉ tổ chức', type: 'text' },
],
},
{
id: '10',
title: '10. Tổ chức chủ trì đề tài',
keyPrefix: 'toChucChuTri',
fields: [
{ key: 'tenToChuc', label: 'Tên tổ chức chủ trì đề tài', type: 'text', required: true },
{ key: 'maSoToChuc', label: 'Mã số tổ chức', type: 'text' },
{ key: 'dienThoai', label: 'Điện thoại', type: 'tel' },
{ key: 'website', label: 'Website', type: 'text' },
{ key: 'diaChi', label: 'Địa chỉ', type: 'text' },
{ key: 'nguoiDungDau', label: 'Họ và tên người đứng đầu', type: 'text' },
{ key: 'soTaiKhoan', label: 'Số tài khoản', type: 'text' },
{ key: 'khoBac', label: 'Tại kho bạc Nhà nước', type: 'text' },
{ key: 'nganHang', label: 'Ngân hàng', type: 'text' },
],
},
],
repeatables: [
{
key: 'toChucPhoiHop',
title: '11. Các tổ chức phối hợp chính thực hiện đề tài (nếu có)',
minRows: 0,
addLabel: 'Thêm tổ chức phối hợp',
rowLabel: 'Tổ chức',
columns: [
{ key: 'tenToChuc', label: 'Tên tổ chức', type: 'text' },
{ key: 'maSoToChuc', label: 'Mã số tổ chức', type: 'text' },
{ key: 'dienThoai', label: 'Điện thoại', type: 'tel' },
{ key: 'diaChi', label: 'Địa chỉ', type: 'text' },
{ key: 'nguoiDungDau', label: 'Người đứng đầu', type: 'text' },
{ key: 'coQuanChuQuan', label: 'Cơ quan chủ quản', type: 'text' },
],
},
{
key: 'thanhVienThucHien',
title: '12. Thành viên thực hiện đề tài',
minRows: 1,
addLabel: 'Thêm thành viên',
rowLabel: 'Thành viên',
columns: [
{ key: 'hoTenHocVi', label: 'Họ và tên, học hàm, học vị', type: 'text' },
{ key: 'chucDanh', label: 'Chức danh thực hiện đề tài', type: 'text', default: 'Thành viên chính' },
{ key: 'toChucCongTac', label: 'Tổ chức công tác', type: 'text' },
],
},
],
},
{
id: 'II',
title: 'MỤC TIÊU, NỘI DUNG VÀ PHƯƠNG ÁN TỔ CHỨC THỰC HIỆN ĐỀ TÀI',
fields: [
{ key: 'mucTieuTongQuat', label: '13. Mục tiêu tổng quát', type: 'textarea', required: true, placeholder: '{{mucTieuTongQuat}}' },
{ key: 'mucTieuCuThe', label: 'Mục tiêu cụ thể', type: 'textarea', placeholder: '{{mucTieuCuThe}}', hint: 'Mỗi mục tiêu trên một dòng.' },
{
key: 'tinhTrangDeTai',
label: '14. Tình trạng của đề tài',
type: 'radio',
placeholder: '{{tinhTrangDeTai}}',
options: [
{ value: 'moi', label: 'Mới' },
{ value: 'keTiepNhom', label: 'Kế tiếp hướng nghiên cứu của chính nhóm tác giả' },
{ value: 'keTiepNguoiKhac', label: 'Kế tiếp nghiên cứu của người khác' },
],
},
{ key: 'tongQuanNgoaiNuoc', label: '15.1 Tổng quan tình hình nghiên cứu — Ngoài nước', type: 'textarea', placeholder: '{{tongQuanNgoaiNuoc}}' },
{ key: 'tongQuanTrongNuoc', label: '15.1 Tổng quan tình hình nghiên cứu — Trong nước', type: 'textarea', placeholder: '{{tongQuanTrongNuoc}}' },
{ key: 'luanGiaiNoiDung', label: '15.2 Luận giải về những nội dung cần nghiên cứu', type: 'textarea', placeholder: '{{luanGiaiNoiDung}}' },
{ key: 'danhMucCongTrinh', label: '16. Danh mục các công trình, tài liệu đã trích dẫn', type: 'textarea', placeholder: '{{danhMucCongTrinh}}', hint: 'Tên công trình, tác giả, nơi và năm công bố — mỗi tài liệu một dòng.' },
{ key: 'phuongAnPhoiHopTrongNuoc', label: '18. Phương án phối hợp với các tổ chức trong nước', type: 'textarea', placeholder: '{{phuongAnPhoiHopTrongNuoc}}' },
{ key: 'phuongAnHopTacQuocTe', label: '19. Phương án hợp tác quốc tế (nếu có)', type: 'textarea', placeholder: '{{phuongAnHopTacQuocTe}}' },
],
repeatables: [
{
key: 'noiDungNghienCuu',
title: '17. Nội dung nghiên cứu khoa học và triển khai thực nghiệm',
minRows: 1,
addLabel: 'Thêm nội dung',
rowLabel: 'Nội dung',
columns: [
{ key: 'tenNoiDung', label: 'Tên nội dung', type: 'text' },
{ key: 'moTa', label: 'Mô tả / cách tiếp cận', type: 'textarea' },
{ key: 'congViec', label: 'Các công việc (mỗi công việc một dòng)', type: 'textarea' },
],
},
{
key: 'thueChuyenGiaTrongNuoc',
title: '20.1 Thuê chuyên gia trong nước (nếu có)',
minRows: 0,
addLabel: 'Thêm chuyên gia trong nước',
rowLabel: 'Chuyên gia',
columns: [
{ key: 'hoTenHocVi', label: 'Họ và tên, học hàm, học vị', type: 'text' },
{ key: 'toChuc', label: 'Thuộc tổ chức', type: 'text' },
{ key: 'linhVucChuyenMon', label: 'Lĩnh vực chuyên môn', type: 'text' },
{ key: 'noiDungLyDo', label: 'Nội dung thực hiện và lý do cần thuê', type: 'textarea' },
{ key: 'thoiGianQuyDoi', label: 'Thời gian quy đổi (tháng)', type: 'number' },
],
},
{
key: 'thueChuyenGiaNuocNgoai',
title: '20.2 Thuê chuyên gia nước ngoài (nếu có)',
minRows: 0,
addLabel: 'Thêm chuyên gia nước ngoài',
rowLabel: 'Chuyên gia',
columns: [
{ key: 'hoTenHocVi', label: 'Họ và tên, học hàm, học vị', type: 'text' },
{ key: 'quocTich', label: 'Quốc tịch', type: 'text' },
{ key: 'toChuc', label: 'Thuộc tổ chức', type: 'text' },
{ key: 'linhVucChuyenMon', label: 'Lĩnh vực chuyên môn', type: 'text' },
{ key: 'noiDungLyDo', label: 'Nội dung thực hiện và lý do cần thuê', type: 'textarea' },
{ key: 'thoiGianQuyDoi', label: 'Thời gian quy đổi (tháng)', type: 'number' },
],
},
{
key: 'tienDoThucHien',
title: '21. Tiến độ thực hiện',
minRows: 1,
addLabel: 'Thêm mốc tiến độ',
rowLabel: 'Mốc',
columns: [
{ key: 'noiDungCongViec', label: 'Nội dung, công việc / mốc đánh giá', type: 'textarea' },
{ key: 'ketQua', label: 'Kết quả phải đạt', type: 'textarea' },
{ key: 'thoiGian', label: 'Thời gian (bắt đầu kết thúc)', type: 'text' },
{ key: 'caNhanToChuc', label: 'Cá nhân, tổ chức chủ trì', type: 'text' },
{ key: 'kinhPhi', label: 'Dự kiến kinh phí (triệu đồng)', type: 'number' },
],
},
],
},
{
id: 'III',
title: 'SẢN PHẨM KHOA HỌC VÀ CÔNG NGHỆ (KH&CN) CỦA ĐỀ TÀI',
fields: [
{ key: 'trinhDoKhoaHoc', label: '22.2 Trình độ khoa học của sản phẩm (Dạng I & II) so với sản phẩm tương tự', type: 'textarea', placeholder: '{{trinhDoKhoaHoc}}' },
{ key: 'mucChatLuong', label: '22.3 Mức chất lượng sản phẩm (Dạng III) so với sản phẩm tương tự', type: 'textarea', placeholder: '{{mucChatLuong}}' },
{ key: 'khaNangThiTruong', label: '23.1 Khả năng về thị trường', type: 'textarea', placeholder: '{{khaNangThiTruong}}' },
{ key: 'khaNangUngDung', label: '23.2 Khả năng ứng dụng vào sản xuất kinh doanh', type: 'textarea', placeholder: '{{khaNangUngDung}}' },
{ key: 'khaNangLienDoanh', label: '23.3 Khả năng liên doanh liên kết với doanh nghiệp', type: 'textarea', placeholder: '{{khaNangLienDoanh}}' },
{ key: 'phuongThucChuyenGiao', label: '23.4 Mô tả phương thức chuyển giao', type: 'textarea', placeholder: '{{phuongThucChuyenGiao}}' },
{ key: 'phamViDiaChiUngDung', label: '24. Phạm vi và địa chỉ (dự kiến) ứng dụng kết quả', type: 'textarea', placeholder: '{{phamViDiaChiUngDung}}' },
{ key: 'tacDongKHCN', label: '25.1 Tác động đối với lĩnh vực KH&CN có liên quan', type: 'textarea', placeholder: '{{tacDongKHCN}}' },
{ key: 'tacDongToChuc', label: '25.2 Tác động đối với tổ chức chủ trì và cơ sở ứng dụng', type: 'textarea', placeholder: '{{tacDongToChuc}}' },
{ key: 'tacDongKinhTeXaHoi', label: '25.3 Tác động đối với kinh tế - xã hội và môi trường', type: 'textarea', placeholder: '{{tacDongKinhTeXaHoi}}' },
{ key: 'trangBiHienCo', label: '26.1.a Bố trí trong số thiết bị hiện có của tổ chức chủ trì', type: 'textarea', placeholder: '{{trangBiHienCo}}' },
{ key: 'dieuChuyenThietBi', label: '26.1.b Điều chuyển thiết bị máy móc (nếu có)', type: 'textarea', placeholder: '{{dieuChuyenThietBi}}' },
{ key: 'phuongAnXuLyTaiSan', label: '26.2 Đề xuất phương án xử lý tài sản', type: 'textarea', placeholder: '{{phuongAnXuLyTaiSan}}' },
],
repeatables: [
{
key: 'sanPhamDangI',
title: '22. Sản phẩm KH&CN — Dạng I: Công bố khoa học (bài báo, sách chuyên khảo…)',
minRows: 0,
addLabel: 'Thêm sản phẩm Dạng I',
rowLabel: 'Sản phẩm',
columns: [
{ key: 'tenSanPham', label: 'Tên sản phẩm', type: 'text' },
{ key: 'yeuCau', label: 'Yêu cầu chất lượng cần đạt', type: 'textarea' },
{ key: 'soLuong', label: 'Số lượng / dự kiến', type: 'text' },
],
},
{
key: 'sanPhamDangII',
title: '22. Sản phẩm KH&CN — Dạng II: Sản phẩm ứng dụng (phần mềm, quy trình, mô hình…)',
minRows: 0,
addLabel: 'Thêm sản phẩm Dạng II',
rowLabel: 'Sản phẩm',
columns: [
{ key: 'tenSanPham', label: 'Tên sản phẩm', type: 'text' },
{ key: 'chiTieu', label: 'Chỉ tiêu kinh tế - kỹ thuật', type: 'textarea' },
{ key: 'yeuCau', label: 'Yêu cầu chất lượng cần đạt', type: 'textarea' },
],
},
{
key: 'sanPhamDangIII',
title: '22. Sản phẩm KH&CN — Dạng III: Sản phẩm khác',
minRows: 0,
addLabel: 'Thêm sản phẩm Dạng III',
rowLabel: 'Sản phẩm',
columns: [
{ key: 'tenSanPham', label: 'Tên sản phẩm', type: 'text' },
{ key: 'yeuCau', label: 'Yêu cầu chất lượng cần đạt', type: 'textarea' },
],
},
{
key: 'daoTaoSauDaiHoc',
title: '22.1 Kết quả tham gia đào tạo sau đại học',
minRows: 0,
addLabel: 'Thêm dòng đào tạo',
rowLabel: 'Dòng',
columns: [
{ key: 'capDaoTao', label: 'Cấp đào tạo', type: 'text' },
{ key: 'soLuong', label: 'Số lượng', type: 'number' },
{ key: 'chuyenNganh', label: 'Chuyên ngành đào tạo', type: 'text' },
{ key: 'ghiChu', label: 'Ghi chú', type: 'text' },
],
},
{
key: 'thueThietBi',
title: '26.1.c Thuê thiết bị máy móc',
minRows: 0,
addLabel: 'Thêm thiết bị thuê',
rowLabel: 'Thiết bị',
columns: [
{ key: 'danhMucTaiSan', label: 'Danh mục tài sản', type: 'text' },
{ key: 'tinhNang', label: 'Tính năng, thông số kỹ thuật', type: 'textarea' },
{ key: 'thoiGianThue', label: 'Thời gian thuê', type: 'text' },
],
},
{
key: 'muaSamMoi',
title: '26.1.d Mua sắm mới thiết bị máy móc',
minRows: 0,
addLabel: 'Thêm thiết bị mua mới',
rowLabel: 'Thiết bị',
columns: [
{ key: 'danhMucTaiSan', label: 'Danh mục tài sản', type: 'text' },
{ key: 'tinhNang', label: 'Tính năng, thông số kỹ thuật', type: 'textarea' },
],
},
],
},
{
id: 'IV',
title: 'NHU CẦU KINH PHÍ THỰC HIỆN ĐỀ TÀI VÀ NGUỒN KINH PHÍ',
fields: [],
repeatables: [
{
key: 'kinhPhiTheoKhoanChi',
title: '27. Kinh phí thực hiện đề tài phân theo các khoản chi (triệu đồng)',
minRows: 1,
addLabel: 'Thêm nguồn kinh phí',
rowLabel: 'Nguồn',
seedRows: [
{ nguonKinhPhi: 'Tổng kinh phí' },
{ nguonKinhPhi: 'Ngân sách nhà nước — a. Khoán chi' },
{ nguonKinhPhi: 'Ngân sách nhà nước — b. Không khoán chi' },
{ nguonKinhPhi: 'Nguồn ngoài ngân sách nhà nước' },
],
columns: [
{ key: 'nguonKinhPhi', label: 'Nguồn kinh phí', type: 'text' },
{ key: 'tongSo', label: 'Tổng số', type: 'number' },
{ key: 'chiThuLao', label: 'Chi thù lao + chuyên gia', type: 'number' },
{ key: 'nguyenVatLieu', label: 'Nguyên, vật liệu, năng lượng', type: 'number' },
{ key: 'thietBi', label: 'Thiết bị, máy móc', type: 'number' },
{ key: 'xayDung', label: 'Xây dựng, sửa chữa nhỏ', type: 'number' },
{ key: 'chiKhac', label: 'Chi khác', type: 'number' },
],
},
{
key: 'duToanKinhPhi',
title: 'Phụ lục — Dự toán kinh phí thực hiện đề tài (triệu đồng)',
minRows: 0,
addLabel: 'Thêm khoản chi',
rowLabel: 'Khoản chi',
seedRows: [
{ noiDungKhoanChi: 'Chi thù lao thực hiện đề tài' },
{ noiDungKhoanChi: 'Thuê chuyên gia (trong nước / nước ngoài)' },
{ noiDungKhoanChi: 'Nguyên, vật liệu, năng lượng' },
{ noiDungKhoanChi: 'Thiết bị, máy móc' },
{ noiDungKhoanChi: 'Xây dựng, sửa chữa nhỏ' },
{ noiDungKhoanChi: 'Chi khác' },
],
columns: [
{ key: 'noiDungKhoanChi', label: 'Nội dung các khoản chi', type: 'text' },
{ key: 'tongKinhPhi', label: 'Tổng kinh phí', type: 'number' },
{ key: 'nsnnTongSo', label: 'NSNN — Tổng số', type: 'number' },
{ key: 'nsnnNam1', label: 'NSNN — Năm 1', type: 'number' },
{ key: 'nsnnNam2', label: 'NSNN — Năm 2', type: 'number' },
{ key: 'nsnnNam3', label: 'NSNN — Năm 3', type: 'number' },
{ key: 'ngoaiTongSo', label: 'Ngoài NSNN — Tổng số', type: 'number' },
],
},
],
},
],
signature: {
diaDiemNgay: { key: 'diaDiemNgay', label: 'Địa điểm, ngày tháng năm', type: 'text', placeholder: '{{diaDiemNgay}}' },
kyChuNhiem: { key: 'kyChuNhiem', label: 'Chủ nhiệm đề tài (họ và tên)', type: 'text', placeholder: '{{kyChuNhiem}}' },
kyToChucChuTri: { key: 'kyToChucChuTri', label: 'Tổ chức chủ trì (họ tên, đóng dấu)', type: 'text', placeholder: '{{kyToChucChuTri}}' },
kyCoQuanPheDuyet: { key: 'kyCoQuanPheDuyet', label: 'Cơ quan/đơn vị có thẩm quyền phê duyệt', type: 'text', placeholder: '{{kyCoQuanPheDuyet}}' },
},
};
export function emptyRow(cols: ColumnDef[]): RepeatableRow {
const r: RepeatableRow = {};
for (const c of cols) r[c.key] = c.default ?? '';
return r;
}
export function buildInitial(): FormValues {
const v: FormValues = {};
const applyField = (f: FieldDef, k: string) => {
v[k] = f.type === 'checkboxGroup' ? [] : f.default ?? '';
};
for (const f of schema.topFields) applyField(f, f.key);
for (const s of schema.sections) {
for (const f of s.fields) applyField(f, f.key);
for (const sub of s.subsections ?? []) for (const f of sub.fields) applyField(f, sub.keyPrefix + '.' + f.key);
for (const rep of s.repeatables ?? []) {
if (rep.seedRows?.length) v[rep.key] = rep.seedRows.map((sd) => ({ ...emptyRow(rep.columns), ...sd }));
else v[rep.key] = Array.from({ length: rep.minRows }, () => emptyRow(rep.columns));
}
}
for (const k of Object.keys(schema.signature)) v[schema.signature[k].key] = '';
return v;
}
export function shouldShow(field: FieldDef, values: FormValues): boolean {
if (!field.showIf) return true;
const cur = values[field.showIf.field];
if (field.showIf.equals !== undefined) return cur === field.showIf.equals;
if (field.showIf.contains !== undefined)
return Array.isArray(cur) && (cur as string[]).includes(field.showIf.contains);
return true;
}
export interface MissingField {
key: string;
label: string;
}
export function collectMissing(values: FormValues): MissingField[] {
const miss: MissingField[] = [];
const check = (f: FieldDef, k: string) => {
if (!f.required || !shouldShow(f, values)) return;
const val = values[k];
const empty = Array.isArray(val) ? val.length === 0 : !String(val ?? '').trim();
if (empty) miss.push({ key: k, label: f.label });
};
for (const f of schema.topFields) check(f, f.key);
for (const s of schema.sections) {
for (const f of s.fields) check(f, f.key);
for (const sub of s.subsections ?? []) for (const f of sub.fields) check(f, sub.keyPrefix + '.' + f.key);
}
return miss;
}
@@ -0,0 +1,46 @@
/**
* TotalSegmentator v2 "total" task — label value → organ key (the 117-class map).
*
* A multi-label labelsTr/<case>.nii.gz produced by TotalSegmentator encodes each organ as an
* integer voxel value 1..117. This maps those values to names so the per-organ overlay panel can
* label them (figure-3 style). A value not in the map falls back to "Nhãn <value>", so the split
* still works for non-TotalSegmentator segmentations — only the names are generic.
*/
export const TOTALSEGMENTATOR_V2_LABELS: Record<number, string> = {
1: 'spleen', 2: 'kidney_right', 3: 'kidney_left', 4: 'gallbladder', 5: 'liver',
6: 'stomach', 7: 'pancreas', 8: 'adrenal_gland_right', 9: 'adrenal_gland_left',
10: 'lung_upper_lobe_left', 11: 'lung_lower_lobe_left', 12: 'lung_upper_lobe_right',
13: 'lung_middle_lobe_right', 14: 'lung_lower_lobe_right', 15: 'esophagus', 16: 'trachea',
17: 'thyroid_gland', 18: 'small_bowel', 19: 'duodenum', 20: 'colon', 21: 'urinary_bladder',
22: 'prostate', 23: 'kidney_cyst_left', 24: 'kidney_cyst_right', 25: 'sacrum',
26: 'vertebrae_S1', 27: 'vertebrae_L5', 28: 'vertebrae_L4', 29: 'vertebrae_L3',
30: 'vertebrae_L2', 31: 'vertebrae_L1', 32: 'vertebrae_T12', 33: 'vertebrae_T11',
34: 'vertebrae_T10', 35: 'vertebrae_T9', 36: 'vertebrae_T8', 37: 'vertebrae_T7',
38: 'vertebrae_T6', 39: 'vertebrae_T5', 40: 'vertebrae_T4', 41: 'vertebrae_T3',
42: 'vertebrae_T2', 43: 'vertebrae_T1', 44: 'vertebrae_C7', 45: 'vertebrae_C6',
46: 'vertebrae_C5', 47: 'vertebrae_C4', 48: 'vertebrae_C3', 49: 'vertebrae_C2',
50: 'vertebrae_C1', 51: 'heart', 52: 'aorta', 53: 'pulmonary_vein',
54: 'brachiocephalic_trunk', 55: 'subclavian_artery_right', 56: 'subclavian_artery_left',
57: 'common_carotid_artery_right', 58: 'common_carotid_artery_left',
59: 'brachiocephalic_vein_left', 60: 'brachiocephalic_vein_right', 61: 'atrial_appendage_left',
62: 'superior_vena_cava', 63: 'inferior_vena_cava', 64: 'portal_vein_and_splenic_vein',
65: 'iliac_artery_left', 66: 'iliac_artery_right', 67: 'iliac_vena_left', 68: 'iliac_vena_right',
69: 'humerus_left', 70: 'humerus_right', 71: 'scapula_left', 72: 'scapula_right',
73: 'clavicula_left', 74: 'clavicula_right', 75: 'femur_left', 76: 'femur_right',
77: 'hip_left', 78: 'hip_right', 79: 'spinal_cord', 80: 'gluteus_maximus_left',
81: 'gluteus_maximus_right', 82: 'gluteus_medius_left', 83: 'gluteus_medius_right',
84: 'gluteus_minimus_left', 85: 'gluteus_minimus_right', 86: 'autochthon_left',
87: 'autochthon_right', 88: 'iliopsoas_left', 89: 'iliopsoas_right', 90: 'brain', 91: 'skull',
92: 'rib_left_1', 93: 'rib_left_2', 94: 'rib_left_3', 95: 'rib_left_4', 96: 'rib_left_5',
97: 'rib_left_6', 98: 'rib_left_7', 99: 'rib_left_8', 100: 'rib_left_9', 101: 'rib_left_10',
102: 'rib_left_11', 103: 'rib_left_12', 104: 'rib_right_1', 105: 'rib_right_2',
106: 'rib_right_3', 107: 'rib_right_4', 108: 'rib_right_5', 109: 'rib_right_6',
110: 'rib_right_7', 111: 'rib_right_8', 112: 'rib_right_9', 113: 'rib_right_10',
114: 'rib_right_11', 115: 'rib_right_12', 116: 'sternum', 117: 'costal_cartilages',
};
/** Human-readable organ name for a label value: TotalSegmentator name (spaces) or "Nhãn <value>". */
export function organDisplayName(value: number): string {
const key = TOTALSEGMENTATOR_V2_LABELS[value];
return key ? key.replace(/_/g, ' ') : `Nhãn ${value}`;
}
@@ -0,0 +1,560 @@
/**
* Sidebar primitive — faithful reproduction of fe0's shadcn sidebar
* (fe0/src/components/ui/sidebar.tsx), trimmed for the @ump/shared kernel.
*
* Differences vs fe0 (kept intentionally bounded for this shell migration):
* - cn / Button / Input / Separator come from @ump/shared (not local @/lib, @/components/ui).
* - useIsMobile is inlined here (fe0 imports it from @/hooks/use-mobile).
* - No Radix Tooltip dependency: SidebarMenuButton's `tooltip` prop renders via the
* native `title` attribute (still shows the label on the collapsed icon rail).
* - No mobile Sheet / Skeleton sub-deps: the desktop fixed rail renders at all widths.
* Visual classNames + the --sidebar-* design tokens are preserved verbatim.
*/
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeft } from "lucide-react";
import { cn, Button, Input, Separator } from "@ump/shared";
const SIDEBAR_COOKIE_NAME = "sidebar:state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const MOBILE_BREAKPOINT = 768;
function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean>(false);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return isMobile;
}
type SidebarContext = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContext | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
const SidebarProvider = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContext>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<div
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
ref={ref}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
);
});
SidebarProvider.displayName = "SidebarProvider";
const Sidebar = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
const { state } = useSidebar();
if (collapsible === "none") {
return (
<div
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
ref={ref}
{...props}
>
{children}
</div>
);
}
return (
<div
ref={ref}
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
{/* This is what handles the sidebar gap on desktop */}
<div
className={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
)}
/>
<div
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
>
{children}
</div>
</div>
</div>
);
});
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<Button
ref={ref}
data-sidebar="trigger"
variant="ghost"
size="icon"
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
},
);
SidebarTrigger.displayName = "SidebarTrigger";
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
({ className, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
},
);
SidebarRail.displayName = "SidebarRail";
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
return (
<main
ref={ref}
className={cn(
"relative flex min-h-svh flex-1 flex-col bg-background",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
{...props}
/>
);
});
SidebarInset.displayName = "SidebarInset";
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
({ className, ...props }, ref) => {
return (
<Input
ref={ref}
data-sidebar="input"
className={cn(
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
className,
)}
{...props}
/>
);
},
);
SidebarInput.displayName = "SidebarInput";
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarHeader.displayName = "SidebarHeader";
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
});
SidebarFooter.displayName = "SidebarFooter";
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
);
},
);
SidebarSeparator.displayName = "SidebarSeparator";
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
});
SidebarContent.displayName = "SidebarContent";
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
});
SidebarGroup.displayName = "SidebarGroup";
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
({ className, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "div";
return (
<Comp
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
},
);
SidebarGroupLabel.displayName = "SidebarGroupLabel";
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
),
);
SidebarGroupContent.displayName = "SidebarGroupContent";
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
));
SidebarMenu.displayName = "SidebarMenu";
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
));
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
const SidebarMenuButton = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string;
} & VariantProps<typeof sidebarMenuButtonVariants>
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
title={tooltip}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
});
SidebarMenuButton.displayName = "SidebarMenuButton";
const SidebarMenuAction = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
ref={ref}
data-sidebar="menu-action"
className={cn(
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
className,
)}
{...props}
/>
);
});
SidebarMenuAction.displayName = "SidebarMenuAction";
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => (
<div
ref={ref}
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuBadge.displayName = "SidebarMenuBadge";
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
({ className, ...props }, ref) => (
<ul
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
),
);
SidebarMenuSub.displayName = "SidebarMenuSub";
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
<li ref={ref} {...props} />
));
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
const SidebarMenuSubButton = React.forwardRef<
HTMLAnchorElement,
React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return (
<Comp
ref={ref}
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
});
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};
@@ -0,0 +1,49 @@
// Data-import APPLICATION — ports (gateway interfaces). The use-cases depend on THESE interfaces,
// never on axios/apiClient; the infrastructure layer implements them. This inverts the dependency
// at the I/O boundary so the (illustrative, partly not-yet-built) backend is absorbed entirely in
// infrastructure. Source: data-import-spec.md §7.2 (API surface).
import type { DataType, ItemsListTask, SamplePathPreview } from '../domain';
// --- Direct Upload (Phase 1 — converged onto the existing content-addressed dataset upload) ---
export interface DirectUploadResult {
/** How many files the server accepted. */
uploadedCount: number;
/** How many were content-address dedupes (already present). */
dedupedCount: number;
/** Logical paths created/replaced on the dataset. */
paths: string[];
}
export interface DirectUploadGateway {
upload(input: {
datasetId: string;
files: File[];
/** Coarse 0..100 progress; the converged endpoint reports begin/end until it threads real % . */
onProgress?: (pct: number) => void;
}): Promise<DirectUploadResult>;
}
// --- Cloud Import (Phase 4 — implemented once be0 Phases 2-3 land the StorageMethod + endpoints) ---
export interface CloudImportResult {
createdTaskIds: string[];
/** Per-entry failures for a partial import (P2 / import-valid-skip-invalid). */
perEntryErrors: { entry: string; message: string }[];
}
export interface CloudImportGateway {
importItemsList(input: {
datasetId: string;
/** A verified StorageMethod id, or the literal 'public'. */
storageMethodId: string;
tasks: ItemsListTask[];
dataType: DataType;
}): Promise<CloudImportResult>;
}
export interface SamplePathGateway {
/** Resolve a bucket-relative sample path (§6 / V2). Rejects with a reason on bad-path/permission/CORS. */
verify(input: { storageMethodId: string; path: string }): Promise<SamplePathPreview>;
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { runDirectUpload } from './runDirectUpload';
import type { DirectUploadGateway } from './ports';
import type { DirectContext, DirectEvent, StagedFile } from '../domain';
const sf = (p: string): StagedFile => ({ name: p.slice(p.lastIndexOf('/') + 1), relativePath: p, size: 1 });
const ctx = (files: StagedFile[]): DirectContext => ({
dataType: 'dicom',
groupByStudy: false,
files,
progress: 0,
errors: [],
createdTaskIds: [],
});
const okGateway: DirectUploadGateway = {
async upload() {
return { uploadedCount: 0, dedupedCount: 0, paths: [] };
},
};
describe('runDirectUpload (application use-case)', () => {
it('dispatches only VALIDATE_FAIL when staging is invalid (no files)', async () => {
const events: DirectEvent[] = [];
await runDirectUpload({ gateway: okGateway, datasetId: 'd1', context: ctx([]), files: [], dispatch: (e) => events.push(e) });
expect(events.map((e) => e.type)).toEqual(['VALIDATE_FAIL']);
});
it('on success: VALIDATE_OK -> UPLOAD_PROGRESS -> UPLOAD_OK -> PROCESS_OK(paths)', async () => {
const events: DirectEvent[] = [];
const gateway: DirectUploadGateway = {
async upload({ onProgress }) {
onProgress?.(50);
return { uploadedCount: 2, dedupedCount: 1, paths: ['a.dcm', 'b.dcm'] };
},
};
await runDirectUpload({
gateway,
datasetId: 'd1',
context: ctx([sf('a.dcm'), sf('b.dcm')]),
files: [],
dispatch: (e) => events.push(e),
});
expect(events.map((e) => e.type)).toEqual(['VALIDATE_OK', 'UPLOAD_PROGRESS', 'UPLOAD_OK', 'PROCESS_OK']);
const done = events.find((e) => e.type === 'PROCESS_OK');
expect(done?.type === 'PROCESS_OK' && done.taskIds).toEqual(['a.dcm', 'b.dcm']);
});
it('on gateway throw: VALIDATE_OK -> UPLOAD_FAIL with a GATEWAY_ERROR', async () => {
const events: DirectEvent[] = [];
const gateway: DirectUploadGateway = {
async upload() {
throw new Error('boom');
},
};
await runDirectUpload({ gateway, datasetId: 'd1', context: ctx([sf('a.dcm')]), files: [], dispatch: (e) => events.push(e) });
expect(events.map((e) => e.type)).toEqual(['VALIDATE_OK', 'UPLOAD_FAIL']);
const fail = events.find((e) => e.type === 'UPLOAD_FAIL');
expect(fail?.type === 'UPLOAD_FAIL' && fail.errors?.[0].code).toBe('GATEWAY_ERROR');
});
});
@@ -0,0 +1,46 @@
// Data-import APPLICATION — the Direct Upload use-case. Framework-agnostic: it depends only on the
// domain + the DirectUploadGateway port (no React, no axios). The presentation hook injects the
// gateway + the machine's `dispatch`, and this orchestrates: validate (pure) -> upload -> fold the
// result back into the machine via events.
//
// Converged-upload note: the existing content-addressed endpoint stores AND processes in one call,
// so on success UPLOAD_OK and PROCESS_OK are dispatched together (the §3 Uploading/Processing split
// is preserved in the machine for fidelity, but collapses against this backend).
import { importError, validateDirectStaging, type DirectContext, type DirectEvent } from '../domain';
import type { DirectUploadGateway } from './ports';
export interface RunDirectUploadDeps {
gateway: DirectUploadGateway;
datasetId: string;
/** The machine context at START_UPLOAD (dataType, groupByStudy, files). */
context: DirectContext;
/** The actual browser Files to stream (kept alongside the domain StagedFiles). */
files: File[];
dispatch: (event: DirectEvent) => void;
}
export async function runDirectUpload(deps: RunDirectUploadDeps): Promise<void> {
const { gateway, datasetId, context, files, dispatch } = deps;
// Pure validation (D3/D4/D6). On failure, return to Staging with located errors.
const errors = validateDirectStaging(context);
if (errors.length > 0) {
dispatch({ type: 'VALIDATE_FAIL', errors });
return;
}
dispatch({ type: 'VALIDATE_OK' });
try {
const result = await gateway.upload({
datasetId,
files,
onProgress: (pct) => dispatch({ type: 'UPLOAD_PROGRESS', progress: pct }),
});
dispatch({ type: 'UPLOAD_OK' });
dispatch({ type: 'PROCESS_OK', taskIds: result.paths });
} catch (err) {
const message = err instanceof Error ? err.message : 'Tải lên thất bại.';
dispatch({ type: 'UPLOAD_FAIL', errors: [importError('GATEWAY_ERROR', message)] });
}
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { isBucketRelativePath, validateBucketRelativePath } from './bucketPath';
describe('isBucketRelativePath (C2 / IL6 / V1)', () => {
it('accepts bucket-relative paths', () => {
expect(isBucketRelativePath('redbrick-bucket/project-2/brain-mri.dcm')).toBe(true);
expect(isBucketRelativePath('my-bucket/file.dcm')).toBe(true);
});
it('rejects full URLs', () => {
expect(isBucketRelativePath('https://s3.amazonaws.com/bucket/x.dcm')).toBe(false);
expect(isBucketRelativePath('http://example.com/x')).toBe(false);
expect(isBucketRelativePath('s3://bucket/key')).toBe(false);
expect(isBucketRelativePath('gs://bucket/key')).toBe(false);
});
it('rejects absolute paths and traversal', () => {
expect(isBucketRelativePath('/abs/path.dcm')).toBe(false);
expect(isBucketRelativePath('bucket/../secret.dcm')).toBe(false);
});
it('rejects empty, bucket-only, and trailing-slash paths', () => {
expect(isBucketRelativePath('')).toBe(false);
expect(isBucketRelativePath(' ')).toBe(false);
expect(isBucketRelativePath('just-a-bucket')).toBe(false);
expect(isBucketRelativePath('bucket/')).toBe(false);
});
});
describe('validateBucketRelativePath', () => {
it('returns null for a valid path', () => {
expect(validateBucketRelativePath('bucket/a.dcm')).toBeNull();
});
it('returns a located PATH_NOT_BUCKET_RELATIVE error for a URL', () => {
const err = validateBucketRelativePath('https://x/y.dcm', { entry: 'task-1' });
expect(err?.code).toBe('PATH_NOT_BUCKET_RELATIVE');
expect(err?.entry).toBe('task-1');
});
});
@@ -0,0 +1,53 @@
// Data-import DOMAIN — the bucket-relative path validator. Pure. This single function enforces
// the SAME rule that appears under three IDs across the specs:
// • C2 (data-import-spec.md:119) — Cloud item paths start at the bucket, not a full URL.
// • IL6 (items-list-spec.md) — Items List item paths are storage-relative.
// • V1 (data-import-spec.md:168) — the sample-path field rejects URLs with guidance.
// Example valid: `redbrick-bucket/project-2/brain-mri.dcm`
// Example invalid: `https://s3.amazonaws.com/redbrick-bucket/...` (a full URL)
import { importError, type ImportError } from './types';
/**
* A bucket-relative path: non-empty, not a URL, not absolute, no `..` traversal. The first
* segment is the bucket name (`bucket/key…`). Returns true if `s` is acceptable.
*/
export function isBucketRelativePath(s: string): boolean {
return bucketPathProblem(s) === null;
}
/**
* Validate a single path, returning a located ImportError (code PATH_NOT_BUCKET_RELATIVE) or null.
* `loc` lets the caller attach the offending Items List entry / file for per-row reporting (P2).
*/
export function validateBucketRelativePath(
s: string,
loc?: { entry?: string; file?: string },
): ImportError | null {
const problem = bucketPathProblem(s);
if (!problem) return null;
return importError('PATH_NOT_BUCKET_RELATIVE', problem, loc ?? { file: s });
}
/** Returns a human (Vietnamese) reason the path is not bucket-relative, or null if it's fine. */
function bucketPathProblem(s: string): string | null {
const path = s?.trim() ?? '';
if (path === '') return 'Đường dẫn trống.';
// A scheme (http://, https://, s3://, gs://, …) means a full URL — rejected.
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(path) || path.includes('://')) {
return 'Phải là đường dẫn tương đối theo bucket (vd: my-bucket/folder/file.dcm), không phải URL đầy đủ.';
}
// Absolute paths and backslashes are not bucket-relative.
if (path.startsWith('/') || path.startsWith('\\')) {
return 'Đường dẫn không được bắt đầu bằng "/" — phải bắt đầu từ tên bucket.';
}
// No parent-directory traversal.
if (path.split('/').some((seg) => seg === '..')) {
return 'Đường dẫn không được chứa ".." (đi ngược thư mục).';
}
// Must have a bucket + at least one key segment.
if (!path.includes('/') || path.endsWith('/')) {
return 'Đường dẫn phải gồm tên bucket và khoá đối tượng (vd: my-bucket/file.dcm).';
}
return null;
}
@@ -0,0 +1,55 @@
import { describe, it, expect } from 'vitest';
import { cloudReducer } from './reducer';
import { initialCloudMachine, type CloudEvent, type CloudMachine } from './states';
const run = (events: CloudEvent[]): CloudMachine => events.reduce(cloudReducer, initialCloudMachine);
describe('cloudReducer §4', () => {
it('happy path: StorageNotReady -> Complete', () => {
const m = run([
{ type: 'STORAGE_VERIFIED', storageMethodId: 'sm-1' },
{ type: 'PREPARE_LIST' },
{ type: 'SET_ITEMS_LIST', json: '[]' },
{ type: 'IMPORT_DATA' },
{ type: 'VALIDATE_OK', tasks: [{ name: 't', items: ['b/a.dcm'] }] },
{ type: 'TASKS_CREATED', taskIds: ['t1'] },
]);
expect(m.state).toBe('Complete');
expect(m.context.storageMethodId).toBe('sm-1');
expect(m.context.createdTaskIds).toEqual(['t1']);
});
it('C1: cannot prepare a list before storage is ready', () => {
expect(cloudReducer(initialCloudMachine, { type: 'PREPARE_LIST' }).state).toBe('StorageNotReady');
});
it("the literal 'public' source is a valid verified storage", () => {
expect(cloudReducer(initialCloudMachine, { type: 'STORAGE_VERIFIED', storageMethodId: 'public' }).state).toBe(
'StorageReady',
);
});
it('VALIDATE_FAIL returns to PreparingItemsList with errors', () => {
const m = run([
{ type: 'STORAGE_VERIFIED', storageMethodId: 'sm' },
{ type: 'PREPARE_LIST' },
{ type: 'IMPORT_DATA' },
{ type: 'VALIDATE_FAIL', errors: [{ code: 'PATH_NOT_BUCKET_RELATIVE', message: 'x' }] },
]);
expect(m.state).toBe('PreparingItemsList');
expect(m.context.errors).toHaveLength(1);
});
it('IMPORT_FAIL -> ImportError -> RETRY -> CreatingTasks', () => {
let m = run([
{ type: 'STORAGE_VERIFIED', storageMethodId: 'sm' },
{ type: 'PREPARE_LIST' },
{ type: 'IMPORT_DATA' },
{ type: 'VALIDATE_OK', tasks: [] },
{ type: 'IMPORT_FAIL' },
]);
expect(m.state).toBe('ImportError');
m = cloudReducer(m, { type: 'RETRY' });
expect(m.state).toBe('CreatingTasks');
});
});
@@ -0,0 +1,56 @@
// Data-import DOMAIN — Cloud Import reducer (pure transition function). Source: §4:125-133.
// Validation (parseItemsList — C2/C5) and task creation (C3/C4) are performed by the application
// use-case, which dispatches VALIDATE_OK/FAIL and TASKS_CREATED/IMPORT_FAIL back in.
import type { CloudEvent, CloudMachine } from './states';
export function cloudReducer(m: CloudMachine, e: CloudEvent): CloudMachine {
const { state, context } = m;
switch (e.type) {
case 'STORAGE_VERIFIED':
// C1 — a verified storage method (or 'public') makes storage ready.
if (state !== 'StorageNotReady' && state !== 'StorageReady') return m;
return { state: 'StorageReady', context: { ...context, storageMethodId: e.storageMethodId } };
case 'RESET_STORAGE':
return { state: 'StorageNotReady', context: { ...context, storageMethodId: null } };
case 'PREPARE_LIST':
if (state !== 'StorageReady' && state !== 'PreparingItemsList') return m;
return { state: 'PreparingItemsList', context };
case 'SET_ITEMS_LIST':
if (state !== 'PreparingItemsList') return m;
return { ...m, context: { ...context, itemsListJson: e.json, errors: [] } };
case 'IMPORT_DATA':
if (state !== 'PreparingItemsList') return m;
return { state: 'ValidatingList', context: { ...context, errors: [] } };
case 'VALIDATE_OK':
if (state !== 'ValidatingList') return m;
return { state: 'CreatingTasks', context: { ...context, tasks: e.tasks, errors: [] } };
case 'VALIDATE_FAIL':
// C2/C5 failure → back to editing with per-entry errors.
if (state !== 'ValidatingList') return m;
return { state: 'PreparingItemsList', context: { ...context, errors: e.errors } };
case 'TASKS_CREATED':
// C3/C4 — referencing tasks created, no data copied.
if (state !== 'CreatingTasks') return m;
return { state: 'Complete', context: { ...context, createdTaskIds: e.taskIds, errors: [] } };
case 'IMPORT_FAIL':
if (state !== 'CreatingTasks') return m;
return { state: 'ImportError', context: { ...context, errors: e.errors ?? context.errors } };
case 'RETRY':
if (state === 'ImportError') return { ...m, state: 'CreatingTasks' };
return m;
default:
return m;
}
}
@@ -0,0 +1,60 @@
// Data-import DOMAIN — Cloud Import state machine: States, Events, Context. Pure.
// Source: docs/workflows/data-import-spec.md §4 (§4:125-133 transitions + guards C1-C6) and
// items-list-spec.md (the Items List is authoritative for task boundaries — MAP4/C3).
//
// [DESIGN] note: §4's transient `ImportingList` state (uploading the JSON) is folded into the
// IMPORT_DATA -> ValidatingList edge — for a client-authored Items List the "upload" is
// instantaneous, so a distinct ImportingList state would never be observably entered.
import type { ImportError } from '../types';
import type { ItemsListTask } from '../itemsList/parse';
export type CloudState =
| 'StorageNotReady'
| 'StorageReady'
| 'PreparingItemsList'
| 'ValidatingList'
| 'CreatingTasks'
| 'Complete'
| 'ImportError';
export interface CloudContext {
/** A *verified* StorageMethod id (C1), or the literal 'public'. Null until storage is chosen. */
storageMethodId: string | null;
/** The authored/uploaded Items List JSON text. */
itemsListJson: string;
/** Parsed entries (one per Task) once validation passes. */
tasks: ItemsListTask[];
errors: ImportError[];
createdTaskIds: string[];
}
export type CloudEvent =
| { type: 'STORAGE_VERIFIED'; storageMethodId: string } // includes 'public'
| { type: 'RESET_STORAGE' }
| { type: 'PREPARE_LIST' }
| { type: 'SET_ITEMS_LIST'; json: string }
| { type: 'IMPORT_DATA' }
| { type: 'VALIDATE_OK'; tasks: ItemsListTask[] }
| { type: 'VALIDATE_FAIL'; errors: ImportError[] }
| { type: 'TASKS_CREATED'; taskIds: string[] }
| { type: 'IMPORT_FAIL'; errors?: ImportError[] }
| { type: 'RETRY' };
export interface CloudMachine {
state: CloudState;
context: CloudContext;
}
export const initialCloudContext: CloudContext = {
storageMethodId: null,
itemsListJson: '',
tasks: [],
errors: [],
createdTaskIds: [],
};
export const initialCloudMachine: CloudMachine = {
state: 'StorageNotReady',
context: initialCloudContext,
};
@@ -0,0 +1,38 @@
// Data-import DOMAIN — the image data types a user imports, and which pipeline carries them.
// Pure: no React, no axios, no @ump/shared. See docs/workflows/data-import-spec.md §3 (the
// Direct Upload type picker), §5 (the per-type mapping table), and items-list-spec.md §2.3
// (the per-type Items List shape). Rule IDs (D*/C*/MAP*) below trace back to that spec.
/** The two ingestion pipelines. Direct = files hosted on our servers; Cloud = referenced. */
export type Pipeline = 'direct' | 'cloud';
/**
* The single image data type chosen for one import job (D1 — exactly one type per upload).
* These are the §3 "SelectingType" options. NRRD/MHA/MHD are supported *formats* (§2.1) but
* not distinct picker types in v1 — see formats.ts and the open questions in the spec (§9.1).
*/
export type DataType = 'dicom' | 'nifti' | 'image2d' | 'video' | 'videoFrames';
export const DATA_TYPES: readonly DataType[] = ['dicom', 'nifti', 'image2d', 'video', 'videoFrames'];
/** Vietnamese picker labels (domain UI is Vietnamese — preserve diacritics). */
export const DATA_TYPE_LABELS_VI: Record<DataType, string> = {
dicom: 'DICOM (chuỗi ảnh)',
nifti: 'NIfTI (thể tích)',
image2d: 'Ảnh 2D',
video: 'Video',
videoFrames: 'Khung hình video',
};
/**
* D2 / MAP3 — "Group by Study" is offered ONLY for DICOM & NIfTI volume data. Offering it for
* 2D images or video is invalid; the reducer/guards must refuse to set groupByStudy otherwise.
*/
export function supportsGroupByStudy(dataType: DataType): boolean {
return dataType === 'dicom' || dataType === 'nifti';
}
/** Whether a data type is a 3D volume (DICOM series or NIfTI) vs a flat 2D/video asset. */
export function isVolumeType(dataType: DataType): boolean {
return dataType === 'dicom' || dataType === 'nifti';
}
@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { validateDirectStaging, canToggleGroupByStudy } from './guards';
import { initialDirectContext, type DirectContext } from './states';
import type { StagedFile } from '../types';
const sf = (p: string): StagedFile => ({ name: p.slice(p.lastIndexOf('/') + 1), relativePath: p, size: 1 });
const ctx = (over: Partial<DirectContext>): DirectContext => ({ ...initialDirectContext, ...over });
describe('validateDirectStaging', () => {
it('errors when no type is chosen', () => {
expect(validateDirectStaging(ctx({})).length).toBeGreaterThan(0);
});
it('errors when there are no files', () => {
expect(validateDirectStaging(ctx({ dataType: 'dicom' })).some((e) => e.code === 'INVALID_STRUCTURE')).toBe(true);
});
it('D3: rejects a file whose format is wrong for the chosen type', () => {
const errs = validateDirectStaging(ctx({ dataType: 'dicom', files: [sf('clip.mp4')] }));
expect(errs.some((e) => e.code === 'UNSUPPORTED_FORMAT')).toBe(true);
});
it('D6: rejects an .mhd without its raw companion', () => {
const errs = validateDirectStaging(ctx({ dataType: 'nifti', files: [sf('vol.mhd')] }));
expect(errs.some((e) => e.code === 'MHD_COMPANION_MISSING')).toBe(true);
});
it('passes a clean DICOM series', () => {
expect(validateDirectStaging(ctx({ dataType: 'dicom', files: [sf('s1/i1.dcm'), sf('s1/i2.dcm')] }))).toEqual([]);
});
});
describe('canToggleGroupByStudy (D2)', () => {
it('is true only for DICOM/NIfTI', () => {
expect(canToggleGroupByStudy(ctx({ dataType: 'dicom' }))).toBe(true);
expect(canToggleGroupByStudy(ctx({ dataType: 'nifti' }))).toBe(true);
expect(canToggleGroupByStudy(ctx({ dataType: 'image2d' }))).toBe(false);
expect(canToggleGroupByStudy(ctx({ dataType: null }))).toBe(false);
});
});
@@ -0,0 +1,56 @@
// Data-import DOMAIN — Direct Upload guards/validators (pure). Source: data-import-spec.md §3.
// These are the testable predicates the reducer + the application use-case consult.
import { isAcceptedForType } from '../formats';
import { supportsGroupByStudy, type DataType } from '../dataTypes';
import { mapFilesToTasks } from '../mapping/fileToTask';
import { validateMhdCompanions } from '../mapping/mhdCompanions';
import { importError, type ImportError } from '../types';
import type { DirectContext } from './states';
/** D2 / MAP3 — Group by Study may be toggled only when a DICOM/NIfTI type is selected. */
export function canToggleGroupByStudy(ctx: DirectContext): boolean {
return ctx.dataType !== null && supportsGroupByStudy(ctx.dataType);
}
/**
* Validate staged files before upload (the §3 Validating step). Returns the full list of blocking
* errors; an empty array means VALIDATE_OK. Enforces:
* • D4 (structure) — at least one file, and the §5 mapping yields ≥1 Task.
* • D3 (format) — every file matches the chosen type's accepted set.
* • D6 (companions)— every .mhd has its raw companion.
*/
export function validateDirectStaging(ctx: DirectContext): ImportError[] {
const errors: ImportError[] = [];
const dataType = ctx.dataType;
if (dataType === null) {
errors.push(importError('INVALID_STRUCTURE', 'Chưa chọn loại dữ liệu.'));
return errors;
}
if (ctx.files.length === 0) {
errors.push(importError('INVALID_STRUCTURE', 'Chưa có tệp nào được chọn.'));
return errors;
}
// D3 — format per chosen type.
for (const f of ctx.files) {
if (!isAcceptedForType(dataType, f.name)) {
errors.push(
importError('UNSUPPORTED_FORMAT', `Tệp "${f.name}" không đúng định dạng cho loại ${dataType}.`, {
file: f.relativePath,
}),
);
}
}
// D6 — MHD companions.
errors.push(...validateMhdCompanions(ctx.files));
// D4 — structural sanity: the mapping must produce at least one Task.
if (mapFilesToTasks(dataType as DataType, ctx.groupByStudy, ctx.files).length === 0) {
errors.push(importError('INVALID_STRUCTURE', 'Cấu trúc tệp không tạo được tác vụ nào.'));
}
return errors;
}
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { directReducer } from './reducer';
import { initialDirectMachine, type DirectEvent, type DirectMachine } from './states';
import type { StagedFile } from '../types';
const sf = (p: string): StagedFile => ({ name: p.slice(p.lastIndexOf('/') + 1), relativePath: p, size: 1 });
const run = (events: DirectEvent[]): DirectMachine => events.reduce(directReducer, initialDirectMachine);
describe('directReducer §3', () => {
it('happy path: SelectingType -> Complete', () => {
const m = run([
{ type: 'SELECT_TYPE', dataType: 'dicom' },
{ type: 'ADD_FILES', files: [sf('s1/i1.dcm')] },
{ type: 'START_UPLOAD' },
{ type: 'VALIDATE_OK' },
{ type: 'UPLOAD_OK' },
{ type: 'PROCESS_OK', taskIds: ['t1'] },
]);
expect(m.state).toBe('Complete');
expect(m.context.createdTaskIds).toEqual(['t1']);
});
it('D1: changing the data type clears staged files', () => {
let m = run([
{ type: 'SELECT_TYPE', dataType: 'dicom' },
{ type: 'ADD_FILES', files: [sf('i1.dcm')] },
]);
expect(m.context.files).toHaveLength(1);
m = directReducer(m, { type: 'SELECT_TYPE', dataType: 'nifti' });
expect(m.context.dataType).toBe('nifti');
expect(m.context.files).toEqual([]);
});
it('D2: TOGGLE_GROUP_BY_STUDY is a no-op for 2D images, allowed for NIfTI', () => {
const img = directReducer(
directReducer(initialDirectMachine, { type: 'SELECT_TYPE', dataType: 'image2d' }),
{ type: 'TOGGLE_GROUP_BY_STUDY' },
);
expect(img.context.groupByStudy).toBe(false);
const nii = directReducer(
directReducer(initialDirectMachine, { type: 'SELECT_TYPE', dataType: 'nifti' }),
{ type: 'TOGGLE_GROUP_BY_STUDY' },
);
expect(nii.context.groupByStudy).toBe(true);
});
it('VALIDATE_FAIL returns to Staging carrying errors', () => {
const m = run([
{ type: 'SELECT_TYPE', dataType: 'dicom' },
{ type: 'ADD_FILES', files: [sf('i1.dcm')] },
{ type: 'START_UPLOAD' },
{ type: 'VALIDATE_FAIL', errors: [{ code: 'UNSUPPORTED_FORMAT', message: 'x' }] },
]);
expect(m.state).toBe('Staging');
expect(m.context.errors).toHaveLength(1);
});
it('UPLOAD_FAIL -> UploadError -> RETRY -> Uploading', () => {
let m = run([
{ type: 'SELECT_TYPE', dataType: 'dicom' },
{ type: 'ADD_FILES', files: [sf('i1.dcm')] },
{ type: 'START_UPLOAD' },
{ type: 'VALIDATE_OK' },
{ type: 'UPLOAD_FAIL' },
]);
expect(m.state).toBe('UploadError');
m = directReducer(m, { type: 'RETRY' });
expect(m.state).toBe('Uploading');
});
it('ignores illegal events (PROCESS_OK while Staging) as a no-op', () => {
const staged = run([
{ type: 'SELECT_TYPE', dataType: 'dicom' },
{ type: 'ADD_FILES', files: [sf('i1.dcm')] },
]);
const after = directReducer(staged, { type: 'PROCESS_OK', taskIds: ['x'] });
expect(after).toBe(staged); // unchanged reference
});
});
@@ -0,0 +1,87 @@
// Data-import DOMAIN — Direct Upload reducer (pure transition function). Source: §3:83-93.
// Illegal (state, event) pairs are no-ops (return the machine unchanged) — safe for a UI-driven
// machine. All side effects (validate, upload, process) are performed by the application use-case,
// which feeds VALIDATE_OK/FAIL, UPLOAD_PROGRESS/OK/FAIL, PROCESS_OK/FAIL back in.
import { canToggleGroupByStudy } from './guards';
import type { DirectEvent, DirectMachine } from './states';
const PRE_UPLOAD: ReadonlySet<string> = new Set(['SelectingType', 'ConfiguringStructure', 'Staging']);
export function directReducer(m: DirectMachine, e: DirectEvent): DirectMachine {
const { state, context } = m;
switch (e.type) {
case 'SELECT_TYPE': {
if (!PRE_UPLOAD.has(state)) return m; // D1 — can't change type mid-upload
const changed = e.dataType !== context.dataType;
return {
state: 'ConfiguringStructure',
// Changing the type discards files/group-by-study staged for the old type.
context: changed
? { ...context, dataType: e.dataType, groupByStudy: false, files: [], errors: [] }
: { ...context, dataType: e.dataType },
};
}
case 'TOGGLE_GROUP_BY_STUDY': {
if (state !== 'ConfiguringStructure' && state !== 'Staging') return m;
if (!canToggleGroupByStudy(context)) return m; // D2 — no-op for 2D/video
return { ...m, context: { ...context, groupByStudy: !context.groupByStudy } };
}
case 'ADD_FILES': {
if (state !== 'ConfiguringStructure' && state !== 'Staging') return m;
return { state: 'Staging', context: { ...context, files: [...context.files, ...e.files], errors: [] } };
}
case 'CLEAR_FILES': {
if (state !== 'Staging' && state !== 'ConfiguringStructure') return m;
return { state: 'Staging', context: { ...context, files: [], errors: [] } };
}
case 'START_UPLOAD':
if (state !== 'Staging') return m;
return { ...m, state: 'Validating' };
case 'VALIDATE_OK':
if (state !== 'Validating') return m;
return { state: 'Uploading', context: { ...context, progress: 0, errors: [] } };
case 'VALIDATE_FAIL':
if (state !== 'Validating') return m;
return { state: 'Staging', context: { ...context, errors: e.errors } };
case 'UPLOAD_PROGRESS':
if (state !== 'Uploading') return m;
return { ...m, context: { ...context, progress: clamp(e.progress) } };
case 'UPLOAD_OK':
if (state !== 'Uploading') return m;
return { state: 'Processing', context: { ...context, progress: 100 } };
case 'UPLOAD_FAIL':
if (state !== 'Uploading') return m;
return { state: 'UploadError', context: { ...context, errors: e.errors ?? context.errors } };
case 'PROCESS_OK':
if (state !== 'Processing') return m;
return { state: 'Complete', context: { ...context, createdTaskIds: e.taskIds, errors: [] } };
case 'PROCESS_FAIL':
if (state !== 'Processing') return m;
return { state: 'PartialFailure', context: { ...context, errors: e.errors ?? context.errors } };
case 'RETRY':
if (state === 'UploadError') return { ...m, state: 'Uploading', context: { ...context, progress: 0 } };
if (state === 'PartialFailure') return { ...m, state: 'Processing' };
return m;
default:
return m;
}
}
function clamp(n: number): number {
return Math.max(0, Math.min(100, n));
}
@@ -0,0 +1,65 @@
// Data-import DOMAIN — Direct Upload state machine: States, Events, Context. Pure.
// Source: docs/workflows/data-import-spec.md §3 (the §3:83-93 transition table + guards D1-D6).
// The reducer (reducer.ts) is a pure transition function; side effects (upload, process) live in
// the application use-case, which dispatches the OK/FAIL/PROGRESS events back into the machine.
import type { DataType } from '../dataTypes';
import type { ImportError, StagedFile } from '../types';
export type DirectState =
| 'SelectingType'
| 'ConfiguringStructure'
| 'Staging'
| 'Validating'
| 'Uploading'
| 'Processing'
| 'Complete'
| 'UploadError'
| 'PartialFailure';
export interface DirectContext {
/** The one image data type for this upload (D1 — exactly one). */
dataType: DataType | null;
/** D2 — only ever true for DICOM/NIfTI. */
groupByStudy: boolean;
files: StagedFile[];
/** Upload progress 0..100. */
progress: number;
errors: ImportError[];
/** Task ids created server-side once Processing completes. */
createdTaskIds: string[];
}
export type DirectEvent =
| { type: 'SELECT_TYPE'; dataType: DataType }
| { type: 'TOGGLE_GROUP_BY_STUDY' }
| { type: 'ADD_FILES'; files: StagedFile[] }
| { type: 'CLEAR_FILES' }
| { type: 'START_UPLOAD' }
| { type: 'VALIDATE_OK' }
| { type: 'VALIDATE_FAIL'; errors: ImportError[] }
| { type: 'UPLOAD_PROGRESS'; progress: number }
| { type: 'UPLOAD_OK' }
| { type: 'UPLOAD_FAIL'; errors?: ImportError[] }
| { type: 'PROCESS_OK'; taskIds: string[] }
| { type: 'PROCESS_FAIL'; errors?: ImportError[] }
| { type: 'RETRY' };
export interface DirectMachine {
state: DirectState;
context: DirectContext;
}
export const initialDirectContext: DirectContext = {
dataType: null,
groupByStudy: false,
files: [],
progress: 0,
errors: [],
createdTaskIds: [],
};
export const initialDirectMachine: DirectMachine = {
state: 'SelectingType',
context: initialDirectContext,
};
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { extensionOf, isSupportedFormat, isAcceptedForType } from './formats';
describe('extensionOf', () => {
it('detects double extensions before the last dot', () => {
expect(extensionOf('brain.nii.gz')).toBe('.nii.gz');
expect(extensionOf('a/b/brain.NII.GZ')).toBe('.nii.gz');
});
it('lower-cases and handles single extensions', () => {
expect(extensionOf('IMG001.DCM')).toBe('.dcm');
expect(extensionOf('photo.PNG')).toBe('.png');
});
it('returns empty string when there is no extension', () => {
expect(extensionOf('README')).toBe('');
});
});
describe('isSupportedFormat (per-pipeline, conflict #1)', () => {
it('accepts MHA/MHD on Direct but not Cloud', () => {
expect(isSupportedFormat('direct', 'vol.mha')).toBe(true);
expect(isSupportedFormat('direct', 'vol.mhd')).toBe(true);
expect(isSupportedFormat('cloud', 'vol.mha')).toBe(false);
expect(isSupportedFormat('cloud', 'vol.mhd')).toBe(false);
});
it('accepts the common imaging formats on both pipelines', () => {
for (const p of ['direct', 'cloud'] as const) {
expect(isSupportedFormat(p, 'a.dcm')).toBe(true);
expect(isSupportedFormat(p, 'b.nii.gz')).toBe(true);
expect(isSupportedFormat(p, 'c.png')).toBe(true);
}
});
it('rejects unsupported formats', () => {
expect(isSupportedFormat('direct', 'notes.txt')).toBe(false);
expect(isSupportedFormat('cloud', 'archive.zip')).toBe(false);
});
});
describe('isAcceptedForType (D3 staging gate)', () => {
it('accepts only the chosen types formats', () => {
expect(isAcceptedForType('dicom', 'series.dcm')).toBe(true);
expect(isAcceptedForType('dicom', 'clip.mp4')).toBe(false);
expect(isAcceptedForType('nifti', 'brain.nii.gz')).toBe(true);
expect(isAcceptedForType('image2d', 'slide.png')).toBe(true);
expect(isAcceptedForType('video', 'clip.mp4')).toBe(true);
});
it('allows MHD/MHA + raw companions alongside volume types', () => {
expect(isAcceptedForType('nifti', 'vol.mhd')).toBe(true);
expect(isAcceptedForType('nifti', 'vol.raw')).toBe(true);
expect(isAcceptedForType('image2d', 'vol.mhd')).toBe(false);
});
});
@@ -0,0 +1,80 @@
// Data-import DOMAIN — supported formats + the accepted-format check (D3 direct / C5 cloud).
// Pure. Source: docs/workflows/data-import-spec.md §2.1. Cross-spec conflict #1: the Direct
// Upload page documents MHA/MHD; the Cloud page omits them — so the accepted set is PER-PIPELINE,
// not one global enum. We model that explicitly here.
import type { DataType, Pipeline } from './dataTypes';
/** Double extensions must be detected before the last-dot fallback (`a.nii.gz` -> `.nii.gz`). */
const DOUBLE_EXTENSIONS = ['.nii.gz'];
/** Lower-cased file extension, double-extension aware. `Brain.NII.GZ` -> `.nii.gz`, `x.DCM` -> `.dcm`. */
export function extensionOf(filename: string): string {
const lower = filename.toLowerCase();
for (const dbl of DOUBLE_EXTENSIONS) {
if (lower.endsWith(dbl)) return dbl;
}
const dot = lower.lastIndexOf('.');
return dot >= 0 ? lower.slice(dot) : '';
}
/** Per-data-type accepted extensions (the §2.1 sets, mapped onto the 5 picker types). */
export const ACCEPTED_EXTENSIONS_BY_TYPE: Record<DataType, readonly string[]> = {
dicom: ['.dcm', '.ima', '.dicom', '.dicm'],
nifti: ['.nii', '.nii.gz'],
image2d: ['.png', '.jpeg', '.jpg', '.bmp'],
video: ['.mp4', '.mov', '.avi'],
videoFrames: ['.png', '.jpeg', '.jpg', '.bmp'],
};
/**
* MHD companion extensions (D6). An `.mhd` header references a raw data file: `.raw`/`.img`
* (uncompressed) or `.zraw` (compressed). Validated by mapping/mhdCompanions.ts.
*/
export const MHD_COMPANION_EXTENSIONS = ['.raw', '.img', '.zraw'] as const;
/**
* The full §2.1 supported imaging set, PER PIPELINE. Direct Upload additionally documents the
* single-file volume formats NRRD/MHA/MHD; the Cloud page omits MHA/MHD (conflict #1, open Q9.1).
* Used as the generic "is this even an imaging file we accept on this pipeline" gate.
*/
const COMMON_IMAGE_EXTENSIONS = [
'.dcm', '.ima', '.dicom', '.dicm', // DICOM
'.nii', '.nii.gz', // NIfTI
'.png', '.jpeg', '.jpg', '.bmp', // RGB
'.mp4', '.mov', '.avi', // Video
'.nrrd', // NRRD
];
const DIRECT_ONLY_EXTENSIONS = ['.mha', '.mhd']; // companions (.raw/.img/.zraw) gate via D6, not here
export const SUPPORTED_EXTENSIONS_DIRECT: readonly string[] = [
...COMMON_IMAGE_EXTENSIONS,
...DIRECT_ONLY_EXTENSIONS,
];
export const SUPPORTED_EXTENSIONS_CLOUD: readonly string[] = COMMON_IMAGE_EXTENSIONS;
export function supportedExtensions(pipeline: Pipeline): readonly string[] {
return pipeline === 'direct' ? SUPPORTED_EXTENSIONS_DIRECT : SUPPORTED_EXTENSIONS_CLOUD;
}
/** D3/C5 — is `filename` an accepted format for this pipeline? (generic imaging gate) */
export function isSupportedFormat(pipeline: Pipeline, filename: string): boolean {
return supportedExtensions(pipeline).includes(extensionOf(filename));
}
/**
* Stricter check used during Direct Upload staging: does `filename` match the *chosen* data
* type's accepted set? (A `.mp4` staged under a DICOM job is wrong even though `.mp4` is supported.)
* `.mhd`/`.mha`/`.raw`/`.img`/`.zraw` are allowed alongside any volume type for D6 handling.
*/
export function isAcceptedForType(dataType: DataType, filename: string): boolean {
const ext = extensionOf(filename);
if (ACCEPTED_EXTENSIONS_BY_TYPE[dataType].includes(ext)) return true;
// MHD/MHA volume + their raw companions ride alongside the volume types (DICOM/NIfTI).
if (isVolumeCompanionExtension(ext) && (dataType === 'nifti' || dataType === 'dicom')) return true;
return false;
}
function isVolumeCompanionExtension(ext: string): boolean {
return ext === '.mhd' || ext === '.mha' || (MHD_COMPANION_EXTENSIONS as readonly string[]).includes(ext);
}
@@ -0,0 +1,23 @@
// Data-import DOMAIN — public surface. Pure, framework-free (no React, no axios, no @ump/shared).
// The application + presentation layers depend inward on this barrel; nothing here imports outward.
export * from './dataTypes';
export * from './types';
export * from './formats';
export * from './bucketPath';
export { mapFilesToTasks, type TaskPlan, type TaskGrouping } from './mapping/fileToTask';
export { validateMhdCompanions } from './mapping/mhdCompanions';
export { parseItemsList, type Series, type ItemsListTask, type ParsedItemsList } from './itemsList/parse';
// Direct Upload machine (§3)
export * from './directUpload/states';
export { canToggleGroupByStudy, validateDirectStaging } from './directUpload/guards';
export { directReducer } from './directUpload/reducer';
// Cloud Import machine (§4)
export * from './cloudImport/states';
export { cloudReducer } from './cloudImport/reducer';
// Sample-Path machine (§6)
export * from './samplePath/states';
export { samplePathReducer } from './samplePath/reducer';
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest';
import { parseItemsList } from './parse';
describe('parseItemsList', () => {
it('P1: malformed JSON -> single ITEMS_LIST_MALFORMED, no tasks', () => {
const r = parseItemsList('{not json', 'dicom');
expect(r.ok).toBe(false);
expect(r.tasks).toEqual([]);
expect(r.errors).toHaveLength(1);
expect(r.errors[0].code).toBe('ITEMS_LIST_MALFORMED');
});
it('rejects a non-array top level', () => {
const r = parseItemsList('{"name":"x"}', 'dicom');
expect(r.errors[0].code).toBe('ITEMS_LIST_NOT_ARRAY');
});
it('accepts a valid standard DICOM list (entry = task)', () => {
const json = JSON.stringify([
{ name: 'study-1', series: [{ items: ['bucket/s1/i1.dcm', 'bucket/s1/i2.dcm'] }] },
]);
const r = parseItemsList(json, 'dicom');
expect(r.ok).toBe(true);
expect(r.tasks).toHaveLength(1);
expect(r.tasks[0].name).toBe('study-1');
});
it('IL5: flags a missing name and a duplicate name', () => {
const json = JSON.stringify([
{ series: [{ items: 'bucket/a.nii' }] },
{ name: 'dup', series: [{ items: 'bucket/b.nii' }] },
{ name: 'dup', series: [{ items: 'bucket/c.nii' }] },
]);
const r = parseItemsList(json, 'nifti');
expect(r.errors.map((e) => e.code)).toContain('ENTRY_NAME_MISSING');
expect(r.errors.map((e) => e.code)).toContain('ENTRY_NAME_DUPLICATE');
});
it('IL3: a NIfTI series with an array items is rejected', () => {
const json = JSON.stringify([{ name: 't', series: [{ items: ['bucket/a.nii', 'bucket/b.nii'] }] }]);
const r = parseItemsList(json, 'nifti');
expect(r.ok).toBe(false);
expect(r.errors.map((e) => e.code)).toContain('ENTRY_ITEMS_SHAPE');
});
it('C2: a full URL in items is rejected as not bucket-relative', () => {
const json = JSON.stringify([{ name: 't', series: [{ items: ['https://s3/bucket/a.dcm'] }] }]);
const r = parseItemsList(json, 'dicom');
expect(r.ok).toBe(false);
expect(r.errors.map((e) => e.code)).toContain('PATH_NOT_BUCKET_RELATIVE');
});
it('accepts the simplified flat-items shape', () => {
const json = JSON.stringify([{ name: 't', items: ['bucket/a.dcm', 'bucket/b.dcm'] }]);
const r = parseItemsList(json, 'dicom');
expect(r.ok).toBe(true);
expect(r.tasks[0].items).toHaveLength(2);
});
it('flags an entry with neither series nor items as empty', () => {
const json = JSON.stringify([{ name: 't' }]);
const r = parseItemsList(json, 'dicom');
expect(r.errors.map((e) => e.code)).toContain('ENTRY_EMPTY');
});
});
@@ -0,0 +1,131 @@
// Data-import DOMAIN — Items List parser/validator for Cloud Import. Pure. The Items List JSON
// is AUTHORITATIVE for cloud task boundaries (MAP4/C3): each top-level entry -> exactly one Task.
// Source: items-list-spec.md §2.1 (standard schema) + §2.2 (simplified auto-split) + the rules
// IL1-IL6, P1-P2; data-import-spec.md C2/C3/C5.
//
// Two per-task shapes are accepted (detected per task, IL2):
// • standard: { name, series: [{ items: string | string[], name? }, …] }
// • simplified: { name, items: string[] } (legacy flat list, split later client-side)
import type { DataType } from '../dataTypes';
import { validateBucketRelativePath } from '../bucketPath';
import { importError, type ImportError } from '../types';
export interface Series {
items: string | string[];
name?: string;
}
/** One Items List entry === one Task (C3/IL1). Carries either `series` (standard) or `items` (simplified). */
export interface ItemsListTask {
name: string;
series?: Series[];
items?: string[];
}
export interface ParsedItemsList {
/** Structurally-parsed tasks (only those that had a usable name). */
tasks: ItemsListTask[];
/** Per-entry + structural errors, located for per-row reporting (P2). */
errors: ImportError[];
/** True iff JSON parsed, was an array, and every entry validated cleanly. */
ok: boolean;
}
/**
* Parse + validate an Items List JSON string for the given target data type.
* P1 fail-fast: a JSON syntax error yields a single ITEMS_LIST_MALFORMED error and no tasks.
*/
export function parseItemsList(json: string, dataType: DataType): ParsedItemsList {
let raw: unknown;
try {
raw = JSON.parse(json);
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
return { tasks: [], errors: [importError('ITEMS_LIST_MALFORMED', `JSON không hợp lệ: ${detail}`)], ok: false };
}
if (!Array.isArray(raw)) {
return {
tasks: [],
errors: [importError('ITEMS_LIST_NOT_ARRAY', 'Items List phải là một mảng các tác vụ (task).')],
ok: false,
};
}
const errors: ImportError[] = [];
const tasks: ItemsListTask[] = [];
const seenNames = new Set<string>();
raw.forEach((entry, index) => {
const obj = (entry ?? {}) as Record<string, unknown>;
const name = typeof obj.name === 'string' ? obj.name.trim() : '';
// IL5 — every task needs a unique, non-empty name.
if (name === '') {
errors.push(importError('ENTRY_NAME_MISSING', `Tác vụ #${index + 1} thiếu "name".`));
return; // can't locate further errors without a name
}
if (seenNames.has(name)) {
errors.push(importError('ENTRY_NAME_DUPLICATE', `Tên tác vụ "${name}" bị trùng.`, { entry: name }));
return;
}
seenNames.add(name);
const hasSeries = Array.isArray(obj.series);
const hasItems = Array.isArray(obj.items);
if (!hasSeries && !hasItems) {
errors.push(importError('ENTRY_EMPTY', `Tác vụ "${name}" phải có "series" hoặc "items".`, { entry: name }));
return;
}
const task: ItemsListTask = { name };
if (hasSeries) {
task.series = obj.series as Series[];
validateStandard(name, task.series, dataType, errors);
} else {
task.items = (obj.items as unknown[]).filter((x): x is string => typeof x === 'string');
validatePaths(name, task.items, errors);
if (task.items.length === 0) {
errors.push(importError('ENTRY_EMPTY', `Tác vụ "${name}" có "items" rỗng.`, { entry: name }));
}
}
tasks.push(task);
});
return { tasks, errors, ok: errors.length === 0 };
}
/** Validate a standard-form entry's series + their item paths, applying the per-type item shape. */
function validateStandard(name: string, series: Series[], dataType: DataType, errors: ImportError[]): void {
if (series.length === 0) {
errors.push(importError('ENTRY_EMPTY', `Tác vụ "${name}" có "series" rỗng.`, { entry: name }));
return;
}
for (const s of series) {
const items = s?.items;
// IL3 — a NIfTI series' items MUST be a single string; an array is invalid.
if (dataType === 'nifti' && Array.isArray(items)) {
errors.push(
importError('ENTRY_ITEMS_SHAPE', `Tác vụ "${name}": NIfTI yêu cầu "items" là một chuỗi, không phải mảng.`, {
entry: name,
}),
);
continue;
}
const paths = typeof items === 'string' ? [items] : Array.isArray(items) ? items.filter((x): x is string => typeof x === 'string') : [];
if (paths.length === 0) {
errors.push(importError('ENTRY_EMPTY', `Tác vụ "${name}": một "series" không có item nào.`, { entry: name }));
}
validatePaths(name, paths, errors);
}
}
/** C2/IL6 — every referenced path must be bucket-relative (not a URL/absolute). */
function validatePaths(entry: string, paths: readonly string[], errors: ImportError[]): void {
for (const p of paths) {
const err = validateBucketRelativePath(p, { entry, file: p });
if (err) errors.push(err);
}
}
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { mapFilesToTasks } from './fileToTask';
import type { StagedFile } from '../types';
/** Build a StagedFile from a relative path (name = last segment). */
function sf(relativePath: string, size = 1): StagedFile {
return { name: relativePath.slice(relativePath.lastIndexOf('/') + 1), relativePath, size };
}
describe('mapFilesToTasks §5', () => {
it('returns no tasks for no files', () => {
expect(mapFilesToTasks('dicom', false, [])).toEqual([]);
});
it('2D image: each image -> one task', () => {
const tasks = mapFilesToTasks('image2d', false, [sf('a.png'), sf('b.png')]);
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.name)).toEqual(['a', 'b']);
expect(tasks.every((t) => t.grouping === 'image')).toBe(true);
});
it('video: each video -> one task', () => {
const tasks = mapFilesToTasks('video', false, [sf('clip1.mp4'), sf('clip2.mov')]);
expect(tasks).toHaveLength(2);
expect(tasks.every((t) => t.grouping === 'video')).toBe(true);
});
it('video frames: a folder -> one task, frames ordered by filename (MAP2)', () => {
const tasks = mapFilesToTasks('videoFrames', false, [
sf('clipA/frame10.png'),
sf('clipA/frame2.png'),
sf('clipA/frame1.png'),
]);
expect(tasks).toHaveLength(1);
expect(tasks[0].name).toBe('clipA');
expect(tasks[0].grouping).toBe('frames');
// natural sort: frame1, frame2, frame10 (not lexicographic frame1, frame10, frame2)
expect(tasks[0].files.map((f) => f.name)).toEqual(['frame1.png', 'frame2.png', 'frame10.png']);
});
it('DICOM (no group-by-study): a series in a folder -> one task (MAP1)', () => {
const tasks = mapFilesToTasks('dicom', false, [
sf('seriesA/i1.dcm'),
sf('seriesA/i2.dcm'),
sf('seriesB/i1.dcm'),
]);
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.name).sort()).toEqual(['seriesA', 'seriesB']);
expect(tasks.every((t) => t.grouping === 'series')).toBe(true);
});
it('DICOM (no group-by-study): a single series without a folder -> one task', () => {
const tasks = mapFilesToTasks('dicom', false, [sf('i1.dcm'), sf('i2.dcm')]);
expect(tasks).toHaveLength(1);
expect(tasks[0].files).toHaveLength(2);
});
it('DICOM (group-by-study): top-level study folder merges its series -> one task', () => {
const tasks = mapFilesToTasks('dicom', true, [
sf('study1/seriesA/i1.dcm'),
sf('study1/seriesB/i1.dcm'),
sf('study2/seriesA/i1.dcm'),
]);
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.name).sort()).toEqual(['study1', 'study2']);
expect(tasks.find((t) => t.name === 'study1')!.files).toHaveLength(2);
expect(tasks.every((t) => t.grouping === 'study')).toBe(true);
});
it('NIfTI (no group-by-study): each file -> a separate task', () => {
const tasks = mapFilesToTasks('nifti', false, [sf('a.nii.gz'), sf('b.nii.gz')]);
expect(tasks).toHaveLength(2);
expect(tasks.map((t) => t.name)).toEqual(['a', 'b']);
expect(tasks.every((t) => t.grouping === 'volume')).toBe(true);
});
it('NIfTI (group-by-study): files in the same folder -> one task', () => {
const tasks = mapFilesToTasks('nifti', true, [
sf('studyX/scan1.nii.gz'),
sf('studyX/scan2.nii.gz'),
sf('studyY/scan1.nii.gz'),
]);
expect(tasks).toHaveLength(2);
expect(tasks.find((t) => t.name === 'studyX')!.files).toHaveLength(2);
expect(tasks.every((t) => t.grouping === 'study')).toBe(true);
});
it('is deterministic: group order is stable regardless of input order', () => {
const a = mapFilesToTasks('dicom', false, [sf('z/i.dcm'), sf('a/i.dcm')]);
const b = mapFilesToTasks('dicom', false, [sf('a/i.dcm'), sf('z/i.dcm')]);
expect(a.map((t) => t.name)).toEqual(b.map((t) => t.name));
expect(a.map((t) => t.name)).toEqual(['a', 'z']);
});
});
@@ -0,0 +1,101 @@
// Data-import DOMAIN — the §5 File-to-Task Mapping Engine. THE testable core: it decides how
// staged files become Tasks (one Task = one row on the Data Page). Pure, deterministic.
// Source: docs/workflows/data-import-spec.md §5 (mapping table + MAP1-MAP3) and §3 (data types).
//
// Folder-level assumptions (spec open Q9.6 — documented, easy to revisit):
// • DICOM, no Group-by-Study: each *immediate folder* (dirOf) is one series -> one Task (MAP1).
// • DICOM, Group-by-Study: each *top-level folder* (study root) -> one Task (a full study).
// • NIfTI, no Group-by-Study: each file -> its own Task.
// • NIfTI, Group-by-Study: each *folder* -> one Task (the study).
// • Video frames: each folder -> one Task, frames ordered by filename (MAP2).
import type { DataType } from '../dataTypes';
import type { StagedFile } from '../types';
import { basename, compareByFilename, dirOf, groupBy, stripExtension, topDirOf } from './paths';
export type TaskGrouping = 'series' | 'study' | 'frames' | 'image' | 'video' | 'volume';
/** A planned Task (intent, not yet uploaded): the files that compose it + how they were grouped. */
export interface TaskPlan {
/** Human-facing task name, derived from the folder or file name. */
name: string;
dataType: DataType;
grouping: TaskGrouping;
/** Member files, ordered deterministically (MAP2) for series/frames. */
files: StagedFile[];
}
/**
* Map staged files to Task plans per the §5 rules. Deterministic: groups are emitted in
* sorted-key order, members sorted by filename, so the output is stable for tests + previews.
*/
export function mapFilesToTasks(
dataType: DataType,
groupByStudy: boolean,
files: readonly StagedFile[],
): TaskPlan[] {
if (files.length === 0) return [];
switch (dataType) {
case 'image2d':
// Each image -> one Task.
return files.map((f) => oneFileTask(f, dataType, 'image'));
case 'video':
// Each video -> one Task.
return files.map((f) => oneFileTask(f, dataType, 'video'));
case 'videoFrames':
// All frames in a folder -> one Task, sorted by filename (MAP2).
return foldersToTasks(files, dataType, 'frames', dirOf, 'frames');
case 'dicom':
// Group-by-Study: top-level folder -> study Task. Else: immediate folder -> series Task (MAP1).
return groupByStudy
? foldersToTasks(files, dataType, 'study', topDirOf, 'study')
: foldersToTasks(files, dataType, 'series', dirOf, 'series');
case 'nifti':
// Group-by-Study: folder -> study Task. Else: each file -> its own volume Task.
return groupByStudy
? foldersToTasks(files, dataType, 'study', dirOf, 'study')
: files.map((f) => oneFileTask(f, dataType, 'volume'));
default:
return assertNever(dataType);
}
}
/** One file becomes one Task named after the file (sans extension). */
function oneFileTask(file: StagedFile, dataType: DataType, grouping: TaskGrouping): TaskPlan {
return { name: stripExtension(file.name), dataType, grouping, files: [file] };
}
/**
* Group files by a folder key, emit one Task per group. Group keys are sorted for deterministic
* output; members are sorted by filename (MAP2). The fallback name is used when files sit at the
* drop root (empty key).
*/
function foldersToTasks(
files: readonly StagedFile[],
dataType: DataType,
grouping: TaskGrouping,
keyOf: (relativePath: string) => string,
rootFallback: string,
): TaskPlan[] {
const groups = groupBy(files, (f) => keyOf(f.relativePath));
const sortedKeys = [...groups.keys()].sort(compareByFilename);
return sortedKeys.map((key) => {
const members = [...groups.get(key)!].sort((a, b) => compareByFilename(a.name, b.name));
return {
name: key === '' ? rootFallback : basename(key),
dataType,
grouping,
files: members,
};
});
}
function assertNever(x: never): never {
throw new Error(`Unhandled data type: ${String(x)}`);
}
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { validateMhdCompanions } from './mhdCompanions';
import type { StagedFile } from '../types';
function sf(relativePath: string): StagedFile {
return { name: relativePath.slice(relativePath.lastIndexOf('/') + 1), relativePath, size: 1 };
}
describe('validateMhdCompanions (D6)', () => {
it('passes when the .mhd has a .raw companion in the same folder', () => {
expect(validateMhdCompanions([sf('vol.mhd'), sf('vol.raw')])).toEqual([]);
});
it('passes with .img and .zraw companions too', () => {
expect(validateMhdCompanions([sf('a/x.mhd'), sf('a/x.img')])).toEqual([]);
expect(validateMhdCompanions([sf('a/y.mhd'), sf('a/y.zraw')])).toEqual([]);
});
it('fails when the .mhd has no companion', () => {
const errs = validateMhdCompanions([sf('vol.mhd')]);
expect(errs).toHaveLength(1);
expect(errs[0].code).toBe('MHD_COMPANION_MISSING');
expect(errs[0].file).toBe('vol.mhd');
});
it('fails when the companion is in a different folder', () => {
const errs = validateMhdCompanions([sf('a/vol.mhd'), sf('b/vol.raw')]);
expect(errs).toHaveLength(1);
});
it('passes when there are no .mhd files at all', () => {
expect(validateMhdCompanions([sf('brain.nii.gz')])).toEqual([]);
});
});
@@ -0,0 +1,41 @@
// Data-import DOMAIN — D6: an `.mhd` upload is invalid unless its companion raw data file
// (`.raw`/`.img` uncompressed, or `.zraw` compressed) with the same base name is in the same
// folder. Pure. Source: docs/workflows/data-import-spec.md §3 (D6) + §2.1.
import { extensionOf, MHD_COMPANION_EXTENSIONS } from '../formats';
import { importError, type ImportError, type StagedFile } from '../types';
import { dirOf, stripExtension } from './paths';
/** Composite key locating a logical volume within its folder: `<dir>::<baseName>`. */
function volumeKey(file: StagedFile): string {
return `${dirOf(file.relativePath)}::${stripExtension(file.name)}`;
}
/**
* Return one MHD_COMPANION_MISSING error per `.mhd` file that lacks a sibling raw companion.
* Empty array = all companions present (or no `.mhd` files at all).
*/
export function validateMhdCompanions(files: readonly StagedFile[]): ImportError[] {
// Index every present raw-companion file by its <dir>::<base> key.
const companions = new Set<string>();
for (const f of files) {
if ((MHD_COMPANION_EXTENSIONS as readonly string[]).includes(extensionOf(f.name))) {
companions.add(volumeKey(f));
}
}
const errors: ImportError[] = [];
for (const f of files) {
if (extensionOf(f.name) !== '.mhd') continue;
if (!companions.has(volumeKey(f))) {
errors.push(
importError(
'MHD_COMPANION_MISSING',
`Tệp "${f.name}" thiếu tệp dữ liệu đi kèm (.raw/.img/.zraw) cùng tên trong cùng thư mục.`,
{ file: f.relativePath },
),
);
}
}
return errors;
}
@@ -0,0 +1,49 @@
// Data-import DOMAIN — path helpers for the §5 mapping engine. Pure. A "folder" is the task
// boundary for series/frame/study grouping (MAP1), so the engine reasons about relativePath
// segments here.
import { extensionOf } from '../formats';
/** Directory part of a relative path. `study1/series1/img.dcm` -> `study1/series1`; `img.dcm` -> ``. */
export function dirOf(relativePath: string): string {
const i = relativePath.lastIndexOf('/');
return i >= 0 ? relativePath.slice(0, i) : '';
}
/** First path segment (the "study" root). `study1/series1/img.dcm` -> `study1`; `img.dcm` -> ``. */
export function topDirOf(relativePath: string): string {
const i = relativePath.indexOf('/');
return i >= 0 ? relativePath.slice(0, i) : '';
}
/** Last path segment. `study1/series1` -> `series1`; `img.dcm` -> `img.dcm`. */
export function basename(p: string): string {
return p.slice(p.lastIndexOf('/') + 1);
}
/** File name without its (double-extension-aware) extension. `brain.nii.gz` -> `brain`. */
export function stripExtension(name: string): string {
const base = basename(name);
const ext = extensionOf(base);
return ext ? base.slice(0, base.length - ext.length) : base;
}
/**
* Deterministic filename ordering (MAP2) — natural/numeric so `img2` precedes `img10`.
* Used to order video frames and series instances by filename.
*/
export function compareByFilename(a: string, b: string): number {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
/** Group items by a string key, preserving first-seen order within each group. */
export function groupBy<T>(items: readonly T[], key: (t: T) => string): Map<string, T[]> {
const out = new Map<string, T[]>();
for (const item of items) {
const k = key(item);
const bucket = out.get(k);
if (bucket) bucket.push(item);
else out.set(k, [item]);
}
return out;
}
@@ -0,0 +1,590 @@
import { describe, it, expect } from 'vitest';
import {
pad3,
pad4,
datasetFolderName,
parseFilename,
groupFilesIntoCases,
validateDataset,
generateDatasetJson,
planFolderTree,
generateBashScript,
generatePythonScript,
type Channel,
type Label,
type DatasetMeta,
type DatasetCase,
type SourceFile,
type FileEnding,
type Split,
type ExecOptions,
} from './core';
// ----------------------------- fixtures -----------------------------
/** A valid DatasetMeta; override any field. Default: 3D, single non-CT channel, bg + 1 label. */
function mkMeta(over: Partial<DatasetMeta> = {}): DatasetMeta {
return {
id: 5,
name: 'Prostate',
fileEnding: '.nii.gz',
dim: '3d',
rgb: false,
channels: [{ index: 0, name: 'T2', isCT: false }],
labels: [
{ name: 'background', value: 0 },
{ name: 'PZ', value: 1 },
],
...over,
};
}
/** A train case with channel 0 + a label, named to match meta.fileEnding. */
function mkCase(over: Partial<DatasetCase> = {}, ending: FileEnding = '.nii.gz'): DatasetCase {
const caseId = over.caseId ?? 'BRATS_001';
const split: Split = over.split ?? 'train';
const channelFiles =
over.channelFiles ?? {
0: mkFile(`${caseId}_0000${ending}`, { role: 'image', caseId, channelIndex: 0, split }),
};
const labelFile =
'labelFile' in over
? over.labelFile
: mkFile(`${caseId}${ending}`, { role: 'label', caseId, split });
return { caseId, split, channelFiles, labelFile };
}
function mkFile(filename: string, over: Partial<SourceFile> = {}): SourceFile {
return {
id: over.id ?? `f-${filename}`,
filename,
sizeBytes: over.sizeBytes ?? 100,
role: over.role ?? 'unknown',
caseId: over.caseId,
channelIndex: over.channelIndex,
split: over.split ?? 'train',
png: over.png,
blob: over.blob,
};
}
const codes = (r: { issues: { code: string }[] }) => r.issues.map((i) => i.code);
const defOpts = (over: Partial<ExecOptions> = {}): ExecOptions => ({
operation: 'copy',
target: 'local',
sourceRoot: '/in',
destRoot: '/out',
...over,
});
// =============================== padding ===============================
describe('pad3 / pad4', () => {
it('zero-pads to width 3 and 4', () => {
expect(pad3(5)).toBe('005');
expect(pad3(42)).toBe('042');
expect(pad3(999)).toBe('999');
expect(pad4(0)).toBe('0000');
expect(pad4(12)).toBe('0012');
});
});
// =========================== datasetFolderName ===========================
describe('datasetFolderName', () => {
it('pads id to 3 and strips spaces from the name', () => {
expect(datasetFolderName(mkMeta({ id: 5, name: 'Prostate' }))).toBe('Dataset005_Prostate');
expect(datasetFolderName(mkMeta({ id: 5, name: 'Pro state' }))).toBe('Dataset005_Prostate');
expect(datasetFolderName(mkMeta({ id: 123, name: 'Brain Tumor Seg' }))).toBe(
'Dataset123_BrainTumorSeg',
);
});
});
// ============================= parseFilename =============================
describe('parseFilename', () => {
it('parses an image filename with a 4-digit channel suffix', () => {
expect(parseFilename('BRATS_001_0000.nii.gz', '.nii.gz')).toEqual({
caseId: 'BRATS_001',
channelIndex: 0,
role: 'image',
});
expect(parseFilename('BRATS_001_0003.nii.gz', '.nii.gz')).toEqual({
caseId: 'BRATS_001',
channelIndex: 3,
role: 'image',
});
});
it('parses a label filename (no channel suffix)', () => {
expect(parseFilename('BRATS_001.nii.gz', '.nii.gz')).toEqual({
caseId: 'BRATS_001',
role: 'label',
});
});
it('returns null when the ending does not match', () => {
expect(parseFilename('BRATS_001_0000.nrrd', '.nii.gz')).toBeNull();
expect(parseFilename('BRATS_001.png', '.nii.gz')).toBeNull();
});
});
// ========================== groupFilesIntoCases ==========================
describe('groupFilesIntoCases', () => {
it('groups a flat file list by caseId into channelFiles + labelFile', () => {
const meta = mkMeta();
const files: SourceFile[] = [
mkFile('BRATS_001_0000.nii.gz'),
mkFile('BRATS_001.nii.gz'),
mkFile('BRATS_002_0000.nii.gz'),
];
const { cases, unassigned } = groupFilesIntoCases(files, meta);
expect(unassigned).toHaveLength(0);
expect(cases).toHaveLength(2);
const c1 = cases.find((c) => c.caseId === 'BRATS_001')!;
expect(c1.channelFiles[0]?.filename).toBe('BRATS_001_0000.nii.gz');
expect(c1.channelFiles[0]?.role).toBe('image');
expect(c1.channelFiles[0]?.channelIndex).toBe(0);
expect(c1.labelFile?.filename).toBe('BRATS_001.nii.gz');
expect(c1.labelFile?.role).toBe('label');
const c2 = cases.find((c) => c.caseId === 'BRATS_002')!;
expect(c2.channelFiles[0]?.filename).toBe('BRATS_002_0000.nii.gz');
expect(c2.labelFile).toBeUndefined();
});
it('puts unparseable files (wrong ending, no caseId) into the unassigned bucket', () => {
const meta = mkMeta(); // .nii.gz
const files: SourceFile[] = [
mkFile('BRATS_001_0000.nii.gz'),
mkFile('stray.txt'), // does not end with .nii.gz, role unknown -> unassigned
];
const { cases, unassigned } = groupFilesIntoCases(files, meta);
expect(cases).toHaveLength(1);
expect(unassigned).toHaveLength(1);
expect(unassigned[0].filename).toBe('stray.txt');
});
it('honours a pre-set caseId/role instead of re-parsing the filename', () => {
const meta = mkMeta();
const files: SourceFile[] = [
mkFile('weird-name.nii.gz', { role: 'image', caseId: 'CASE_X', channelIndex: 0 }),
];
const { cases } = groupFilesIntoCases(files, meta);
expect(cases).toHaveLength(1);
expect(cases[0].caseId).toBe('CASE_X');
expect(cases[0].channelFiles[0]?.filename).toBe('weird-name.nii.gz');
});
});
// ============================ validateDataset ============================
describe('validateDataset — valid input', () => {
it('a well-formed single-channel 3D dataset yields ok:true with 0 errors', () => {
// id 5 -> dataset.idReserved warning is expected, so use id 42 to get a fully clean report.
const meta = mkMeta({ id: 42, name: 'Prostate' });
const r = validateDataset(meta, [mkCase()]);
expect(r.ok).toBe(true);
expect(r.errorCount).toBe(0);
expect(r.warningCount).toBe(0);
expect(r.issues).toHaveLength(0);
});
it('bookkeeping: ok=false once any error is present; counts split by level', () => {
const meta = mkMeta({ id: 5, name: '' }); // id 5 -> reserved warning; empty name -> error
const r = validateDataset(meta, [mkCase()]);
expect(r.ok).toBe(false);
expect(r.errorCount).toBeGreaterThanOrEqual(1);
expect(r.warningCount).toBeGreaterThanOrEqual(1);
expect(r.errorCount + r.warningCount).toBe(r.issues.length);
expect(codes(r)).toContain('dataset.name');
expect(codes(r)).toContain('dataset.idReserved');
});
});
describe('validateDataset — dataset id / name rules', () => {
it('dataset.id: id below 1 or above 999 is an error', () => {
expect(codes(validateDataset(mkMeta({ id: 0 }), [mkCase()]))).toContain('dataset.id');
expect(codes(validateDataset(mkMeta({ id: 1000 }), [mkCase()]))).toContain('dataset.id');
});
it('dataset.idReserved: ids 1..10 emit a warning', () => {
const r = validateDataset(mkMeta({ id: 3 }), [mkCase()]);
const reserved = r.issues.find((i) => i.code === 'dataset.idReserved');
expect(reserved?.level).toBe('warning');
});
it('dataset.name: empty / whitespace name is an error', () => {
expect(codes(validateDataset(mkMeta({ id: 42, name: ' ' }), [mkCase()]))).toContain(
'dataset.name',
);
});
it('dataset.nameSpaces: a name with spaces is a warning', () => {
const r = validateDataset(mkMeta({ id: 42, name: 'Has Space' }), [mkCase()]);
const sp = r.issues.find((i) => i.code === 'dataset.nameSpaces');
expect(sp?.level).toBe('warning');
});
});
describe('validateDataset — channel rules', () => {
it('channels.empty: no channels is an error', () => {
const meta = mkMeta({ id: 42, channels: [] });
// case must satisfy whatever fileChannels yields; with no channels there is no required channel.
const c = mkCase({ channelFiles: {} });
expect(codes(validateDataset(meta, [c]))).toContain('channels.empty');
});
it('channels.consecutive: a non-0-based / gapped index set is an error', () => {
const meta = mkMeta({
id: 42,
channels: [{ index: 1, name: 'T2', isCT: false }], // starts at 1, not 0
});
const c = mkCase({
channelFiles: { 1: mkFile('BRATS_001_0001.nii.gz', { role: 'image', caseId: 'BRATS_001', channelIndex: 1 }) },
});
expect(codes(validateDataset(meta, [c]))).toContain('channels.consecutive');
});
it('channels.name: a blank channel name is an error', () => {
const meta = mkMeta({ id: 42, channels: [{ index: 0, name: ' ', isCT: false }] });
expect(codes(validateDataset(meta, [mkCase()]))).toContain('channels.name');
});
it('channels.dupName: duplicate channel names are a warning', () => {
const meta = mkMeta({
id: 42,
channels: [
{ index: 0, name: 'T2', isCT: false },
{ index: 1, name: 'T2', isCT: false },
],
});
const c = mkCase({
channelFiles: {
0: mkFile('BRATS_001_0000.nii.gz', { role: 'image', caseId: 'BRATS_001', channelIndex: 0 }),
1: mkFile('BRATS_001_0001.nii.gz', { role: 'image', caseId: 'BRATS_001', channelIndex: 1 }),
},
});
const r = validateDataset(meta, [c]);
const dup = r.issues.find((i) => i.code === 'channels.dupName');
expect(dup?.level).toBe('warning');
});
});
describe('validateDataset — label rules', () => {
it('labels.background: missing value-0 label is an error', () => {
const meta = mkMeta({ id: 42, labels: [{ name: 'PZ', value: 1 }] });
expect(codes(validateDataset(meta, [mkCase()]))).toContain('labels.background');
});
it('labels.consecutive: gapped label values are an error', () => {
const meta = mkMeta({
id: 42,
labels: [
{ name: 'background', value: 0 },
{ name: 'PZ', value: 2 }, // gap: no value 1
],
});
expect(codes(validateDataset(meta, [mkCase()]))).toContain('labels.consecutive');
});
it('labels.name: a blank label name is an error', () => {
const meta = mkMeta({
id: 42,
labels: [
{ name: 'background', value: 0 },
{ name: ' ', value: 1 },
],
});
expect(codes(validateDataset(meta, [mkCase()]))).toContain('labels.name');
});
it('labels.dupName: duplicate label names are an error', () => {
const meta = mkMeta({
id: 42,
labels: [
{ name: 'same', value: 0 },
{ name: 'same', value: 1 },
],
});
const r = validateDataset(meta, [mkCase()]);
const dup = r.issues.find((i) => i.code === 'labels.dupName');
expect(dup?.level).toBe('error');
});
});
describe('validateDataset — format / dimension coherence', () => {
it('format.dimMismatch: a 2D-only ending on a 3D dataset is a warning', () => {
// .png is a 2D ending; on a 3d dataset this is unusual -> warning.
const meta = mkMeta({ id: 42, dim: '3d', fileEnding: '.png' });
const c = mkCase({}, '.png');
const r = validateDataset(meta, [c]);
const mm = r.issues.find((i) => i.code === 'format.dimMismatch');
expect(mm?.level).toBe('warning');
});
it('rgb.channels (warning) + rgb.format (error): RGB on a non-image ending with wrong channels', () => {
// rgb true, .nii.gz (not a 2D image format) and a single non-R/G/B channel.
const meta = mkMeta({
id: 42,
dim: '2d',
rgb: true,
fileEnding: '.nii.gz',
channels: [{ index: 0, name: 'T2', isCT: false }],
});
// RGB => single file slot at channel 0.
const c = mkCase({}, '.nii.gz');
const r = validateDataset(meta, [c]);
const rgbCh = r.issues.find((i) => i.code === 'rgb.channels');
const rgbFmt = r.issues.find((i) => i.code === 'rgb.format');
expect(rgbCh?.level).toBe('warning');
expect(rgbFmt?.level).toBe('error');
});
it('a correct RGB dataset (3x R/G/B, .png) emits neither rgb.channels nor rgb.format', () => {
const meta = mkMeta({
id: 42,
dim: '2d',
rgb: true,
fileEnding: '.png',
channels: [
{ index: 0, name: 'R', isCT: false },
{ index: 1, name: 'G', isCT: false },
{ index: 2, name: 'B', isCT: false },
],
});
const c = mkCase({}, '.png'); // RGB -> only channel 0 slot required
const r = validateDataset(meta, [c]);
expect(codes(r)).not.toContain('rgb.channels');
expect(codes(r)).not.toContain('rgb.format');
});
});
describe('validateDataset — case rules', () => {
it('cases.noTrain: a dataset with only test cases is an error', () => {
const meta = mkMeta({ id: 42 });
const testCase = mkCase({ caseId: 'T1', split: 'test', labelFile: undefined });
expect(codes(validateDataset(meta, [testCase]))).toContain('cases.noTrain');
});
it('cases.dupId: two cases with the same id is an error', () => {
const meta = mkMeta({ id: 42 });
const a = mkCase({ caseId: 'BRATS_001' });
const b = mkCase({ caseId: 'BRATS_001' });
expect(codes(validateDataset(meta, [a, b]))).toContain('cases.dupId');
});
it('case.missingChannel: a case lacking a required channel file is an error scoped to the case', () => {
const meta = mkMeta({ id: 42 });
const c = mkCase({ caseId: 'BRATS_001', channelFiles: {} }); // missing channel 0
const r = validateDataset(meta, [c]);
const miss = r.issues.find((i) => i.code === 'case.missingChannel');
expect(miss).toBeDefined();
expect(miss?.caseId).toBe('BRATS_001');
});
it('case.missingLabel: a train case with no label file is an error', () => {
const meta = mkMeta({ id: 42 });
const c = mkCase({ caseId: 'BRATS_001', labelFile: undefined });
const r = validateDataset(meta, [c]);
const miss = r.issues.find((i) => i.code === 'case.missingLabel');
expect(miss?.level).toBe('error');
expect(miss?.caseId).toBe('BRATS_001');
});
it('case.testLabel: a test case that carries a label is a warning', () => {
const meta = mkMeta({ id: 42 });
const train = mkCase({ caseId: 'TR1' });
const test = mkCase({
caseId: 'TS1',
split: 'test',
channelFiles: {
0: mkFile('TS1_0000.nii.gz', { role: 'image', caseId: 'TS1', channelIndex: 0, split: 'test' }),
},
labelFile: mkFile('TS1.nii.gz', { role: 'label', caseId: 'TS1', split: 'test' }),
});
const r = validateDataset(meta, [train, test]);
const tl = r.issues.find((i) => i.code === 'case.testLabel');
expect(tl?.level).toBe('warning');
expect(tl?.caseId).toBe('TS1');
});
});
describe('validateDataset — file format guards', () => {
it('file.lossy: a .jpg file is an error', () => {
const meta = mkMeta({ id: 42, dim: '2d', fileEnding: '.png' });
const c = mkCase(
{
caseId: 'C1',
channelFiles: {
0: mkFile('C1_0000.jpg', { role: 'image', caseId: 'C1', channelIndex: 0 }),
},
labelFile: mkFile('C1.png', { role: 'label', caseId: 'C1' }),
},
'.png',
);
expect(codes(validateDataset(meta, [c]))).toContain('file.lossy');
});
it('file.ending: a file not ending in the declared ending is an error', () => {
const meta = mkMeta({ id: 42, fileEnding: '.nii.gz' });
const c = mkCase({
caseId: 'C1',
channelFiles: {
0: mkFile('C1_0000.nrrd', { role: 'image', caseId: 'C1', channelIndex: 0 }),
},
labelFile: mkFile('C1.nii.gz', { role: 'label', caseId: 'C1' }),
});
expect(codes(validateDataset(meta, [c]))).toContain('file.ending');
});
});
// =========================== generateDatasetJson ===========================
describe('generateDatasetJson', () => {
it('keys channel_names by index, mapping isCT channels to "CT"', () => {
const meta = mkMeta({
channels: [
{ index: 0, name: 'scan', isCT: true },
{ index: 1, name: 'T2', isCT: false },
],
});
const json = generateDatasetJson(meta, [mkCase()]) as {
channel_names: Record<string, string>;
};
expect(json.channel_names['0']).toBe('CT');
expect(json.channel_names['1']).toBe('T2');
});
it('maps labels name->value, counts numTraining, and echoes file_ending', () => {
const meta = mkMeta({
labels: [
{ name: 'background', value: 0 },
{ name: 'PZ', value: 1 },
{ name: 'TZ', value: 2 },
],
});
const cases: DatasetCase[] = [
mkCase({ caseId: 'A' }),
mkCase({ caseId: 'B' }),
mkCase({
caseId: 'T1',
split: 'test',
channelFiles: {
0: mkFile('T1_0000.nii.gz', { role: 'image', caseId: 'T1', channelIndex: 0, split: 'test' }),
},
labelFile: undefined,
}),
];
const json = generateDatasetJson(meta, cases) as {
labels: Record<string, number>;
numTraining: number;
file_ending: string;
};
expect(json.labels).toEqual({ background: 0, PZ: 1, TZ: 2 });
expect(json.numTraining).toBe(2); // only train cases A + B
expect(json.file_ending).toBe('.nii.gz');
});
});
// ============================= planFolderTree =============================
describe('planFolderTree', () => {
it('plans a train case into imagesTr/<case>_<XXXX> and labelsTr/<case>', () => {
const meta = mkMeta({ id: 5, name: 'Prostate' });
const plan = planFolderTree(meta, [mkCase({ caseId: 'BRATS_001' })]);
const targets = plan.map((p) => p.targetPath);
expect(targets).toContain('Dataset005_Prostate/imagesTr/BRATS_001_0000.nii.gz');
expect(targets).toContain('Dataset005_Prostate/labelsTr/BRATS_001.nii.gz');
});
it('plans a test case into imagesTs and emits no label path', () => {
const meta = mkMeta({ id: 5, name: 'Prostate' });
const test = mkCase({
caseId: 'TS1',
split: 'test',
channelFiles: {
0: mkFile('TS1_0000.nii.gz', { role: 'image', caseId: 'TS1', channelIndex: 0, split: 'test' }),
},
labelFile: mkFile('TS1.nii.gz', { role: 'label', caseId: 'TS1', split: 'test' }),
});
const plan = planFolderTree(meta, [test]);
const targets = plan.map((p) => p.targetPath);
expect(targets).toContain('Dataset005_Prostate/imagesTs/TS1_0000.nii.gz');
expect(targets.some((t) => t.includes('labelsTr') || t.includes('labelsTs'))).toBe(false);
});
it('RGB uses a single file slot (channel 0) regardless of declared channels', () => {
const meta = mkMeta({
id: 5,
name: 'Cells',
dim: '2d',
rgb: true,
fileEnding: '.png',
channels: [
{ index: 0, name: 'R', isCT: false },
{ index: 1, name: 'G', isCT: false },
{ index: 2, name: 'B', isCT: false },
],
});
const c = mkCase({ caseId: 'CELL1' }, '.png');
const plan = planFolderTree(meta, [c]);
const imageTargets = plan.filter((p) => p.targetPath.includes('imagesTr'));
expect(imageTargets).toHaveLength(1); // only channel 0 slot
expect(imageTargets[0].targetPath).toBe('Dataset005_Cells/imagesTr/CELL1_0000.png');
});
});
// ===================== generateBashScript / PythonScript =====================
describe('generateBashScript', () => {
it('embeds the dataset.json block, mkdir of the dataset folder, and source->target manifest lines', () => {
const meta = mkMeta({ id: 5, name: 'Prostate' });
const cases = [mkCase({ caseId: 'BRATS_001' })];
const plan = planFolderTree(meta, cases);
const json = generateDatasetJson(meta, cases);
const script = generateBashScript(meta, plan, json, defOpts());
// dataset.json block present (serialized JSON contains the file_ending key)
expect(script).toContain('dataset.json');
expect(script).toContain('"file_ending": ".nii.gz"');
// mkdir of a path under the dataset folder
expect(script).toContain('mkdir -p "$DEST_ROOT/Dataset005_Prostate/imagesTr"');
// manifest: src<TAB>dst line for the channel file
expect(script).toContain('BRATS_001_0000.nii.gz\tDataset005_Prostate/imagesTr/BRATS_001_0000.nii.gz');
// honours the copy operation
expect(script).toContain('cp -p');
});
it('uses mv when operation is move', () => {
const meta = mkMeta({ id: 5, name: 'Prostate' });
const cases = [mkCase({ caseId: 'BRATS_001' })];
const plan = planFolderTree(meta, cases);
const json = generateDatasetJson(meta, cases);
const script = generateBashScript(meta, plan, json, defOpts({ operation: 'move' }));
expect(script).toContain('mv "$SOURCE_DIR/$src" "$DEST_ROOT/$dst"');
});
});
describe('generatePythonScript', () => {
it('embeds DATASET_JSON, makedirs of the dataset folder, and source->target manifest tuples', () => {
const meta = mkMeta({ id: 5, name: 'Prostate' });
const cases = [mkCase({ caseId: 'BRATS_001' })];
const plan = planFolderTree(meta, cases);
const json = generateDatasetJson(meta, cases);
const script = generatePythonScript(meta, plan, json, defOpts());
expect(script).toContain('DATASET_JSON = ');
expect(script).toContain('"file_ending": ".nii.gz"');
// makedirs of the dataset root + subdirs
expect(script).toContain('os.path.join(DEST_ROOT, "Dataset005_Prostate")');
expect(script).toContain('os.makedirs(os.path.join(root, d), exist_ok=True)');
// manifest tuple: (src, dst)
expect(script).toContain(
'("BRATS_001_0000.nii.gz", "Dataset005_Prostate/imagesTr/BRATS_001_0000.nii.gz"),',
);
});
});
@@ -0,0 +1,544 @@
/**
* nnU-Net v2 dataset organizer — framework-agnostic typed core.
*
* Combines nnU-Net v2 dataset-format requirements with the RedBrick import model:
* RedBrick Task -> nnU-Net training/test CASE
* RedBrick series -> nnU-Net input CHANNEL (modality)
* RedBrick GT -> nnU-Net LABEL (segmentation in labelsTr)
*
* Pure functions only — no React, no DOM. Import into UI or a build step.
*
* Ported verbatim into the data-import feature from docs/workflows/nnunet.ts (the design
* reference). This is the pure domain for the nnU-Net upload mode; the UI wraps it. Keep its
* lossless-only FILE_ENDINGS separate from the feature's input formats.ts (opposite .jpg policy).
*/
// ----------------------------- Types -----------------------------
/** Lossless formats nnU-Net supports. .jpg/.jpeg are intentionally excluded. */
export type FileEnding =
| ".nii.gz"
| ".nrrd"
| ".mha"
| ".png"
| ".bmp"
| ".tif"
| ".tiff";
export const FILE_ENDINGS: FileEnding[] = [
".nii.gz",
".nrrd",
".mha",
".png",
".bmp",
".tif",
".tiff",
];
export type Split = "train" | "test";
export interface Channel {
/** 0-based, must be consecutive across the dataset. Rendered as 4-digit XXXX. */
index: number;
/** e.g. "T2", "ADC", "CT". "CT" triggers global intensity normalization. */
name: string;
isCT: boolean;
}
export interface Label {
/** e.g. "background", "PZ". */
name: string;
/** Integer class value. Must be consecutive from 0; background must be 0. */
value: number;
}
export interface PngInfo {
width: number;
height: number;
/** PNG IHDR colour type: 0 gray, 2 RGB, 3 indexed, 4 gray+alpha, 6 RGBA. */
colorType: number;
}
export interface SourceFile {
id: string;
filename: string;
sizeBytes: number;
/** Inferred or user-set: image (has _XXXX) vs label (no channel suffix). */
role: "image" | "label" | "unknown";
caseId?: string;
channelIndex?: number;
split: Split;
/** Retained handle (enables PNG inspection and presigned upload). */
blob?: Blob;
/** Parsed from the PNG header when available. */
png?: PngInfo;
}
export interface DatasetCase {
caseId: string;
split: Split;
/** channelIndex -> file (undefined = missing channel). */
channelFiles: Record<number, SourceFile | undefined>;
/** Required for train cases, absent for test cases. */
labelFile?: SourceFile;
}
export interface DatasetMeta {
/** 1..999, rendered 3-digit. 1..10 reserved for the Medical Segmentation Decathlon. */
id: number;
/** Free-form, conventionally CamelCase with no spaces. */
name: string;
fileEnding: FileEnding;
/** Spatial dimensionality. Drives default formats and the training config (2d vs 3d_fullres). */
dim: "2d" | "3d";
/** RGB natural-image mode: all three colour channels live in ONE file (nnU-Net's exception). */
rgb: boolean;
channels: Channel[];
labels: Label[];
}
export type IssueLevel = "error" | "warning";
export interface ValidationIssue {
level: IssueLevel;
/** Stable rule id, e.g. "labels.consecutive". */
code: string;
message: string;
/** Optional case the issue is scoped to. */
caseId?: string;
}
export interface ValidationReport {
issues: ValidationIssue[];
errorCount: number;
warningCount: number;
ok: boolean;
}
export interface PlannedFile {
source: SourceFile;
/** Path relative to nnUNet_raw, e.g. Dataset005_Prostate/imagesTr/case_0000.nii.gz */
targetPath: string;
}
// --------------------------- Formatting ---------------------------
export const pad3 = (n: number): string => String(n).padStart(3, "0");
export const pad4 = (n: number): string => String(n).padStart(4, "0");
/** "Dataset005_Prostate" */
export function datasetFolderName(meta: DatasetMeta): string {
const safeName = (meta.name || "Dataset").replace(/\s+/g, "");
return `Dataset${pad3(meta.id)}_${safeName}`;
}
/** 2D natural-image formats vs 3D volume formats. */
export function endingsForDim(dim: "2d" | "3d"): FileEnding[] {
return dim === "2d" ? [".png", ".bmp", ".tif", ".tiff"] : [".nii.gz", ".nrrd", ".mha", ".tif", ".tiff"];
}
/**
* Files-per-case channel list. For RGB the three colour channels live in ONE
* file, so there is a single file slot (channel 0) even though dataset.json
* still declares three channel_names (R, G, B).
*/
export function fileChannels(meta: DatasetMeta): { index: number; name: string }[] {
return meta.rgb ? [{ index: 0, name: "RGB" }] : meta.channels.map((c) => ({ index: c.index, name: c.name }));
}
/** Parse width/height/colour-type from a PNG's first bytes (IHDR). Null if not a PNG. */
export function parsePngHeader(bytes: Uint8Array): PngInfo | null {
if (bytes.length < 26) return null;
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
for (let i = 0; i < 8; i++) if (bytes[i] !== sig[i]) return null;
const u32 = (o: number) => ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]) >>> 0;
return { width: u32(16), height: u32(20), colorType: bytes[25] };
}
export function pngColorTypeName(ct: number): string {
return ({ 0: "grayscale", 2: "RGB", 3: "indexed", 4: "gray+alpha", 6: "RGBA" } as Record<number, string>)[ct] ?? `type ${ct}`;
}
// ------------------------- Filename parsing -------------------------
/** Strip a known file ending (handles multi-dot like .nii.gz). */
export function stripEnding(filename: string, ending: FileEnding): string | null {
return filename.endsWith(ending) ? filename.slice(0, -ending.length) : null;
}
/**
* Infer {caseId, channelIndex, role} from an nnU-Net-style filename.
* Images end with _XXXX (4 digits); labels do not.
* "BRATS_001_0000.nii.gz" -> { caseId: "BRATS_001", channelIndex: 0, role: "image" }
* "BRATS_001.nii.gz" -> { caseId: "BRATS_001", role: "label" }
*/
export function parseFilename(
filename: string,
ending: FileEnding
): { caseId: string; channelIndex?: number; role: "image" | "label" } | null {
const base = stripEnding(filename, ending);
if (base === null) return null;
const m = base.match(/^(.*)_(\d{4})$/);
if (m) return { caseId: m[1], channelIndex: parseInt(m[2], 10), role: "image" };
return { caseId: base, role: "label" };
}
// --------------------------- Grouping ---------------------------
/** Group a flat file list into cases using nnU-Net naming + a split hint. */
export function groupFilesIntoCases(
files: SourceFile[],
meta: DatasetMeta
): { cases: DatasetCase[]; unassigned: SourceFile[] } {
const byCase = new Map<string, DatasetCase>();
const unassigned: SourceFile[] = [];
const ensure = (caseId: string, split: Split): DatasetCase => {
let c = byCase.get(caseId);
if (!c) {
c = { caseId, split, channelFiles: {}, labelFile: undefined };
byCase.set(caseId, c);
}
return c;
};
for (const f of files) {
const parsed =
f.caseId != null && f.role !== "unknown"
? { caseId: f.caseId, channelIndex: f.channelIndex, role: f.role }
: parseFilename(f.filename, meta.fileEnding);
if (!parsed) {
unassigned.push(f);
continue;
}
const c = ensure(parsed.caseId, f.split);
if (parsed.role === "image" && parsed.channelIndex != null) {
c.channelFiles[parsed.channelIndex] = { ...f, role: "image", caseId: parsed.caseId, channelIndex: parsed.channelIndex };
} else {
c.labelFile = { ...f, role: "label", caseId: parsed.caseId };
}
}
return { cases: [...byCase.values()], unassigned };
}
// ------------------- RedBrick Items List import -------------------
/** Minimal shape of a RedBrick Items List entry (standard format). */
export interface RedBrickEntry {
name: string;
series?: { items: string | string[]; name?: string }[];
items?: string[]; // simplified auto-split format
preAssign?: Record<string, string | string[]>;
}
/**
* Map a RedBrick Items List into nnU-Net source files.
* Each entry -> one case; each series -> one channel (in array order).
* Mirrors the RedBrick spec: Task=Case, series=Channel.
*/
export function fromRedBrickItemsList(
entries: RedBrickEntry[],
ending: FileEnding,
split: Split = "train"
): SourceFile[] {
const out: SourceFile[] = [];
entries.forEach((entry, ei) => {
const seriesList =
entry.series ??
(entry.items ? entry.items.map((it) => ({ items: it })) : []);
seriesList.forEach((s, ci) => {
const first = Array.isArray(s.items) ? s.items[0] : s.items;
out.push({
id: `rb-${ei}-${ci}-${Math.random().toString(36).slice(2, 7)}`,
filename: `${entry.name}_${pad4(ci)}${ending}`,
sizeBytes: 0,
role: "image",
caseId: entry.name,
channelIndex: ci,
split,
});
void first;
});
});
return out;
}
// --------------------------- Validation ---------------------------
export function validateDataset(
meta: DatasetMeta,
cases: DatasetCase[]
): ValidationReport {
const issues: ValidationIssue[] = [];
const err = (code: string, message: string, caseId?: string) =>
issues.push({ level: "error", code, message, caseId });
const warn = (code: string, message: string, caseId?: string) =>
issues.push({ level: "warning", code, message, caseId });
// Dataset id / name
if (!Number.isInteger(meta.id) || meta.id < 1 || meta.id > 999)
err("dataset.id", "Dataset ID must be an integer between 1 and 999.");
if (meta.id >= 1 && meta.id <= 10)
warn("dataset.idReserved", "IDs 001010 are reserved for the Medical Segmentation Decathlon.");
if (!meta.name.trim()) err("dataset.name", "Dataset name is required.");
if (/\s/.test(meta.name)) warn("dataset.nameSpaces", "Dataset name should not contain spaces (use CamelCase).");
// Channels
if (meta.channels.length === 0) err("channels.empty", "Define at least one input channel.");
const chIdx = meta.channels.map((c) => c.index).sort((a, b) => a - b);
chIdx.forEach((v, i) => {
if (v !== i) err("channels.consecutive", "Channel indices must be consecutive starting at 0.");
});
const chNames = meta.channels.map((c) => c.name.trim());
if (chNames.some((n) => !n)) err("channels.name", "Every channel needs a name.");
if (new Set(chNames).size !== chNames.length) warn("channels.dupName", "Channel names should be unique.");
// Labels
if (!meta.labels.some((l) => l.value === 0))
err("labels.background", "Labels must include background with value 0.");
const labelVals = meta.labels.map((l) => l.value).sort((a, b) => a - b);
labelVals.forEach((v, i) => {
if (v !== i) err("labels.consecutive", "Label values must be consecutive integers starting at 0.");
});
const labelNames = meta.labels.map((l) => l.name.trim());
if (labelNames.some((n) => !n)) err("labels.name", "Every label needs a name.");
if (new Set(labelNames).size !== labelNames.length) err("labels.dupName", "Label names must be unique.");
// Dimensionality / format coherence
if (!endingsForDim(meta.dim).includes(meta.fileEnding))
warn("format.dimMismatch", `${meta.fileEnding} is unusual for a ${meta.dim.toUpperCase()} dataset.`);
if (meta.rgb) {
const names = meta.channels.map((c) => c.name.trim().toUpperCase()).join(",");
if (meta.channels.length !== 3 || names !== "R,G,B")
warn("rgb.channels", "RGB mode expects exactly three channels named R, G, B.");
if (![".png", ".bmp", ".tif", ".tiff"].includes(meta.fileEnding))
err("rgb.format", "RGB mode requires a 2D image format (.png, .bmp, .tif).");
}
// Cases
const trainCases = cases.filter((c) => c.split === "train");
if (trainCases.length === 0) err("cases.noTrain", "At least one training case is required.");
const ids = cases.map((c) => c.caseId);
const dup = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dup.length) err("cases.dupId", `Duplicate case identifiers: ${[...new Set(dup)].join(", ")}.`);
const reqChannels = fileChannels(meta);
for (const c of cases) {
for (const ch of reqChannels) {
if (!c.channelFiles[ch.index])
err("case.missingChannel", meta.rgb ? "Missing the RGB image file." : `Missing channel ${pad4(ch.index)} (${ch.name}).`, c.caseId);
}
if (c.split === "train" && !c.labelFile)
err("case.missingLabel", "Training case has no label file (labelsTr).", c.caseId);
if (c.split === "test" && c.labelFile)
warn("case.testLabel", "Test case has a label; labels are ignored for the test set (imagesTs).", c.caseId);
// PNG inspection (only for files whose headers we actually read)
const dims: { w: number; h: number; name: string }[] = [];
for (const ch of reqChannels) {
const f = c.channelFiles[ch.index];
if (f?.png) dims.push({ w: f.png.width, h: f.png.height, name: f.filename });
}
if (c.labelFile?.png) {
dims.push({ w: c.labelFile.png.width, h: c.labelFile.png.height, name: c.labelFile.filename });
if (c.labelFile.png.colorType === 2 || c.labelFile.png.colorType === 6)
warn("label.rgb", `Label ${c.labelFile.filename} is ${pngColorTypeName(c.labelFile.png.colorType)}; labels must be single-channel integer masks.`, c.caseId);
}
if (dims.length >= 2) {
const { w, h } = dims[0];
if (dims.some((d) => d.w !== w || d.h !== h))
err("case.geometry", `Image and label dimensions differ within the case (${dims.map((d) => `${d.w}×${d.h}`).join(", ")}).`, c.caseId);
}
}
// File format consistency + lossy guard
const allFiles = cases.flatMap((c) => [
...Object.values(c.channelFiles).filter(Boolean) as SourceFile[],
...(c.labelFile ? [c.labelFile] : []),
]);
for (const f of allFiles) {
if (/\.jpe?g$/i.test(f.filename))
err("file.lossy", `Lossy format not allowed: ${f.filename}. Use a lossless format.`);
if (!f.filename.endsWith(meta.fileEnding))
err("file.ending", `File ending must be ${meta.fileEnding}: ${f.filename}.`);
}
const errorCount = issues.filter((i) => i.level === "error").length;
const warningCount = issues.filter((i) => i.level === "warning").length;
return { issues, errorCount, warningCount, ok: errorCount === 0 };
}
// --------------------- Outputs: json + tree ---------------------
export function generateDatasetJson(
meta: DatasetMeta,
cases: DatasetCase[]
): Record<string, unknown> {
const channel_names: Record<string, string> = {};
[...meta.channels]
.sort((a, b) => a.index - b.index)
.forEach((c) => (channel_names[String(c.index)] = c.isCT ? "CT" : c.name));
const labels: Record<string, number> = {};
[...meta.labels]
.sort((a, b) => a.value - b.value)
.forEach((l) => (labels[l.name || `label_${l.value}`] = l.value));
return {
channel_names,
labels,
numTraining: cases.filter((c) => c.split === "train").length,
file_ending: meta.fileEnding,
};
}
/** Source -> target path plan (the rename/move manifest). */
export function planFolderTree(meta: DatasetMeta, cases: DatasetCase[]): PlannedFile[] {
const root = datasetFolderName(meta);
const out: PlannedFile[] = [];
const chans = fileChannels(meta);
for (const c of cases) {
const imgDir = c.split === "train" ? "imagesTr" : "imagesTs";
for (const ch of chans) {
const f = c.channelFiles[ch.index];
if (f)
out.push({
source: f,
targetPath: `${root}/${imgDir}/${c.caseId}_${pad4(ch.index)}${meta.fileEnding}`,
});
}
if (c.split === "train" && c.labelFile)
out.push({
source: c.labelFile,
targetPath: `${root}/labelsTr/${c.caseId}${meta.fileEnding}`,
});
}
return out;
}
// --------------------- Execution: script emitters ---------------------
export type Operation = "copy" | "move";
export type Target = "local" | "s3" | "gcs" | "azure";
export interface ExecOptions {
operation: Operation;
target: Target;
/** Where the raw files currently live (filesystem path or cloud URI). */
sourceRoot: string;
/** nnUNet_raw destination (filesystem path or cloud URI). */
destRoot: string;
}
const uniqueDirs = (plan: PlannedFile[]): string[] =>
[...new Set(plan.map((p) => p.targetPath.split("/").slice(0, -1).join("/")))];
/** POSIX bash script: mkdir tree, write dataset.json, copy/move each file. */
export function generateBashScript(
meta: DatasetMeta,
plan: PlannedFile[],
datasetJson: Record<string, unknown>,
opts: ExecOptions
): string {
const DS = datasetFolderName(meta);
const op = opts.operation === "move" ? "mv" : "cp -p";
const L: string[] = [
"#!/usr/bin/env bash",
"# Sắp xếp tệp thô theo cấu trúc thư mục chuẩn. Kiểm tra trước khi chạy.",
"set -euo pipefail",
"",
`SOURCE_DIR="\${1:-${opts.sourceRoot || "./incoming"}}"`,
`DEST_ROOT="\${nnUNet_raw:-${opts.destRoot || "./nnUNet_raw"}}"`,
"",
"# 1. folder structure",
];
uniqueDirs(plan).forEach((d) => L.push(`mkdir -p "$DEST_ROOT/${d}"`));
L.push("", "# 2. dataset.json", `cat > "$DEST_ROOT/${DS}/dataset.json" <<'JSON'`, JSON.stringify(datasetJson, null, 2), "JSON", "");
L.push("# 3. transfer files (tab-separated src<TAB>dst)", `while IFS=$'\\t' read -r src dst; do`, ` [ -z "$src" ] && continue`, ` ${op} "$SOURCE_DIR/$src" "$DEST_ROOT/$dst"`, `done <<'MANIFEST'`);
plan.forEach((p) => L.push(`${p.source.filename}\t${p.targetPath}`));
L.push("MANIFEST", "", `echo "Organized ${plan.length} files into $DEST_ROOT/${DS}"`);
return L.join("\n");
}
/** Cross-platform Python (shutil) script. */
export function generatePythonScript(
meta: DatasetMeta,
plan: PlannedFile[],
datasetJson: Record<string, unknown>,
opts: ExecOptions
): string {
const DS = datasetFolderName(meta);
const manifest = plan.map((p) => ` (${JSON.stringify(p.source.filename)}, ${JSON.stringify(p.targetPath)}),`).join("\n");
return [
"#!/usr/bin/env python3",
'"""Sắp xếp tệp thô theo cấu trúc thư mục chuẩn. Kiểm tra trước khi chạy."""',
"import os, sys, json, shutil",
"",
`SOURCE_DIR = sys.argv[1] if len(sys.argv) > 1 else ${JSON.stringify(opts.sourceRoot || "./incoming")}`,
`DEST_ROOT = os.environ.get("nnUNet_raw", ${JSON.stringify(opts.destRoot || "./nnUNet_raw")})`,
`OPERATION = ${JSON.stringify(opts.operation)} # "copy" or "move"`,
"",
"DATASET_JSON = " + JSON.stringify(datasetJson, null, 2),
"",
"MANIFEST = [",
manifest,
"]",
"",
`root = os.path.join(DEST_ROOT, ${JSON.stringify(DS)})`,
"for d in ('imagesTr', 'labelsTr', 'imagesTs'):",
" os.makedirs(os.path.join(root, d), exist_ok=True)",
"with open(os.path.join(root, 'dataset.json'), 'w') as f:",
" json.dump(DATASET_JSON, f, indent=2)",
"",
"xfer = shutil.move if OPERATION == 'move' else shutil.copy2",
"for src, dst in MANIFEST:",
" s = os.path.join(SOURCE_DIR, src)",
" t = os.path.join(DEST_ROOT, dst)",
" os.makedirs(os.path.dirname(t), exist_ok=True)",
" xfer(s, t)",
"",
"print(f'Organized {len(MANIFEST)} files into {root}')",
].join("\n");
}
/** Cloud CLI script (aws s3 / gsutil / azcopy). */
export function generateCloudScript(
meta: DatasetMeta,
plan: PlannedFile[],
datasetJson: Record<string, unknown>,
opts: ExecOptions
): string {
const DS = datasetFolderName(meta);
const p = opts.target;
const xfer =
p === "s3" ? (opts.operation === "move" ? "aws s3 mv" : "aws s3 cp")
: p === "gcs" ? (opts.operation === "move" ? "gsutil mv" : "gsutil cp")
: "azcopy copy";
const up = p === "s3" ? "aws s3 cp" : p === "gcs" ? "gsutil cp" : "azcopy copy";
const defSrc = p === "s3" ? "s3://my-bucket/incoming" : p === "gcs" ? "gs://my-bucket/incoming" : "https://acct.blob.core.windows.net/incoming";
const defDst = p === "s3" ? "s3://my-bucket/nnUNet_raw" : p === "gcs" ? "gs://my-bucket/nnUNet_raw" : "https://acct.blob.core.windows.net/nnUNet_raw";
const L: string[] = [
"#!/usr/bin/env bash",
`# Chuyển tệp thô theo cấu trúc thư mục chuẩn lên ${p.toUpperCase()}. Kiểm tra trước khi chạy.`,
"set -euo pipefail",
"",
`SRC="${opts.sourceRoot || defSrc}"`,
`DST="${opts.destRoot || defDst}"`,
"",
"# dataset.json",
"cat > dataset.json <<'JSON'",
JSON.stringify(datasetJson, null, 2),
"JSON",
`${up} "dataset.json" "$DST/${DS}/dataset.json"`,
"",
"# files",
];
plan.forEach((pf) => L.push(`${xfer} "$SRC/${pf.source.filename}" "$DST/${pf.targetPath}"`));
if (opts.operation === "move" && p === "azure")
L.push("", "# NOTE: azcopy has no atomic move — delete sources separately if required.");
L.push("", `echo "Transferred ${plan.length} files to $DST/${DS}"`);
return L.join("\n");
}
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest';
import { nnunetSourceFilesFromItemsList } from './fromItemsList';
describe('nnunetSourceFilesFromItemsList', () => {
it('maps a standard Items List into per-series channels with consecutive indices', () => {
const json = JSON.stringify([
{ name: 'case_00', series: [{ items: 'a/t2.nii.gz' }, { items: 'a/adc.nii.gz' }] },
{ name: 'case_01', series: [{ items: 'b/t2.nii.gz' }, { items: 'b/adc.nii.gz' }] },
]);
const r = nnunetSourceFilesFromItemsList(json, 'nifti', '.nii.gz');
expect(r.ok).toBe(true);
expect(r.errors).toEqual([]);
expect(r.files).toHaveLength(4);
const c0 = r.files.filter((f) => f.caseId === 'case_00');
expect(c0.map((f) => f.channelIndex)).toEqual([0, 1]);
expect(c0[0].filename).toBe('case_00_0000.nii.gz');
expect(c0.every((f) => f.role === 'image' && f.split === 'train')).toBe(true);
});
it('passes the split through to every synthesized file', () => {
// path must be bucket-relative (bucket/key) — the adapter reuses the shared validator (IL6/C2).
const json = JSON.stringify([{ name: 'c', series: [{ items: 'bucket/x.nii.gz' }] }]);
const r = nnunetSourceFilesFromItemsList(json, 'nifti', '.nii.gz', 'test');
expect(r.ok).toBe(true);
expect(r.files[0]?.split).toBe('test');
});
it('surfaces parser errors and yields NO files on malformed JSON (never half-maps)', () => {
const r = nnunetSourceFilesFromItemsList('not json', 'nifti', '.nii.gz');
expect(r.ok).toBe(false);
expect(r.files).toEqual([]);
expect(r.errors[0]?.code).toBe('ITEMS_LIST_MALFORMED');
});
it('reuses the shared IL3 rule — a NIfTI series given an array of items is rejected, no files', () => {
const json = JSON.stringify([{ name: 'c', series: [{ items: ['a.nii.gz', 'b.nii.gz'] }] }]);
const r = nnunetSourceFilesFromItemsList(json, 'nifti', '.nii.gz');
expect(r.ok).toBe(false);
expect(r.files).toEqual([]);
expect(r.errors.some((e) => e.code === 'ENTRY_ITEMS_SHAPE')).toBe(true);
});
});
@@ -0,0 +1,37 @@
// Data-import DOMAIN — nnU-Net Items List adapter. Pure.
//
// Converges the feature's authoritative Items List parser/validator (itemsList/parse.ts) with the
// nnU-Net source-file synthesis (core.fromRedBrickItemsList). We do NOT re-parse JSON or re-validate
// bucket paths here — parseItemsList already owns that (IL3 NIfTI single-string, IL5 unique name,
// C2/IL6 bucket-relative). We only chain its clean tasks into nnU-Net SourceFiles that carry a
// consecutive channelIndex per series (nnU-Net: Task=Case, series=Channel).
import type { DataType } from '../dataTypes';
import type { ImportError } from '../types';
import { parseItemsList } from '../itemsList/parse';
import { fromRedBrickItemsList, type FileEnding, type SourceFile, type Split } from './core';
export interface NnunetItemsListResult {
/** Synthesized nnU-Net source files (one per series, channelIndex by series order). Empty if !ok. */
files: SourceFile[];
/** Parse/validation errors from the shared Items List parser (located for per-row display). */
errors: ImportError[];
/** True iff the Items List parsed and every entry validated cleanly. */
ok: boolean;
}
/**
* Parse + validate an Items List JSON (via the shared parser), then convert each clean task into
* nnU-Net source files. Only converts when the parse is clean, so malformed input surfaces errors
* without producing half-mapped files. `ItemsListTask` is structurally a `RedBrickEntry`.
*/
export function nnunetSourceFilesFromItemsList(
json: string,
dataType: DataType,
ending: FileEnding,
split: Split = 'train',
): NnunetItemsListResult {
const parsed = parseItemsList(json, dataType);
const files = parsed.ok ? fromRedBrickItemsList(parsed.tasks, ending, split) : [];
return { files, errors: parsed.errors, ok: parsed.ok };
}
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import {
applyDatasetKind,
initialNnunetState,
nnunetReducer,
NNUNET_STEPS,
type NnunetWizardState,
} from './wizardState';
import type { SourceFile } from './core';
const base = (): NnunetWizardState => structuredClone(initialNnunetState);
const file = (over: Partial<SourceFile> = {}): SourceFile => ({
id: Math.random().toString(36).slice(2),
filename: 'la_003_0000.nii.gz',
sizeBytes: 1,
role: 'unknown',
split: 'train',
...over,
});
describe('applyDatasetKind', () => {
it('volume3d from an RGB dataset resets to a single CT channel + a 3D ending', () => {
const rgb = applyDatasetKind(base().meta, 'rgb2d');
const vol = applyDatasetKind(rgb, 'volume3d');
expect(vol.dim).toBe('3d');
expect(vol.rgb).toBe(false);
expect(vol.fileEnding).toBe('.nii.gz');
expect(vol.channels).toEqual([{ index: 0, name: 'CT', isCT: true }]);
});
it('gray2d → one grayscale channel + .png; rgb2d → locked R,G,B + .png', () => {
const g = applyDatasetKind(base().meta, 'gray2d');
expect([g.dim, g.rgb, g.fileEnding]).toEqual(['2d', false, '.png']);
expect(g.channels).toEqual([{ index: 0, name: 'grayscale', isCT: false }]);
const r = applyDatasetKind(base().meta, 'rgb2d');
expect([r.dim, r.rgb, r.fileEnding]).toEqual(['2d', true, '.png']);
expect(r.channels.map((c) => c.name)).toEqual(['R', 'G', 'B']);
});
});
describe('nnunetReducer — steps', () => {
it('NEXT/BACK clamp to [0, last] and SET_STEP clamps', () => {
let s = base();
s = nnunetReducer(s, { type: 'BACK' });
expect(s.step).toBe(0);
for (let i = 0; i < 10; i++) s = nnunetReducer(s, { type: 'NEXT' });
expect(s.step).toBe(NNUNET_STEPS.length - 1);
expect(nnunetReducer(s, { type: 'SET_STEP', step: 99 }).step).toBe(NNUNET_STEPS.length - 1);
expect(nnunetReducer(s, { type: 'SET_STEP', step: -5 }).step).toBe(0);
});
});
describe('nnunetReducer — channels stay consecutive from 0', () => {
it('ADD then REMOVE re-indexes', () => {
let s = base();
s = nnunetReducer(s, { type: 'ADD_CHANNEL' });
s = nnunetReducer(s, { type: 'ADD_CHANNEL' });
expect(s.meta.channels.map((c) => c.index)).toEqual([0, 1, 2]);
s = nnunetReducer(s, { type: 'REMOVE_CHANNEL', index: 0 });
expect(s.meta.channels.map((c) => c.index)).toEqual([0, 1]);
});
it('MOVE swaps and re-indexes; out-of-range is a no-op', () => {
let s = base();
s = nnunetReducer(s, { type: 'UPDATE_CHANNEL', index: 0, patch: { name: 'A' } });
s = nnunetReducer(s, { type: 'ADD_CHANNEL' });
s = nnunetReducer(s, { type: 'UPDATE_CHANNEL', index: 1, patch: { name: 'B' } });
s = nnunetReducer(s, { type: 'MOVE_CHANNEL', index: 0, dir: 1 });
expect(s.meta.channels.map((c) => `${c.index}:${c.name}`)).toEqual(['0:B', '1:A']);
expect(nnunetReducer(s, { type: 'MOVE_CHANNEL', index: 0, dir: -1 })).toBe(s); // no-op returns same ref
});
});
describe('nnunetReducer — labels stay consecutive + background protected', () => {
it('REMOVE re-values; removing background (value 0) is a no-op', () => {
let s = base();
s = nnunetReducer(s, { type: 'ADD_LABEL' }); // bg0, label_1(1), ''(2)
expect(s.meta.labels.map((l) => l.value)).toEqual([0, 1, 2]);
s = nnunetReducer(s, { type: 'REMOVE_LABEL', index: 1 }); // drop label_1
expect(s.meta.labels.map((l) => l.value)).toEqual([0, 1]);
const before = s.meta.labels;
s = nnunetReducer(s, { type: 'REMOVE_LABEL', index: 0 }); // background — protected
expect(s.meta.labels.find((l) => l.value === 0)?.name).toBe('background');
expect(s.meta.labels.length).toBe(before.length);
});
});
describe('nnunetReducer — files + case split', () => {
it('SET_CASE_SPLIT matches by inferred filename caseId; REMOVE_CASE drops the case; CLEAR empties', () => {
let s = base();
s = nnunetReducer(s, { type: 'ADD_FILES', files: [file({ filename: 'la_003_0000.nii.gz' }), file({ filename: 'la_003.nii.gz' }), file({ filename: 'la_004_0000.nii.gz' })] });
expect(s.files).toHaveLength(3);
s = nnunetReducer(s, { type: 'SET_CASE_SPLIT', caseId: 'la_003', split: 'test' });
expect(s.files.filter((f) => f.filename.startsWith('la_003')).every((f) => f.split === 'test')).toBe(true);
expect(s.files.find((f) => f.filename.startsWith('la_004'))?.split).toBe('train');
s = nnunetReducer(s, { type: 'REMOVE_CASE', caseId: 'la_003' });
expect(s.files.map((f) => f.filename)).toEqual(['la_004_0000.nii.gz']);
expect(nnunetReducer(s, { type: 'CLEAR_FILES' }).files).toEqual([]);
});
it('SET_FILE_PNG attaches header info by id', () => {
let s = base();
const f = file();
s = nnunetReducer(s, { type: 'ADD_FILES', files: [f] });
s = nnunetReducer(s, { type: 'SET_FILE_PNG', id: f.id, png: { width: 256, height: 256, colorType: 0 } });
expect(s.files[0].png).toEqual({ width: 256, height: 256, colorType: 0 });
});
});
@@ -0,0 +1,181 @@
// Data-import DOMAIN — nnU-Net wizard state (pure reducer). Mirrors the feature's other FSMs:
// a pure (state, action) -> state function, illegal transitions are no-ops. The component holds
// this via useReducer and computes the derived views (groupFilesIntoCases / validateDataset /
// generateDatasetJson / planFolderTree from core.ts) with useMemo — they are NOT stored here.
import {
endingsForDim,
parseFilename,
type Channel,
type DatasetMeta,
type FileEnding,
type Label,
type SourceFile,
type Split,
} from './core';
export const NNUNET_STEPS = ['dataset', 'channels', 'labels', 'cases', 'export'] as const;
export type NnunetStepKey = (typeof NNUNET_STEPS)[number];
/** The three dataset shapes the wizard offers (drives dim/format/how channels map to files). */
export type DatasetKind = 'volume3d' | 'gray2d' | 'rgb2d';
export interface NnunetWizardState {
/** 0..NNUNET_STEPS.length-1 */
step: number;
meta: DatasetMeta;
/** Staged source files; role/caseId/channelIndex are inferred by core.groupFilesIntoCases. */
files: SourceFile[];
}
export type NnunetAction =
| { type: 'SET_STEP'; step: number }
| { type: 'NEXT' }
| { type: 'BACK' }
| { type: 'SET_DATASET_KIND'; kind: DatasetKind }
| { type: 'PATCH_META'; patch: Partial<Pick<DatasetMeta, 'id' | 'name' | 'fileEnding'>> }
| { type: 'ADD_CHANNEL' }
| { type: 'UPDATE_CHANNEL'; index: number; patch: Partial<Channel> }
| { type: 'REMOVE_CHANNEL'; index: number }
| { type: 'MOVE_CHANNEL'; index: number; dir: -1 | 1 }
| { type: 'ADD_LABEL' }
| { type: 'UPDATE_LABEL'; index: number; patch: Partial<Label> }
| { type: 'REMOVE_LABEL'; index: number }
| { type: 'ADD_FILES'; files: SourceFile[] }
| { type: 'SET_FILE_PNG'; id: string; png: NonNullable<SourceFile['png']> }
| { type: 'SET_CASE_SPLIT'; caseId: string; split: Split }
| { type: 'REMOVE_CASE'; caseId: string }
| { type: 'CLEAR_FILES' };
export const initialNnunetState: NnunetWizardState = {
step: 0,
meta: {
id: 100,
name: '',
fileEnding: '.nii.gz',
dim: '3d',
rgb: false,
channels: [{ index: 0, name: 'CT', isCT: true }],
labels: [
{ name: 'background', value: 0 },
{ name: 'label_1', value: 1 },
],
},
files: [],
};
/** Re-index channels to 0..n-1 in array order (nnU-Net requires consecutive indices from 0). */
const reindexChannels = (channels: Channel[]): Channel[] => channels.map((c, index) => ({ ...c, index }));
/** Re-value labels to 0..n-1 in array order (background must stay 0, values consecutive). */
const revalueLabels = (labels: Label[]): Label[] => labels.map((l, value) => ({ ...l, value }));
/** Apply a dataset-kind preset: sets dim/rgb/fileEnding and a sensible default channel set. */
export function applyDatasetKind(meta: DatasetMeta, kind: DatasetKind): DatasetMeta {
if (kind === 'volume3d') {
const wasRgb = meta.channels.map((c) => c.name.toUpperCase()).join(',') === 'R,G,B';
const keepEnding = (['.nii.gz', '.nrrd', '.mha'] as FileEnding[]).includes(meta.fileEnding);
return {
...meta,
dim: '3d',
rgb: false,
fileEnding: keepEnding ? meta.fileEnding : '.nii.gz',
channels: wasRgb ? [{ index: 0, name: 'CT', isCT: true }] : meta.channels,
};
}
if (kind === 'gray2d') {
return { ...meta, dim: '2d', rgb: false, fileEnding: '.png', channels: [{ index: 0, name: 'grayscale', isCT: false }] };
}
// rgb2d
return {
...meta,
dim: '2d',
rgb: true,
fileEnding: '.png',
channels: [
{ index: 0, name: 'R', isCT: false },
{ index: 1, name: 'G', isCT: false },
{ index: 2, name: 'B', isCT: false },
],
};
}
/** The case id a staged file belongs to (explicit, else inferred from its nnU-Net filename). */
const caseIdOf = (f: SourceFile, ending: FileEnding): string | undefined =>
f.caseId ?? parseFilename(f.filename, ending)?.caseId;
export function nnunetReducer(state: NnunetWizardState, action: NnunetAction): NnunetWizardState {
switch (action.type) {
case 'SET_STEP':
return { ...state, step: clampStep(action.step) };
case 'NEXT':
return { ...state, step: clampStep(state.step + 1) };
case 'BACK':
return { ...state, step: clampStep(state.step - 1) };
case 'SET_DATASET_KIND':
return { ...state, meta: applyDatasetKind(state.meta, action.kind) };
case 'PATCH_META':
return { ...state, meta: { ...state.meta, ...action.patch } };
case 'ADD_CHANNEL':
return {
...state,
meta: { ...state.meta, channels: [...state.meta.channels, { index: state.meta.channels.length, name: '', isCT: false }] },
};
case 'UPDATE_CHANNEL':
return {
...state,
meta: { ...state.meta, channels: state.meta.channels.map((c, i) => (i === action.index ? { ...c, ...action.patch } : c)) },
};
case 'REMOVE_CHANNEL':
return {
...state,
meta: { ...state.meta, channels: reindexChannels(state.meta.channels.filter((_, i) => i !== action.index)) },
};
case 'MOVE_CHANNEL': {
const j = action.index + action.dir;
if (j < 0 || j >= state.meta.channels.length) return state;
const channels = [...state.meta.channels];
[channels[action.index], channels[j]] = [channels[j], channels[action.index]];
return { ...state, meta: { ...state.meta, channels: reindexChannels(channels) } };
}
case 'ADD_LABEL':
return { ...state, meta: { ...state.meta, labels: [...state.meta.labels, { name: '', value: state.meta.labels.length }] } };
case 'UPDATE_LABEL':
return {
...state,
meta: { ...state.meta, labels: state.meta.labels.map((l, i) => (i === action.index ? { ...l, ...action.patch } : l)) },
};
case 'REMOVE_LABEL':
// Never remove background (value 0); always re-value the rest consecutively.
return {
...state,
meta: { ...state.meta, labels: revalueLabels(state.meta.labels.filter((l, i) => i !== action.index || l.value === 0)) },
};
case 'ADD_FILES':
return { ...state, files: [...state.files, ...action.files] };
case 'SET_FILE_PNG':
return { ...state, files: state.files.map((f) => (f.id === action.id ? { ...f, png: action.png } : f)) };
case 'SET_CASE_SPLIT':
return {
...state,
files: state.files.map((f) => (caseIdOf(f, state.meta.fileEnding) === action.caseId ? { ...f, split: action.split } : f)),
};
case 'REMOVE_CASE':
return { ...state, files: state.files.filter((f) => caseIdOf(f, state.meta.fileEnding) !== action.caseId) };
case 'CLEAR_FILES':
return { ...state, files: [] };
default:
return state;
}
}
function clampStep(n: number): number {
return Math.max(0, Math.min(NNUNET_STEPS.length - 1, n));
}
/** Accepted file endings for the current dim — for the format <select> in the Dataset step. */
export const endingsForState = (state: NnunetWizardState): FileEnding[] => endingsForDim(state.meta.dim);
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { samplePathReducer } from './reducer';
import { initialSamplePathMachine } from './states';
describe('samplePathReducer §6', () => {
it('V1: a full URL fails immediately, without entering Verifying', () => {
const m = samplePathReducer(initialSamplePathMachine, { type: 'PASTE_PATH', path: 'https://x/y.dcm' });
expect(m.state).toBe('VerifyFailed');
expect(m.context.error?.code).toBe('PATH_NOT_BUCKET_RELATIVE');
});
it('a bucket-relative path enters Verifying, then VERIFY_OK -> Verified with a preview', () => {
let m = samplePathReducer(initialSamplePathMachine, { type: 'PASTE_PATH', path: 'bucket/a.dcm' });
expect(m.state).toBe('Verifying');
m = samplePathReducer(m, { type: 'VERIFY_OK', preview: { note: 'ok' } });
expect(m.state).toBe('Verified');
expect(m.context.preview?.note).toBe('ok');
});
it('VERIFY_FAIL -> VerifyFailed; RESET -> Idle', () => {
let m = samplePathReducer(initialSamplePathMachine, { type: 'PASTE_PATH', path: 'bucket/a.dcm' });
m = samplePathReducer(m, { type: 'VERIFY_FAIL', error: { code: 'PATH_NOT_BUCKET_RELATIVE', message: 'denied' } });
expect(m.state).toBe('VerifyFailed');
m = samplePathReducer(m, { type: 'RESET' });
expect(m.state).toBe('Idle');
});
});
@@ -0,0 +1,29 @@
// Data-import DOMAIN — Sample-Path reducer (pure). Source: data-import-spec.md §6 (V1/V2).
import { validateBucketRelativePath } from '../bucketPath';
import { initialSamplePathMachine, type SamplePathEvent, type SamplePathMachine } from './states';
export function samplePathReducer(m: SamplePathMachine, e: SamplePathEvent): SamplePathMachine {
switch (e.type) {
case 'PASTE_PATH': {
// V1 — reject a non-bucket-relative path up front, before any network round-trip.
const v1 = validateBucketRelativePath(e.path);
if (v1) return { state: 'VerifyFailed', context: { path: e.path, preview: null, error: v1 } };
return { state: 'Verifying', context: { path: e.path, preview: null, error: null } };
}
case 'VERIFY_OK':
if (m.state !== 'Verifying') return m;
return { state: 'Verified', context: { ...m.context, preview: e.preview, error: null } };
case 'VERIFY_FAIL':
if (m.state !== 'Verifying') return m;
return { state: 'VerifyFailed', context: { ...m.context, preview: null, error: e.error } };
case 'RESET':
return initialSamplePathMachine;
default:
return m;
}
}
@@ -0,0 +1,34 @@
// Data-import DOMAIN — Sample-Path Verification state machine (§6). Pure.
// Idle -> Verifying -> (Verified preview | VerifyFailed). V1 (bucket-relative input) is checked
// synchronously in the reducer; V2 (does it resolve? bad-path vs permission/CORS) is the async
// gateway result fed back via VERIFY_OK / VERIFY_FAIL.
import type { ImportError } from '../types';
export type SamplePathState = 'Idle' | 'Verifying' | 'Verified' | 'VerifyFailed';
/** A successful resolution preview (V2) — an asset URL and/or a human note. */
export interface SamplePathPreview {
assetUrl?: string;
note?: string;
}
export interface SamplePathContext {
path: string;
preview: SamplePathPreview | null;
error: ImportError | null;
}
export type SamplePathEvent =
| { type: 'PASTE_PATH'; path: string }
| { type: 'VERIFY_OK'; preview: SamplePathPreview }
| { type: 'VERIFY_FAIL'; error: ImportError }
| { type: 'RESET' };
export interface SamplePathMachine {
state: SamplePathState;
context: SamplePathContext;
}
export const initialSamplePathContext: SamplePathContext = { path: '', preview: null, error: null };
export const initialSamplePathMachine: SamplePathMachine = { state: 'Idle', context: initialSamplePathContext };
@@ -0,0 +1,62 @@
// Data-import DOMAIN — shared primitive value types used across the mapping engine, the
// validators, the Items List parser, and the state machines. Pure (framework-free).
/**
* A file staged for a Direct Upload, reduced to the only attributes the domain reasons about.
* `relativePath` is the path *within the selected folder/drop* (e.g. `study1/series1/img001.dcm`)
* — the presentation layer maps a browser `File` (its `webkitRelativePath` or bare `name`) into
* this shape. Keeping the engine on a plain interface (not `File`) makes §5 trivially unit-testable
* with zero `File` construction.
*/
export interface StagedFile {
/** Bare file name including extension, e.g. `img001.dcm`. */
name: string;
/** Path relative to the dropped root; equals `name` when no folder structure is present. */
relativePath: string;
/** Size in bytes (used for progress/limits; not for mapping decisions). */
size: number;
/**
* The underlying browser File, when staged from a Direct Upload picker. Optional and IGNORED by
* the mapping engine + validators (they reason only on name/relativePath/size) — it rides along
* so the presentation/infrastructure layers can stream the actual bytes. Domain tests omit it.
*/
file?: File;
}
/**
* A structured, per-item domain error. `code` is stable/machine-readable (for tests + i18n);
* `message` is a human (Vietnamese) string. `entry`/`file` locate the offending Items List task
* or staged file so the UI can report errors per-row (spec rule P2).
*/
export interface ImportError {
code: ImportErrorCode;
message: string;
/** Items List task name this error belongs to, if any. */
entry?: string;
/** Staged file / item path this error belongs to, if any. */
file?: string;
}
export type ImportErrorCode =
| 'MIXED_TYPES' // D1
| 'GROUP_BY_STUDY_UNSUPPORTED' // D2 / MAP3
| 'UNSUPPORTED_FORMAT' // D3 / C5
| 'INVALID_STRUCTURE' // D4
| 'MHD_COMPANION_MISSING' // D6
| 'PATH_NOT_BUCKET_RELATIVE' // C2 / IL6 / V1
| 'ITEMS_LIST_MALFORMED' // P1
| 'ITEMS_LIST_NOT_ARRAY' // P1
| 'ENTRY_NAME_MISSING' // IL5
| 'ENTRY_NAME_DUPLICATE' // IL5
| 'ENTRY_ITEMS_SHAPE' // IL3 (NIfTI single string) / IL4 (frames ordered array)
| 'ENTRY_EMPTY' // a task with no resolvable items
| 'GATEWAY_ERROR'; // a network/server failure surfaced from an infrastructure adapter
/** Construct an ImportError tersely. */
export function importError(
code: ImportErrorCode,
message: string,
loc?: { entry?: string; file?: string },
): ImportError {
return { code, message, ...loc };
}
@@ -0,0 +1,22 @@
// Data-import INFRASTRUCTURE — the only layer that touches @ump/shared/apiClient. Implements the
// DirectUploadGateway port by CONVERGING the spec's Direct Upload onto the existing content-addressed
// dataset upload (be0 `POST /api/v1/datasets/{id}/files` via shared `uploadDatasetFiles`). A second
// pipeline would split-brain the single dataset-file list; see the design notes.
import { uploadDatasetFiles } from '@ump/shared';
import type { DirectUploadGateway } from '../application/ports';
export const httpDirectUploadGateway: DirectUploadGateway = {
async upload({ datasetId, files, onProgress }) {
// The shared apiClient doesn't thread axios onUploadProgress yet, so report coarse begin/end.
onProgress?.(5);
const res = await uploadDatasetFiles(datasetId, files);
onProgress?.(100);
const dedupedCount = res.files.filter((f) => f.deduped).length;
return {
uploadedCount: res.files.length,
dedupedCount,
paths: res.files.map((f) => f.path),
};
},
};
@@ -0,0 +1,17 @@
// Data-import PRESENTATION — D5 privacy notice: Direct Upload hosts data on our servers; point the
// user to Cloud Import (referenced, not copied) as the private alternative.
import { ShieldAlert } from 'lucide-react';
export function PrivacyNotice() {
return (
<div className="flex gap-2 rounded-md border border-amber-300/70 bg-amber-50 p-3 text-sm text-amber-900">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
Dữ liệu tải lên trực tiếp đưc <strong>lưu trữ trên máy chủ</strong> của hệ thống. Với dữ liệu
riêng hoặc nhạy cảm, hãy cân nhắc <strong>Nhập từ kho đám mây</strong> dữ liệu khi đó chỉ đưc
tham chiếu, không sao chép.
</p>
</div>
);
}
@@ -0,0 +1,257 @@
// Data-import PRESENTATION — §7.3 UploadDataDialog. A multi-step Direct Upload wizard hosted in a
// shared Dialog (mirrors SegmentationUploadDialog). Steps are driven entirely by the §3 machine
// state; the §5 task preview + validation errors come straight from the domain. Side effects live
// in the use-case behind useDirectUpload.
import { useEffect, useRef, type ChangeEvent, type ReactNode } from 'react';
import { AlertCircle, CheckCircle2, FileImage, Loader2, Upload, FolderUp } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
Button,
Label,
Checkbox,
} from '@ump/shared';
import { DATA_TYPES, DATA_TYPE_LABELS_VI, supportsGroupByStudy } from '../../domain';
import { useDirectUpload } from '../hooks/useDirectUpload';
import { PrivacyNotice } from './PrivacyNotice';
interface UploadDataDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
datasetId: string;
/** Called once tasks are created so the caller can refetch the dataset file list. */
onCompleted: () => void;
}
export function UploadDataDialog({ open, onOpenChange, datasetId, onCompleted }: UploadDataDialogProps) {
const u = useDirectUpload(datasetId);
const { machine, taskPreview } = u;
const { state, context } = machine;
const fileInput = useRef<HTMLInputElement>(null);
const dirInput = useRef<HTMLInputElement>(null);
const busy = state === 'Validating' || state === 'Uploading' || state === 'Processing';
const editing = state === 'SelectingType' || state === 'ConfiguringStructure' || state === 'Staging';
// A directory picker needs the non-standard webkitdirectory attribute, set imperatively.
useEffect(() => {
if (dirInput.current) dirInput.current.setAttribute('webkitdirectory', '');
}, []);
// Refetch the dataset's files once tasks are created.
useEffect(() => {
if (state === 'Complete') onCompleted();
}, [state, onCompleted]);
const onPick = (e: ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
if (picked.length) u.addFiles(picked);
e.target.value = '';
};
return (
<Dialog
open={open}
onOpenChange={(o) => {
if (!busy) onOpenChange(o);
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Nhập dữ liệu Tải lên trực tiếp</DialogTitle>
<DialogDescription>
Chọn loại dữ liệu, thêm tệp (hoặc cả thư mục), rồi tải lên. Hệ thống nhóm tệp thành các tác vụ
(task) theo loại.
</DialogDescription>
</DialogHeader>
{/* ---- Editing: type selection + structure + staging ---- */}
{editing && (
<div className="space-y-4">
{/* Type chips (D1 — one type; changing it clears staged files). */}
<div>
<Label className="text-xs text-muted-foreground">Loại dữ liệu</Label>
<div className="mt-1 flex flex-wrap gap-2">
{DATA_TYPES.map((t) => {
const active = context.dataType === t;
return (
<button
key={t}
type="button"
onClick={() => u.selectType(t)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
active
? 'border-primary bg-primary text-primary-foreground'
: 'hover:border-primary hover:bg-accent/30'
}`}
>
{DATA_TYPE_LABELS_VI[t]}
</button>
);
})}
</div>
</div>
{context.dataType === null ? (
<p className="text-sm text-muted-foreground">Chọn một loại dữ liệu đ bắt đu.</p>
) : (
<>
{/* D2 — Group by Study only for DICOM/NIfTI. */}
{supportsGroupByStudy(context.dataType) && (
<div className="flex items-center gap-2">
<Checkbox
id="gbs"
checked={context.groupByStudy}
onCheckedChange={() => u.toggleGroupByStudy()}
/>
<Label htmlFor="gbs" className="text-sm font-normal">
Nhóm theo nghiên cứu (Group by Study) gộp các tệp cùng thư mục thành một tác vụ.
</Label>
</div>
)}
<PrivacyNotice />
{/* File / folder pickers. */}
<div className="flex flex-wrap gap-2">
<input ref={fileInput} type="file" multiple className="hidden" onChange={onPick} />
<input ref={dirInput} type="file" multiple className="hidden" onChange={onPick} />
<Button variant="outline" size="sm" onClick={() => fileInput.current?.click()}>
<Upload className="mr-2 h-4 w-4" /> Chọn tệp
</Button>
<Button variant="outline" size="sm" onClick={() => dirInput.current?.click()}>
<FolderUp className="mr-2 h-4 w-4" /> Chọn thư mục
</Button>
{context.files.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => u.clearFiles()}>
Xóa hết
</Button>
)}
</div>
{/* Validation errors (after a failed VALIDATE). */}
{context.errors.length > 0 && (
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
<p className="flex items-center gap-1 font-medium">
<AlertCircle className="h-4 w-4" /> {context.errors.length} lỗi cần sửa:
</p>
<ul className="list-disc pl-5">
{context.errors.slice(0, 8).map((e, i) => (
<li key={i}>{e.message}</li>
))}
</ul>
</div>
)}
{/* §5 task preview. */}
{context.files.length > 0 && (
<div className="rounded-md border p-3 text-sm">
<p className="mb-2 text-muted-foreground">
<strong className="text-foreground">{context.files.length}</strong> tệp {' '}
<strong className="text-foreground">{taskPreview.length}</strong> tác vụ
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{taskPreview.slice(0, 12).map((t) => (
<div key={`${t.grouping}-${t.name}`} className="flex items-center gap-2 text-xs">
<FileImage className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="font-mono">{t.name}</span>
<span className="text-muted-foreground">
({t.files.length} tệp · {t.grouping})
</span>
</div>
))}
{taskPreview.length > 12 && (
<p className="text-xs text-muted-foreground"> {taskPreview.length - 12} tác vụ khác</p>
)}
</div>
</div>
)}
</>
)}
</div>
)}
{/* ---- Busy + terminal states ---- */}
{state === 'Validating' && <Centered icon="spin">Đang kiểm tra tệp</Centered>}
{state === 'Uploading' && (
<div className="space-y-2 py-4">
<p className="text-sm text-muted-foreground">Đang tải lên máy chủ</p>
<div className="h-2 w-full overflow-hidden rounded bg-muted">
<div
className="h-2 rounded bg-primary transition-[width] duration-300"
style={{ width: `${context.progress}%` }}
/>
</div>
</div>
)}
{state === 'Processing' && <Centered icon="spin">Đang xử trên máy chủ</Centered>}
{state === 'Complete' && (
<Centered icon="ok">
Đã tạo {context.createdTaskIds.length} tệp/tác vụ trong bộ dữ liệu.
</Centered>
)}
{(state === 'UploadError' || state === 'PartialFailure') && (
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
<p className="flex items-center gap-1 font-medium">
<AlertCircle className="h-4 w-4" />{' '}
{state === 'UploadError' ? 'Tải lên thất bại.' : 'Một số tác vụ chưa hoàn tất.'}
</p>
{context.errors.slice(0, 5).map((e, i) => (
<p key={i}>{e.message}</p>
))}
</div>
)}
<DialogFooter>
{editing && (
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Hủy
</Button>
<Button
onClick={() => void u.start()}
disabled={state === 'SelectingType' || context.files.length === 0}
>
<Upload className="mr-2 h-4 w-4" /> Tải lên {context.files.length || ''}
</Button>
</>
)}
{busy && (
<Button disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Đang xử
</Button>
)}
{state === 'Complete' && <Button onClick={() => onOpenChange(false)}>Đóng</Button>}
{(state === 'UploadError' || state === 'PartialFailure') && (
<>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Hủy
</Button>
<Button onClick={() => void u.start()}>Thử lại</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Centered({ icon, children }: { icon: 'spin' | 'ok'; children: ReactNode }) {
return (
<div className="flex flex-col items-center gap-2 py-8 text-center text-sm">
{icon === 'spin' ? (
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
) : (
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
)}
<p>{children}</p>
</div>
);
}
export default UploadDataDialog;
@@ -0,0 +1,67 @@
// Data-import PRESENTATION — the React bridge for Direct Upload. Wires useReducer(directReducer)
// to the application use-case + the infrastructure gateway, exposing a small action API + a live
// §5 task preview. This is the only place the machine, the use-case, and the gateway meet.
import { useCallback, useMemo, useReducer } from 'react';
import {
directReducer,
initialDirectMachine,
mapFilesToTasks,
type DataType,
type DirectMachine,
type StagedFile,
type TaskPlan,
} from '../../domain';
import { runDirectUpload } from '../../application/runDirectUpload';
import { httpDirectUploadGateway } from '../../infrastructure/httpDirectUploadGateway';
/** Map a browser File to the domain StagedFile, preserving folder structure via webkitRelativePath. */
function toStagedFile(file: File): StagedFile {
const rel = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
return { name: file.name, relativePath: rel, size: file.size, file };
}
export interface UseDirectUpload {
machine: DirectMachine;
/** Live §5 mapping preview of the staged files (drives the "N files → M tasks" UI). */
taskPreview: TaskPlan[];
selectType: (t: DataType) => void;
toggleGroupByStudy: () => void;
addFiles: (files: File[]) => void;
clearFiles: () => void;
/** Begin (from Staging) or retry (from an error state) the upload. */
start: () => Promise<void>;
}
export function useDirectUpload(datasetId: string): UseDirectUpload {
const [machine, dispatch] = useReducer(directReducer, initialDirectMachine);
const selectType = useCallback((t: DataType) => dispatch({ type: 'SELECT_TYPE', dataType: t }), []);
const toggleGroupByStudy = useCallback(() => dispatch({ type: 'TOGGLE_GROUP_BY_STUDY' }), []);
const addFiles = useCallback((files: File[]) => dispatch({ type: 'ADD_FILES', files: files.map(toStagedFile) }), []);
const clearFiles = useCallback(() => dispatch({ type: 'CLEAR_FILES' }), []);
const start = useCallback(async () => {
const s = machine.state;
if (s === 'Staging') dispatch({ type: 'START_UPLOAD' });
else if (s === 'UploadError' || s === 'PartialFailure') dispatch({ type: 'RETRY' });
else return;
await runDirectUpload({
gateway: httpDirectUploadGateway,
datasetId,
context: machine.context,
files: machine.context.files.map((f) => f.file).filter((f): f is File => Boolean(f)),
dispatch,
});
}, [datasetId, machine.state, machine.context]);
const taskPreview = useMemo(
() =>
machine.context.dataType
? mapFilesToTasks(machine.context.dataType, machine.context.groupByStudy, machine.context.files)
: [],
[machine.context.dataType, machine.context.groupByStudy, machine.context.files],
);
return { machine, taskPreview, selectType, toggleGroupByStudy, addFiles, clearFiles, start };
}
@@ -0,0 +1,267 @@
import { useCallback, useMemo, useReducer } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
CheckCircle2,
Database,
FileCheck2,
FolderTree,
Layers,
Tag,
XCircle,
} from 'lucide-react';
import { Button, cn, getDataset } from '@ump/shared';
import {
datasetFolderName,
generateDatasetJson,
groupFilesIntoCases,
pad4,
parsePngHeader,
planFolderTree,
validateDataset,
type SourceFile,
type Split,
} from '../../domain/nnunet/core';
import { initialNnunetState, NNUNET_STEPS, nnunetReducer } from '../../domain/nnunet/wizardState';
import { nnunetSourceFilesFromItemsList } from '../../domain/nnunet/fromItemsList';
import type { DataType } from '../../domain/dataTypes';
import { ChannelsStep, DatasetStep, LabelsStep } from './steps';
import { CasesStep, ExportStep } from './dataSteps';
import { OutputPane } from './OutputPane';
/** Synthesize a small demo dataset for the "Load example" button (channels × a few cases + a test case). */
function exampleFiles(meta: { fileEnding: string; rgb: boolean; dim: '2d' | '3d'; channels: { index: number }[] }): SourceFile[] {
const e = meta.fileEnding;
const out: SourceFile[] = [];
const mk = (filename: string, split: Split): SourceFile => ({
id: Math.random().toString(36).slice(2),
filename,
sizeBytes: (1024 * 1024 * (0.2 + Math.random() * 8)) | 0,
role: 'unknown',
split,
});
const chans = meta.rgb ? [0] : meta.channels.map((c) => c.index);
const prefix = meta.rgb ? 'img' : meta.dim === '2d' ? 'scan' : 'case';
['00', '01', '02'].forEach((n) => {
chans.forEach((ci) => out.push(mk(`${prefix}_${n}_${pad4(ci)}${e}`, 'train')));
out.push(mk(`${prefix}_${n}${e}`, 'train'));
});
chans.forEach((ci) => out.push(mk(`${prefix}_50_${pad4(ci)}${e}`, 'test')));
return out;
}
const STEP_META = [
{ label: 'Bộ dữ liệu', icon: Database },
{ label: 'Kênh', icon: Layers },
{ label: 'Nhãn', icon: Tag },
{ label: 'Các case', icon: FolderTree },
{ label: 'Kiểm tra & xuất', icon: FileCheck2 },
] as const;
/** nnU-Net v2 dataset-organizer wizard for a dataset — the new upload mode (project-workflow §data-import). */
export function NnunetOrganizerPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const [state, dispatch] = useReducer(nnunetReducer, initialNnunetState);
const { meta, files, step } = state;
const dsQ = useQuery({ queryKey: ['imagehub', 'dataset', id], queryFn: () => getDataset(id), enabled: !!id });
// Pure derivations (core.ts) — recomputed from {meta, files}; never stored in reducer state.
const { cases, unassigned } = useMemo(() => groupFilesIntoCases(files, meta), [files, meta]);
const report = useMemo(() => validateDataset(meta, cases), [meta, cases]);
const datasetJson = useMemo(() => generateDatasetJson(meta, cases), [meta, cases]);
const plan = useMemo(() => planFolderTree(meta, cases), [meta, cases]);
const trainCount = cases.filter((c) => c.split === 'train').length;
const testCount = cases.filter((c) => c.split === 'test').length;
// Browser-coupled handlers (File objects, clipboard) the pure reducer can't own.
const addFiles = useCallback((fileList: FileList | File[]) => {
const next: SourceFile[] = Array.from(fileList).map((f) => ({
id: Math.random().toString(36).slice(2),
filename: f.name,
sizeBytes: f.size ?? 0,
role: 'unknown',
split: 'train' as Split,
blob: f,
}));
dispatch({ type: 'ADD_FILES', files: next });
// Sniff PNG geometry so validateDataset can check dimensions/colour-type.
next.forEach((sf) => {
if (/\.png$/i.test(sf.filename) && sf.blob) {
sf.blob
.slice(0, 33)
.arrayBuffer()
.then((buf) => {
const info = parsePngHeader(new Uint8Array(buf));
if (info) dispatch({ type: 'SET_FILE_PNG', id: sf.id, png: info });
})
.catch(() => {});
}
});
}, []);
const importItemsList = useCallback(
(json: string): string[] => {
const dataType: DataType = meta.dim === '3d' ? 'nifti' : 'image2d';
const r = nnunetSourceFilesFromItemsList(json, dataType, meta.fileEnding);
if (r.ok) {
dispatch({ type: 'ADD_FILES', files: r.files });
return [];
}
return r.errors.map((e) => e.message);
},
[meta.dim, meta.fileEnding],
);
const loadExample = useCallback(() => {
dispatch({ type: 'CLEAR_FILES' });
dispatch({ type: 'ADD_FILES', files: exampleFiles(meta) });
}, [meta]);
const folderName = datasetFolderName(meta);
const isLast = step === NNUNET_STEPS.length - 1;
return (
<div className="mx-auto max-w-[1400px] space-y-5">
<Button variant="ghost" size="sm" onClick={() => navigate(`/dashboard/datasets/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> {dsQ.data?.name ?? 'Bộ dữ liệu'}
</Button>
{/* Header: title + resolved folder + validation status */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-serif text-2xl font-semibold text-foreground">Tổ chức dữ liệu</h1>
<p className="text-sm text-muted-foreground">Sắp xếp dữ liệu hình nh y khoa theo cấu trúc thư mục chuẩn.</p>
</div>
<div className="flex items-center gap-4">
<span className="rounded-md bg-muted px-2.5 py-1 font-mono text-sm text-foreground">{folderName}</span>
<span className="flex items-center gap-1.5 text-sm font-medium">
{report.errorCount ? (
<>
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-destructive">{report.errorCount} lỗi</span>
</>
) : report.warningCount ? (
<>
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span className="text-amber-600">{report.warningCount} cảnh báo</span>
</>
) : (
<>
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
<span className="text-emerald-600">Hợp lệ</span>
</>
)}
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[180px_1fr_minmax(280px,360px)]">
{/* Step nav */}
<nav className="flex gap-1 overflow-x-auto lg:flex-col">
{STEP_META.map((s, i) => {
const Icon = s.icon;
const active = i === step;
const done = i < step;
return (
<button
key={s.label}
type="button"
onClick={() => dispatch({ type: 'SET_STEP', step: i })}
className={cn(
'flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2.5 text-left text-sm transition',
active ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
<span
className={cn(
'grid h-5 w-5 shrink-0 place-items-center rounded-full font-mono text-[11px]',
active ? 'bg-primary-foreground/20' : done ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground',
)}
>
{done ? <CheckCircle2 className="h-3.5 w-3.5" /> : i + 1}
</span>
<Icon className="h-4 w-4 shrink-0" />
<span className="font-medium">{s.label}</span>
</button>
);
})}
</nav>
{/* Step body + footer */}
<main className="flex min-h-[460px] flex-col rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex-1">
{step === 0 && <DatasetStep state={state} dispatch={dispatch} />}
{step === 1 && <ChannelsStep state={state} dispatch={dispatch} />}
{step === 2 && <LabelsStep state={state} dispatch={dispatch} />}
{step === 3 && (
<CasesStep
datasetId={id}
state={state}
dispatch={dispatch}
cases={cases}
unassigned={unassigned}
report={report}
onAddFiles={addFiles}
onImportItemsList={importItemsList}
onLoadExample={loadExample}
/>
)}
{step === 4 && <ExportStep meta={meta} report={report} datasetJson={datasetJson} plan={plan} folderName={folderName} />}
</div>
<div className="mt-4 flex items-center justify-between border-t border-border pt-4">
<Button variant="ghost" size="sm" disabled={step === 0} onClick={() => dispatch({ type: 'BACK' })}>
<ArrowLeft className="mr-1.5 h-4 w-4" /> Quay lại
</Button>
{!isLast ? (
<Button size="sm" onClick={() => dispatch({ type: 'NEXT' })}>
Tiếp <ArrowRight className="ml-1.5 h-4 w-4" />
</Button>
) : (
<span
className={cn(
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium',
report.ok ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700',
)}
>
{report.ok ? (
<>
<CheckCircle2 className="h-4 w-4" /> Sẵn sàng xuất
</>
) : (
<>
<AlertTriangle className="h-4 w-4" /> Sửa lỗi đ xuất
</>
)}
</span>
)}
</div>
</main>
<OutputPane
folderName={folderName}
plan={plan}
datasetJson={datasetJson}
trainCount={trainCount}
testCount={testCount}
channels={meta.channels.length}
labels={meta.labels.length}
dim={meta.dim}
rgb={meta.rgb}
/>
</div>
{unassigned.length > 0 && step !== 3 && (
<p className="text-xs text-amber-600">
{unassigned.length} tệp chưa gom đưc vào case xem bước Các case.
</p>
)}
</div>
);
}
export default NnunetOrganizerPage;
@@ -0,0 +1,112 @@
import { useMemo, useState } from 'react';
import { FileJson, FolderTree } from 'lucide-react';
import { cn } from '@ump/shared';
import type { PlannedFile } from '../../domain/nnunet/core';
interface TreeNode {
name: string;
children: Map<string, TreeNode>;
file: boolean;
}
/** Live right-rail: the resolved nnU-Net folder tree + the generated dataset.json. */
export function OutputPane({
folderName,
plan,
datasetJson,
trainCount,
testCount,
channels,
labels,
dim,
rgb,
}: {
folderName: string;
plan: PlannedFile[];
datasetJson: Record<string, unknown>;
trainCount: number;
testCount: number;
channels: number;
labels: number;
dim: '2d' | '3d';
rgb: boolean;
}) {
const [tab, setTab] = useState<'tree' | 'json'>('tree');
const jsonText = JSON.stringify(datasetJson, null, 2);
const tree = useMemo<TreeNode>(() => {
const root: TreeNode = { name: `${folderName}/`, children: new Map(), file: false };
const add = (path: string) => {
const parts = path.split('/').slice(1);
let node = root;
parts.forEach((p, i) => {
const isFile = i === parts.length - 1;
if (!node.children.has(p)) node.children.set(p, { name: p, children: new Map(), file: isFile });
node = node.children.get(p)!;
});
};
add(`${folderName}/dataset.json`);
plan.forEach((p) => add(p.targetPath));
return root;
}, [folderName, plan]);
const tabs: Array<['tree' | 'json', string, typeof FolderTree]> = [
['tree', 'Thư mục', FolderTree],
['json', 'dataset.json', FileJson],
];
return (
<aside className="flex h-fit flex-col self-start overflow-hidden rounded-xl border border-border bg-card lg:sticky lg:top-4">
<div className="flex items-center gap-1 border-b border-border px-2 pt-2">
{tabs.map(([k, label, Icon]) => (
<button
key={k}
type="button"
onClick={() => setTab(k)}
className={cn(
'flex items-center gap-1.5 rounded-t-lg px-3 py-1.5 text-xs font-medium transition',
tab === k ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="h-3.5 w-3.5" /> {label}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 bg-muted/40 px-3 py-1.5 font-mono text-[11px] text-muted-foreground">
<span className="text-accent">{dim}{rgb ? ' rgb' : ''}</span>
<span>{channels} kênh</span>
<span>{labels} nhãn</span>
<span className="text-primary">{trainCount} Tr</span>
<span>{testCount} Ts</span>
</div>
<div className="max-h-[60vh] overflow-auto p-3 text-[12px] leading-relaxed">
{tab === 'tree' ? (
<TreeView node={tree} depth={0} />
) : (
<pre className="whitespace-pre font-mono text-foreground">{jsonText}</pre>
)}
</div>
</aside>
);
}
function TreeView({ node, depth }: { node: TreeNode; depth: number }) {
const children = [...node.children.values()].sort(
(a, b) => Number(a.file) - Number(b.file) || a.name.localeCompare(b.name),
);
return (
<div>
<div className="whitespace-pre font-mono" style={{ paddingLeft: depth * 12 }}>
<span className={node.file ? 'text-muted-foreground' : 'text-primary'}>
{node.file ? '' : '▸ '}
{node.name}
</span>
</div>
{children.map((c, i) => (
<TreeView key={i} node={c} depth={depth + 1} />
))}
</div>
);
}
export default OutputPane;
@@ -0,0 +1,246 @@
import { type Dispatch, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle,
CheckCircle2,
Copy,
Download,
FolderTree,
XCircle,
} from 'lucide-react';
import {
Button,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
cn,
listDatasetFiles,
} from '@ump/shared';
import { FolderUploadButtons } from '../../../dataset-workspace/presentation/FolderUploadButtons';
import { FolderTreeView } from '../../../dataset-workspace/presentation/FolderTreeView';
import {
datasetFolderName,
generateBashScript,
generateCloudScript,
generatePythonScript,
type DatasetCase,
type DatasetMeta,
type Operation,
type PlannedFile,
type SourceFile,
type Split,
type Target,
type ValidationReport,
} from '../../domain/nnunet/core';
import type { NnunetAction, NnunetWizardState } from '../../domain/nnunet/wizardState';
import { StepHead } from './steps';
// ---------- Step 3 — Cases ----------
interface CasesStepProps {
datasetId: string;
state: NnunetWizardState;
dispatch: Dispatch<NnunetAction>;
cases: DatasetCase[];
unassigned: SourceFile[];
report: ValidationReport;
onAddFiles: (files: FileList | File[]) => void;
/** Returns error messages (empty = imported OK). */
onImportItemsList: (json: string) => string[];
onLoadExample: () => void;
}
export function CasesStep({ datasetId }: CasesStepProps) {
const filesQ = useQuery({
queryKey: ['imagehub', 'dataset', datasetId, 'files'],
queryFn: () => listDatasetFiles(datasetId),
enabled: !!datasetId,
});
return (
<div>
<StepHead title="Các case" sub="Tải ảnh gốc và nhãn trực tiếp vào bộ dữ liệu." />
<div className="space-y-3">
<FolderUploadButtons datasetId={datasetId} />
<FolderTreeView files={filesQ.data ?? []} />
</div>
</div>
);
}
// ---------- Step 4 — Validate & export ----------
function download(name: string, text: string) {
const url = URL.createObjectURL(new Blob([text], { type: 'application/octet-stream' }));
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
}
interface ExportStepProps {
meta: DatasetMeta;
report: ValidationReport;
datasetJson: Record<string, unknown>;
plan: PlannedFile[];
folderName: string;
}
export function ExportStep({ meta, report, datasetJson, plan, folderName }: ExportStepProps) {
const jsonText = JSON.stringify(datasetJson, null, 2);
const manifest = JSON.stringify(plan.map((p) => ({ from: p.source.filename, to: p.targetPath })), null, 2);
const [operation, setOperation] = useState<Operation>('copy');
const [target, setTarget] = useState<Target>('local');
const [srcRoot, setSrcRoot] = useState('');
const [dstRoot, setDstRoot] = useState('');
const [scriptTab, setScriptTab] = useState<'bash' | 'python'>('bash');
const [copied, setCopied] = useState('');
const copy = (key: string, text: string) => {
navigator.clipboard?.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(''), 1200);
});
};
const opts = { operation, target, sourceRoot: srcRoot, destRoot: dstRoot };
const isLocal = target === 'local';
const script = isLocal
? scriptTab === 'python'
? generatePythonScript(meta, plan, datasetJson, opts)
: generateBashScript(meta, plan, datasetJson, opts)
: generateCloudScript(meta, plan, datasetJson, opts);
const scriptName = isLocal ? (scriptTab === 'python' ? `organize_${folderName}.py` : `organize_${folderName}.sh`) : `upload_${folderName}.sh`;
const cfg = meta.dim === '2d' ? '2d' : '3d_fullres';
return (
<div>
<StepHead title="Kiểm tra & xuất" sub="Xử lý lỗi, xuất dataset.json, rồi sinh script đưa tệp vào đúng vị trí." />
<div className="mb-4 space-y-1.5">
{report.issues.length === 0 && (
<div className="flex items-center gap-2 rounded-lg bg-emerald-50 p-3 text-sm text-emerald-700">
<CheckCircle2 className="h-4 w-4" /> Mọi kiểm tra đu đt.
</div>
)}
{report.issues.map((it, i) => (
<div key={i} className={cn('flex items-start gap-2 rounded-lg p-2.5 text-sm', it.level === 'error' ? 'bg-destructive/10 text-destructive' : 'bg-amber-50 text-amber-700')}>
{it.level === 'error' ? <XCircle className="mt-0.5 h-4 w-4 shrink-0" /> : <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />}
<span>
{it.caseId && <span className="mr-1.5 rounded bg-background/60 px-1.5 py-0.5 font-mono text-xs">{it.caseId}</span>}
{it.message}
</span>
</div>
))}
</div>
<div className="mb-6 flex flex-wrap gap-2">
<Button disabled={!report.ok} onClick={() => download('dataset.json', jsonText)}>
<Download className="mr-1.5 h-4 w-4" /> dataset.json
</Button>
<Button variant="outline" disabled={!report.ok} onClick={() => download(`${folderName}_manifest.json`, manifest)}>
<FolderTree className="mr-1.5 h-4 w-4" /> Manifest di chuyển
</Button>
<Button variant="ghost" onClick={() => copy('json', jsonText)}>
<Copy className="mr-1.5 h-4 w-4" /> {copied === 'json' ? 'Đã chép' : 'Chép dataset.json'}
</Button>
</div>
<div className="border-t border-border pt-4">
<div className="text-base font-semibold text-foreground">Thực thi di chuyển</div>
<p className="mb-3.5 mt-0.5 max-w-2xl text-sm text-muted-foreground">
Sinh script tạo thư mục, ghi dataset.json {operation === 'move' ? 'di chuyển' : 'sao chép'} từng tệp tới đúng đưng dẫn. Xem lại rồi chạy nơi dữ liệu của bạn.
</p>
<div className="mb-3.5 flex flex-wrap items-end gap-4">
<div>
<div className="mb-1.5 text-xs font-medium text-foreground">Thao tác</div>
<div className="inline-flex overflow-hidden rounded-lg border border-border">
{(['copy', 'move'] as Operation[]).map((o) => (
<button
key={o}
type="button"
onClick={() => setOperation(o)}
className={cn('px-3 py-1.5 text-sm font-medium transition', operation === o ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}
>
{o === 'copy' ? 'Sao chép' : 'Di chuyển'}
</button>
))}
</div>
</div>
<div>
<div className="mb-1.5 text-xs font-medium text-foreground">Đích đến</div>
<Select value={target} onValueChange={(v) => setTarget(v as Target)}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Hệ thống tệp cục bộ</SelectItem>
<SelectItem value="s3">Amazon S3</SelectItem>
<SelectItem value="gcs">Google Cloud Storage</SelectItem>
<SelectItem value="azure">Azure Blob</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="mb-3.5 grid max-w-2xl gap-4 sm:grid-cols-2">
<label className="block">
<div className="mb-1.5 text-xs font-medium text-foreground">Thư mục nguồn</div>
<Input className="font-mono" value={srcRoot} onChange={(e) => setSrcRoot(e.target.value)} placeholder={isLocal ? './incoming' : 'nguồn'} />
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-foreground">Thư mục đích</div>
<Input className="font-mono" value={dstRoot} onChange={(e) => setDstRoot(e.target.value)} placeholder={isLocal ? './data_raw' : 'đích'} />
</label>
</div>
{!report.ok && (
<div className="mb-3 flex items-start gap-2 rounded-lg bg-amber-50 p-2.5 text-sm text-amber-700">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>Bộ dữ liệu còn lỗi hãy sửa trước khi chạy script trên dữ liệu thật.</span>
</div>
)}
<div className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-1 border-b border-border bg-muted/50 px-2 pt-2">
{isLocal ? (
(['bash', 'python'] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setScriptTab(t)}
className={cn('rounded-t-lg px-3 py-1.5 text-xs font-medium', scriptTab === t ? 'bg-card text-primary' : 'text-muted-foreground')}
>
{t === 'bash' ? 'Bash' : 'Python'}
</button>
))
) : (
<span className="rounded-t-lg bg-card px-3 py-1.5 text-xs font-medium text-primary">Cloud CLI</span>
)}
<div className="ml-auto flex gap-1.5 pb-1.5">
<Button variant="ghost" size="sm" className="h-7" onClick={() => copy('script', script)}>
<Copy className="mr-1 h-3 w-3" /> {copied === 'script' ? 'Đã chép' : 'Chép'}
</Button>
<Button size="sm" className="h-7" onClick={() => download(scriptName, script)}>
<Download className="mr-1 h-3 w-3" /> {scriptName}
</Button>
</div>
</div>
<pre className="m-0 max-h-72 overflow-auto whitespace-pre bg-muted/30 p-3.5 font-mono text-xs leading-relaxed text-foreground">{script}</pre>
</div>
<div className="mt-3.5 rounded-lg border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
<b className="text-foreground">Kiểm tra &amp; huấn luyện:</b>{' '}
kiểm tra tính toàn vẹn dữ liệu rồi huấn luyện bằng công cụ của bạn (cấu hình <b>{cfg}</b>).
</div>
</div>
</div>
);
}
@@ -0,0 +1,244 @@
import { type Dispatch, type ReactNode } from 'react';
import { ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react';
import {
Badge,
Button,
Checkbox,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
cn,
} from '@ump/shared';
import { datasetFolderName, pad4, type FileEnding } from '../../domain/nnunet/core';
import {
endingsForState,
type DatasetKind,
type NnunetAction,
type NnunetWizardState,
} from '../../domain/nnunet/wizardState';
type StepProps = { state: NnunetWizardState; dispatch: Dispatch<NnunetAction> };
// ---------- shared step primitives (UMP design system) ----------
export function StepHead({ title, sub }: { title: string; sub: string }) {
return (
<div className="mb-5">
<h2 className="font-serif text-lg font-semibold text-foreground">{title}</h2>
<p className="mt-0.5 max-w-2xl text-sm text-muted-foreground">{sub}</p>
</div>
);
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<div className="mb-1.5 text-xs font-medium text-foreground">{label}</div>
{children}
{hint && <div className="mt-1 text-[11px] text-muted-foreground">{hint}</div>}
</label>
);
}
// ---------- Step 0 — Dataset identity ----------
const KIND_LABELS: Record<DatasetKind, string> = {
volume3d: 'Khối 3D — NIfTI / NRRD / MHA (mỗi kênh một tệp)',
gray2d: 'Ảnh xám 2D — PNG / BMP / TIF (mỗi kênh một tệp)',
rgb2d: 'Ảnh RGB 2D — PNG (ba kênh màu trong một tệp)',
};
export function DatasetStep({ state, dispatch }: StepProps) {
const { meta } = state;
const kind: DatasetKind = meta.rgb ? 'rgb2d' : meta.dim === '2d' ? 'gray2d' : 'volume3d';
const config = meta.dim === '2d' ? '2d' : '3d_fullres';
return (
<div>
<StepHead
title="Định danh bộ dữ liệu"
sub="Chọn loại dữ liệu rồi đặt tên. Bộ dữ liệu được lưu theo tên DatasetXXX_Tên."
/>
<Field label="Loại dữ liệu" hint="Quyết định số chiều, định dạng tệp và cách kênh ánh xạ vào tệp.">
<Select value={kind} onValueChange={(v) => dispatch({ type: 'SET_DATASET_KIND', kind: v as DatasetKind })}>
<SelectTrigger className="max-w-md">
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(KIND_LABELS) as DatasetKind[]).map((k) => (
<SelectItem key={k} value={k}>
{KIND_LABELS[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<div className="mt-4 grid max-w-xl gap-4 sm:grid-cols-2">
<Field label="Mã bộ dữ liệu (ID)" hint="1999. ID 001010 dành cho Medical Segmentation Decathlon.">
<Input
type="number"
min={1}
max={999}
value={meta.id}
onChange={(e) => dispatch({ type: 'PATCH_META', patch: { id: parseInt(e.target.value || '0', 10) } })}
className="font-mono"
/>
</Field>
<Field label="Tên bộ dữ liệu" hint="CamelCase, không khoảng trắng.">
<Input
value={meta.name}
onChange={(e) => dispatch({ type: 'PATCH_META', patch: { name: e.target.value } })}
placeholder="Prostate"
/>
</Field>
<Field label="Đuôi tệp" hint={meta.dim === '2d' ? 'Định dạng 2D không mất dữ liệu (không .jpg).' : 'Định dạng 3D không mất dữ liệu.'}>
<Select value={meta.fileEnding} onValueChange={(v) => dispatch({ type: 'PATCH_META', patch: { fileEnding: v as FileEnding } })}>
<SelectTrigger className="font-mono">
<SelectValue />
</SelectTrigger>
<SelectContent>
{endingsForState(state).map((e) => (
<SelectItem key={e} value={e} className="font-mono">
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 p-3.5">
<div>
<div className="mb-1 text-[11px] uppercase tracking-wide text-muted-foreground">Thư mục kết quả</div>
<div className="font-mono text-primary">{datasetFolderName(meta)}/</div>
</div>
<div className="text-right">
<div className="mb-1 text-[11px] uppercase tracking-wide text-muted-foreground">Cấu hình huấn luyện</div>
<div className="font-mono text-primary">{config}</div>
</div>
</div>
</div>
);
}
// ---------- Step 1 — Channels ----------
export function ChannelsStep({ state, dispatch }: StepProps) {
const { meta } = state;
if (meta.rgb) {
return (
<div>
<StepHead title="Kênh đầu vào" sub="Chế độ RGB: ba kênh màu được lưu chung trong một ảnh cho mỗi case." />
<div className="max-w-2xl space-y-2">
{['R', 'G', 'B'].map((n, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg border border-border bg-card p-2 opacity-90">
<Badge variant="outline" className="w-14 justify-center font-mono text-primary">{pad4(i)}</Badge>
<Input value={n} disabled className="flex-1" />
<span className="px-2 text-[11px] text-muted-foreground">đã khóa</span>
</div>
))}
</div>
<p className="mt-4 max-w-2xl rounded-lg border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
Ngoại lệ RGB: <span className="font-mono">dataset.json</span> khai báo ba kênh{' '}
<span className="font-mono">{'{0:R, 1:G, 2:B}'}</span> nhưng mỗi case chỉ một tệp{' '}
<span className="font-mono">{`{case}_0000${meta.fileEnding}`}</span>. Đi loại dữ liệu sang{' '}
<b>nh xám 2D</b> đ tự đnh nghĩa kênh.
</p>
</div>
);
}
return (
<div>
<StepHead
title="Kênh đầu vào"
sub={
meta.dim === '2d'
? 'Mỗi kênh là một ảnh cho mỗi case. Mọi case phải có cùng kênh, cùng thứ tự.'
: 'Mỗi kênh là một ảnh đồng đăng ký cho mỗi case (vd T2, ADC). Thứ tự + số lượng phải giống nhau ở mọi case.'
}
/>
<div className="max-w-2xl space-y-2">
{meta.channels.map((c, i) => (
<div key={i} className="flex items-center gap-2 rounded-lg border border-border bg-card p-2">
<Badge variant="outline" className="w-14 justify-center font-mono text-primary">{pad4(c.index)}</Badge>
<Input
value={c.name}
onChange={(e) => dispatch({ type: 'UPDATE_CHANNEL', index: i, patch: { name: e.target.value } })}
placeholder="Tên kênh (vd T2)"
className="flex-1"
/>
<label
className="flex cursor-pointer select-none items-center gap-1.5 px-2 text-xs text-muted-foreground"
title="CT dùng chuẩn hóa cường độ toàn cục; các kênh khác dùng z-score theo từng kênh."
>
<Checkbox checked={c.isCT} onCheckedChange={(v) => dispatch({ type: 'UPDATE_CHANNEL', index: i, patch: { isCT: v === true } })} /> CT
</label>
<div className="flex flex-col">
<button type="button" onClick={() => dispatch({ type: 'MOVE_CHANNEL', index: i, dir: -1 })} className="text-muted-foreground hover:text-foreground">
<ChevronUp className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={() => dispatch({ type: 'MOVE_CHANNEL', index: i, dir: 1 })} className="text-muted-foreground hover:text-foreground">
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
<button type="button" onClick={() => dispatch({ type: 'REMOVE_CHANNEL', index: i })} className="p-1 text-muted-foreground hover:text-destructive">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
<Button variant="ghost" size="sm" className="mt-3 text-primary hover:bg-primary/10" onClick={() => dispatch({ type: 'ADD_CHANNEL' })}>
<Plus className="mr-1.5 h-4 w-4" /> Thêm kênh
</Button>
<p className="mt-4 max-w-xl text-xs text-muted-foreground">
Đánh dấu <b>CT</b> đ chuẩn hóa toàn cục. Tương đương RedBrick: mỗi kênh một <span className="font-mono">series</span> của Task.
</p>
</div>
);
}
// ---------- Step 2 — Labels ----------
export function LabelsStep({ state, dispatch }: StepProps) {
const { meta } = state;
return (
<div>
<StepHead title="Nhãn phân vùng" sub="Các lớp số nguyên trong mặt nạ. Nền (background) là 0; các giá trị phải liên tiếp." />
<div className="max-w-2xl space-y-2">
{[...meta.labels]
.sort((a, b) => a.value - b.value)
.map((l) => {
const i = meta.labels.indexOf(l);
const isBg = l.value === 0;
return (
<div key={i} className="flex items-center gap-2 rounded-lg border border-border bg-card p-2">
<Badge variant="outline" className="w-10 justify-center font-mono">{l.value}</Badge>
<Input
value={l.name}
onChange={(e) => dispatch({ type: 'UPDATE_LABEL', index: i, patch: { name: e.target.value } })}
placeholder={isBg ? 'background' : 'tên lớp (vd tumor)'}
className={cn('flex-1', isBg && 'text-muted-foreground')}
disabled={isBg}
/>
{isBg ? (
<span className="px-2 text-[11px] text-muted-foreground">bắt buộc</span>
) : (
<button type="button" onClick={() => dispatch({ type: 'REMOVE_LABEL', index: i })} className="p-1 text-muted-foreground hover:text-destructive">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
);
})}
</div>
<Button variant="ghost" size="sm" className="mt-3 text-primary hover:bg-primary/10" onClick={() => dispatch({ type: 'ADD_LABEL' })}>
<Plus className="mr-1.5 h-4 w-4" /> Thêm nhãn
</Button>
<p className="mt-4 max-w-xl text-xs text-muted-foreground">
Tương đương RedBrick: mỗi nhãn một lớp phân vùng trong Taxonomy; Ground Truth trở thành mặt nạ trong{' '}
<span className="font-mono">labelsTr</span>.
</p>
</div>
);
}
@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { buildFolderTree, type TreeFileInput } from './folderTree';
const f = (over: Partial<TreeFileInput> & { id: string; logicalPath: string }): TreeFileInput => ({
folderPath: '',
fileKind: 'image',
parentFileId: null,
size: 0,
mediaType: 'application/octet-stream',
organLabel: '',
...over,
});
describe('buildFolderTree', () => {
it('places files into folders by folderPath, loose files at root', () => {
const tree = buildFolderTree([
f({ id: '1', logicalPath: 'ct.nii.gz', folderPath: 'imagesTr' }),
f({ id: '2', logicalPath: 'seg.nii.gz', folderPath: 'labelsTr' }),
f({ id: '3', logicalPath: 'readme.txt', folderPath: '' }),
]);
expect(tree.files.map((x) => x.name)).toEqual(['readme.txt']);
expect(tree.folders.map((x) => x.name)).toEqual(['imagesTr', 'labelsTr']);
expect(tree.folders[0].files[0].name).toBe('ct.nii.gz');
expect(tree.fileCount).toBe(3);
});
it('nests segmentation masks under their parent image, not as separate entries', () => {
const tree = buildFolderTree([
f({ id: 'img', logicalPath: 'ct.nii.gz', folderPath: 'ca_001' }),
f({ id: 'm1', logicalPath: 'kidney_l.nii.gz', folderPath: 'ca_001', fileKind: 'segmentation', parentFileId: 'img', organLabel: 'thận trái' }),
f({ id: 'm2', logicalPath: 'kidney_r.nii.gz', folderPath: 'ca_001', fileKind: 'segmentation', parentFileId: 'img', organLabel: 'thận phải' }),
]);
const ca = tree.folders[0];
expect(ca.files).toHaveLength(1);
expect(ca.files[0].masks.map((m) => m.name)).toEqual(['kidney_l.nii.gz', 'kidney_r.nii.gz']);
expect(ca.fileCount).toBe(1);
});
it('treats a mask with a missing parent as a standalone file', () => {
const tree = buildFolderTree([
f({ id: 'm', logicalPath: 'orphan.nii.gz', fileKind: 'segmentation', parentFileId: 'gone' }),
]);
expect(tree.files.map((x) => x.name)).toEqual(['orphan.nii.gz']);
expect(tree.files[0].masks).toEqual([]);
});
it('builds deep folder paths and sorts siblings alphabetically', () => {
const tree = buildFolderTree([
f({ id: '1', logicalPath: 'b.nii.gz', folderPath: 'root/sub' }),
f({ id: '2', logicalPath: 'a.nii.gz', folderPath: 'root/sub' }),
]);
expect(tree.folders[0].path).toBe('root');
expect(tree.folders[0].folders[0].path).toBe('root/sub');
expect(tree.folders[0].folders[0].files.map((x) => x.name)).toEqual(['a.nii.gz', 'b.nii.gz']);
});
it('returns an empty root for no files', () => {
const tree = buildFolderTree([]);
expect(tree.fileCount).toBe(0);
expect(tree.folders).toEqual([]);
expect(tree.files).toEqual([]);
});
});
@@ -0,0 +1,105 @@
/**
* Folder tree for a dataset's working files (Option B — real folders inside a dataset).
*
* Builds an expandable folder hierarchy from each file's folderPath (migration 026), and nests
* segmentation masks under the image they segment (parentFileId / migration 018) so a scan and its
* organ masks read as one entry. Pure + structural so it is unit-testable without the API client.
*/
/** The structural slice of an ImagehubFile this builder needs (ImagehubFile satisfies it). */
export type TreeFileInput = {
id: string;
logicalPath: string;
folderPath: string;
fileKind: 'image' | 'segmentation';
parentFileId?: string | null;
size: number;
mediaType: string;
organLabel: string;
};
export type TreeFile = {
kind: 'file';
id: string;
name: string;
fileKind: 'image' | 'segmentation';
size: number;
mediaType: string;
organLabel: string;
/** Segmentation masks linked to this image via parentFileId, nested beneath it. */
masks: TreeFile[];
};
export type TreeFolder = {
kind: 'folder';
name: string;
path: string;
folders: TreeFolder[];
files: TreeFile[];
/** Recursive count of top-level entries (images + orphan masks); nested masks are not counted. */
fileCount: number;
};
function segments(path: string): string[] {
return path.split('/').map((s) => s.trim()).filter(Boolean);
}
export function buildFolderTree(files: TreeFileInput[]): TreeFolder {
const byId = new Map(files.map((f) => [f.id, f]));
const masksByParent = new Map<string, TreeFileInput[]>();
const nested = new Set<string>();
for (const f of files) {
if (f.fileKind === 'segmentation' && f.parentFileId && byId.has(f.parentFileId)) {
const arr = masksByParent.get(f.parentFileId) ?? [];
arr.push(f);
masksByParent.set(f.parentFileId, arr);
nested.add(f.id);
}
}
const root: TreeFolder = { kind: 'folder', name: '', path: '', folders: [], files: [], fileCount: 0 };
const folderAt = (path: string): TreeFolder => {
let cur = root;
let acc = '';
for (const seg of segments(path)) {
acc = acc ? `${acc}/${seg}` : seg;
let next = cur.folders.find((x) => x.name === seg);
if (!next) {
next = { kind: 'folder', name: seg, path: acc, folders: [], files: [], fileCount: 0 };
cur.folders.push(next);
}
cur = next;
}
return cur;
};
const toFile = (f: TreeFileInput): TreeFile => ({
kind: 'file',
id: f.id,
name: f.logicalPath,
fileKind: f.fileKind,
size: f.size,
mediaType: f.mediaType,
organLabel: f.organLabel,
masks: (masksByParent.get(f.id) ?? [])
.slice()
.sort((a, b) => a.logicalPath.localeCompare(b.logicalPath))
.map(toFile),
});
for (const f of files) {
if (nested.has(f.id)) continue;
folderAt(f.folderPath || '').files.push(toFile(f));
}
const finalize = (n: TreeFolder): number => {
n.folders.sort((a, b) => a.name.localeCompare(b.name));
n.files.sort((a, b) => a.name.localeCompare(b.name));
let count = n.files.length;
for (const child of n.folders) count += finalize(child);
n.fileCount = count;
return count;
};
finalize(root);
return root;
}
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import type { ImagehubFile } from '@ump/shared';
import {
buildWorkspaceModel,
caseStatus,
datasetJsonPreview,
dominantEnding,
formatBytes,
inferEnding,
} from './workspaceModel';
function f(p: Partial<ImagehubFile> & { logicalPath: string }): ImagehubFile {
return {
id: p.id ?? p.logicalPath,
logicalPath: p.logicalPath,
folderPath: p.folderPath ?? '',
sha256: 'sha',
size: p.size ?? 1000,
mediaType: 'application/octet-stream',
imagingMeta: {},
fileKind: p.fileKind ?? 'image',
parentFileId: p.parentFileId ?? null,
organLabel: p.organLabel ?? '',
uploadedAt: null,
downloadUrl: null,
};
}
describe('inferEnding / dominantEnding', () => {
it('longest-matches .nii.gz over .gz', () => {
expect(inferEnding('case_001_0000.nii.gz')).toBe('.nii.gz');
expect(inferEnding('img.png')).toBe('.png');
expect(inferEnding('notes.txt')).toBeNull();
});
it('picks the dominant ending, defaulting to .nii.gz', () => {
expect(dominantEnding([f({ logicalPath: 'a.nii.gz' }), f({ logicalPath: 'b.nii.gz' }), f({ logicalPath: 'c.png' })])).toBe('.nii.gz');
expect(dominantEnding([])).toBe('.nii.gz');
});
});
describe('buildWorkspaceModel — grouping', () => {
it('groups multi-channel nnU-Net files into one case with both channels inferred', () => {
const m = buildWorkspaceModel('Prostate', [
f({ id: 'a', logicalPath: 'case_001_0000.nii.gz' }),
f({ id: 'b', logicalPath: 'case_001_0001.nii.gz' }),
]);
expect(m.cases).toHaveLength(1);
expect(m.cases[0].caseId).toBe('case_001');
expect(m.meta.channels.map((c) => c.index)).toEqual([0, 1]);
// both channels present, no label -> warn (training readiness), not error
expect(caseStatus(m.cases[0], m.meta)).toBe('warn');
expect(m.organized).toBe(true);
});
it('treats a bare file as a single channel-0 image case', () => {
const m = buildWorkspaceModel('DS', [f({ logicalPath: 'patient1.nii.gz' })]);
expect(m.cases).toHaveLength(1);
expect(m.cases[0].caseId).toBe('patient1');
expect(m.cases[0].channelFiles[0]).toBeDefined();
});
it('attaches a segmentation mask as the label of its parent image case', () => {
const m = buildWorkspaceModel('DS', [
f({ id: 'img', logicalPath: 'case_001_0000.nii.gz' }),
f({ id: 'seg', logicalPath: 'liver.nii.gz', fileKind: 'segmentation', parentFileId: 'img', organLabel: 'liver' }),
]);
expect(m.cases).toHaveLength(1);
expect(m.cases[0].labelFile).toBeDefined();
// channel 0 present + label present -> valid
expect(caseStatus(m.cases[0], m.meta)).toBe('valid');
});
it('flags a case missing one of the dataset channels as error', () => {
const m = buildWorkspaceModel('DS', [
f({ id: 'a', logicalPath: 'case_001_0000.nii.gz' }),
f({ id: 'b', logicalPath: 'case_001_0001.nii.gz' }),
f({ id: 'c', logicalPath: 'case_002_0000.nii.gz' }), // missing channel 1
]);
const c2 = m.cases.find((c) => c.caseId === 'case_002')!;
expect(caseStatus(c2, m.meta)).toBe('error');
});
it('recognizes a labelsTr/ file as the case label even when uploaded as fileKind image', () => {
const m = buildWorkspaceModel('DS', [
f({ id: 'img', logicalPath: 'KIT23_00000_0000.nii.gz', folderPath: 'imagesTr' }),
f({ id: 'lbl', logicalPath: 'KIT23_00000.nii.gz', folderPath: 'labelsTr' }),
]);
expect(m.cases).toHaveLength(1);
const c = m.cases[0];
expect(c.caseId).toBe('KIT23_00000');
expect(c.labelFile?.id).toBe('lbl');
expect(c.channelFiles[0]?.id).toBe('img');
expect(caseStatus(c, m.meta)).toBe('valid');
});
});
describe('buildWorkspaceModel — readiness report', () => {
it('keeps per-case/file readiness issues and drops descriptor-level noise', () => {
const m = buildWorkspaceModel('Has Spaces', [f({ logicalPath: 'case_001_0000.nii.gz' })]);
// dataset.name (spaces) / dataset.id (reserved) noise must be filtered out
expect(m.report.issues.some((i) => i.code.startsWith('dataset.'))).toBe(false);
expect(m.report.issues.some((i) => i.code.startsWith('channels.'))).toBe(false);
// a training case without a label is a kept readiness issue
expect(m.report.issues.some((i) => i.code === 'case.missingLabel')).toBe(true);
});
it('returns an empty, unorganized model for a dataset with no files', () => {
const m = buildWorkspaceModel('', []);
expect(m.cases).toHaveLength(0);
expect(m.organized).toBe(false);
expect(m.report.ok).toBe(true);
});
});
describe('helpers', () => {
it('formatBytes', () => {
expect(formatBytes(500)).toBe('500 B');
expect(formatBytes(1024)).toBe('1.0 KB');
expect(formatBytes(-1)).toBe('—');
});
it('datasetJsonPreview has the nnU-Net descriptor shape', () => {
const m = buildWorkspaceModel('DS', [
f({ id: 'a', logicalPath: 'case_001_0000.nii.gz' }),
f({ id: 's', logicalPath: 'm.nii.gz', fileKind: 'segmentation', parentFileId: 'a' }),
]);
const json = datasetJsonPreview(m) as Record<string, unknown>;
expect(json).toHaveProperty('channel_names');
expect(json).toHaveProperty('labels');
expect(json).toHaveProperty('file_ending', '.nii.gz');
expect(json.numTraining).toBe(1);
});
});
@@ -0,0 +1,265 @@
/**
* Dataset Workspace — domain adapter (pure, no React / no API).
*
* Bridges REAL ImageHub files (`listDatasetFiles`) into the nnU-Net case model so the cockpit
* workspace can show a dataset case-by-case with an nnU-Net readiness check.
*
* ImageHub-native mapping (more correct than the mockup's filename-only guess):
* - `fileKind: 'image'` -> an input CHANNEL. Channel index from a trailing `_XXXX`
* (nnU-Net multi-modal), else channel 0 (single-modality).
* - `fileKind: 'segmentation'` -> the LABEL of its parent image's case (the ground truth).
*
* The nnU-Net DESCRIPTOR (declared channel names, label class-map, numTraining) is NOT yet
* persisted server-side — that is nnU-Net Phase 2. So the channels/labels here are INFERRED and
* `inferred: true` flags it for the UI. We therefore keep only the per-case / per-file readiness
* issues from `validateDataset` and drop the descriptor-level noise (dataset id/name, label class
* map), which would be meaningless against an inferred meta.
*
* Reuses features/data-import/domain/nnunet/core.ts — single source of truth for nnU-Net rules.
*/
import type { ImagehubFile } from '@ump/shared';
import {
FILE_ENDINGS,
groupFilesIntoCases,
parseFilename,
stripEnding,
validateDataset,
type Channel,
type DatasetCase,
type DatasetMeta,
type FileEnding,
type Label,
type SourceFile,
type ValidationReport,
} from '../../data-import/domain/nnunet/core';
export type CaseStatus = 'valid' | 'warn' | 'error';
export interface WorkspaceModel {
meta: DatasetMeta;
ending: FileEnding;
cases: DatasetCase[];
unassigned: SourceFile[];
/** nnU-Net readiness issues (per-case / per-file only — descriptor noise filtered out). */
report: ValidationReport;
/** Has at least one nnU-Net-style image file (so the case view is meaningful). */
organized: boolean;
/** Always true today: the descriptor was inferred from files, not loaded from a saved descriptor. */
inferred: boolean;
}
/** Last path segment (logical_path is basename-flattened already, but be defensive). */
export function basename(path: string): string {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
/** Longest-match a known nnU-Net file ending (".nii.gz" wins over ".gz"). */
export function inferEnding(filename: string): FileEnding | null {
const lower = filename.toLowerCase();
const hit = [...FILE_ENDINGS]
.sort((a, b) => b.length - a.length)
.find((e) => lower.endsWith(e));
return hit ?? null;
}
/** The dataset's dominant file ending; falls back to ".nii.gz" when nothing matches. */
export function dominantEnding(files: ImagehubFile[]): FileEnding {
const counts = new Map<FileEnding, number>();
for (const f of files) {
const e = inferEnding(basename(f.logicalPath));
if (e) counts.set(e, (counts.get(e) ?? 0) + 1);
}
let best: FileEnding = '.nii.gz';
let bestN = -1;
for (const [e, n] of counts) {
if (n > bestN) {
best = e;
bestN = n;
}
}
return best;
}
// Issue codes that are meaningful against REAL files. Descriptor-level codes are dropped because
// the descriptor (id/name/declared channel + label class-map) is inferred, not user-declared.
const READINESS_CODES = new Set([
'case.missingChannel',
'case.missingLabel',
'case.geometry',
'case.testLabel',
'file.lossy',
'file.ending',
'cases.dupId',
'label.rgb',
]);
function filterReadiness(report: ValidationReport): ValidationReport {
const issues = report.issues.filter((i) => READINESS_CODES.has(i.code));
const errorCount = issues.filter((i) => i.level === 'error').length;
const warningCount = issues.filter((i) => i.level === 'warning').length;
return { issues, errorCount, warningCount, ok: errorCount === 0 };
}
/**
* A file is the case LABEL when it is EITHER a linked segmentation (fileKind) OR it sits in an
* nnU-Net label folder (labelsTr / labelsTs). The "Tải nhãn" button uploads masks as plain files
* into labelsTr/, so the folder convention — not fileKind — is what identifies them as labels.
*/
export function isLabelFile(f: Pick<ImagehubFile, 'fileKind' | 'folderPath'>): boolean {
if (f.fileKind === 'segmentation') return true;
const seg = (f.folderPath || '').split('/').filter(Boolean).pop();
return seg === 'labelsTr' || seg === 'labelsTs';
}
/** Map an ImageHub file to a typed nnU-Net source file (image channel or label). */
function fileToSource(
f: ImagehubFile,
ending: FileEnding,
caseIdByFileId: Map<string, string>,
): SourceFile {
const name = basename(f.logicalPath);
const base = {
id: f.id,
filename: name,
sizeBytes: f.size,
split: 'train' as const, // no split is persisted yet; everything is treated as training data
};
if (isLabelFile(f)) {
// A mask is the label of its parent image's case.
const parentCase = f.parentFileId ? caseIdByFileId.get(f.parentFileId) : undefined;
const caseId = parentCase ?? stripEnding(name, ending) ?? name;
return { ...base, role: 'label', caseId };
}
// An image file. A trailing `_XXXX` selects the channel; otherwise it is a single channel-0 image.
const parsed = parseFilename(name, ending);
if (parsed?.role === 'image' && parsed.channelIndex != null) {
return { ...base, role: 'image', caseId: parsed.caseId, channelIndex: parsed.channelIndex };
}
const caseId = stripEnding(name, ending) ?? name;
return { ...base, role: 'image', caseId, channelIndex: 0 };
}
/** Best-effort nnU-Net descriptor inferred from the source files. */
function inferMeta(datasetName: string, ending: FileEnding, sources: SourceFile[]): DatasetMeta {
const idxs = new Set<number>();
let anyLabel = false;
for (const s of sources) {
if (s.role === 'label') anyLabel = true;
else if (s.role === 'image' && s.channelIndex != null) idxs.add(s.channelIndex);
}
const channels: Channel[] = (idxs.size ? [...idxs] : [0])
.sort((a, b) => a - b)
.map((index) => ({ index, name: `Kênh ${index}`, isCT: false }));
const labels: Label[] = anyLabel
? [
{ name: 'background', value: 0 },
{ name: 'nhãn', value: 1 },
]
: [{ name: 'background', value: 0 }];
const dim: '2d' | '3d' = ['.png', '.bmp', '.tif', '.tiff'].includes(ending) ? '2d' : '3d';
return {
id: 1, // placeholder — descriptor-level checks are filtered out, so the value is unused
name: datasetName || 'Dataset',
fileEnding: ending,
dim,
rgb: false,
channels,
labels,
};
}
/** Build the full workspace model from a dataset's real files. */
export function buildWorkspaceModel(datasetName: string, files: ImagehubFile[]): WorkspaceModel {
const ending = dominantEnding(files);
// Resolve each image file's case id first, so a segmentation mask can join its parent's case.
const caseIdByFileId = new Map<string, string>();
for (const f of files) {
if (isLabelFile(f)) continue;
const name = basename(f.logicalPath);
const parsed = parseFilename(name, ending);
caseIdByFileId.set(f.id, parsed?.caseId ?? stripEnding(name, ending) ?? name);
}
const sources = files.map((f) => fileToSource(f, ending, caseIdByFileId));
const meta = inferMeta(datasetName, ending, sources);
const { cases, unassigned } = groupFilesIntoCases(sources, meta);
const report = filterReadiness(validateDataset(meta, cases));
const organized = sources.some((s) => s.role === 'image');
// Stable order for the table: case id ascending.
cases.sort((a, b) => a.caseId.localeCompare(b.caseId));
return { meta, ending, cases, unassigned, report, organized, inferred: true };
}
/** All files attached to a case (channels + label), in channel order. */
export function caseFiles(c: DatasetCase, meta: DatasetMeta): SourceFile[] {
const out: SourceFile[] = [];
for (const ch of meta.channels) {
const f = c.channelFiles[ch.index];
if (f) out.push(f);
}
if (c.labelFile) out.push(c.labelFile);
return out;
}
export function caseHasLabel(c: DatasetCase): boolean {
return !!c.labelFile;
}
/**
* Per-case status for the table: channel completeness + file-ending only. Missing label is a
* (training-readiness) WARNING, not an error, because most datasets are images-pending-annotation.
*/
export function caseStatus(c: DatasetCase, meta: DatasetMeta): CaseStatus {
const missingChannel = meta.channels.some((ch) => !c.channelFiles[ch.index]);
if (missingChannel) return 'error';
const badEnding = caseFiles(c, meta).some((f) => !f.filename.endsWith(meta.fileEnding));
if (badEnding) return 'error';
if (!c.labelFile) return 'warn';
return 'valid';
}
/** Channel presence map for a case (drives the channel chips). */
export function channelPresence(c: DatasetCase, meta: DatasetMeta): { channel: Channel; present: boolean }[] {
return meta.channels.map((channel) => ({ channel, present: !!c.channelFiles[channel.index] }));
}
/** Human-readable byte size. */
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n < 0) return '—';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let v = n;
let i = -1;
do {
v /= 1024;
i++;
} while (v >= 1024 && i < units.length - 1);
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
/** The generated dataset.json preview (inferred descriptor). */
export function datasetJsonPreview(model: WorkspaceModel): Record<string, unknown> {
const channel_names: Record<string, string> = {};
[...model.meta.channels]
.sort((a, b) => a.index - b.index)
.forEach((c) => (channel_names[String(c.index)] = c.isCT ? 'CT' : c.name));
const labels: Record<string, number> = {};
[...model.meta.labels]
.sort((a, b) => a.value - b.value)
.forEach((l) => (labels[l.name || `label_${l.value}`] = l.value));
return {
channel_names,
labels,
numTraining: model.cases.filter((c) => c.split === 'train').length,
file_ending: model.ending,
};
}
@@ -0,0 +1,728 @@
/**
* Dataset Workspace — the case-centric nnU-Net view of ONE real ImageHub dataset.
*
* Reskin of docs/workflows/DatasetWorkspace.tsx onto the UMP design system, wired to REAL data:
* files come from listDatasetFiles, cases + readiness from the nnU-Net core domain (via the
* workspaceModel adapter), versions from listDatasetVersions. The nnU-Net descriptor
* (channel names / label class-map) is inferred from filenames until persistence (Phase 2), so the
* config + readiness are flagged "suy ra" (inferred).
*/
import { Fragment, lazy, Suspense, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertTriangle,
Boxes,
Braces,
CheckCircle2,
ChevronDown,
ChevronRight,
Eye,
FileText,
FolderTree,
Info,
Layers,
Loader2,
ScanLine,
Search,
Wand2,
XCircle,
} from 'lucide-react';
import {
Badge,
Button,
Card,
CardContent,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
cn,
fetchAsFile,
fileDownloadUrl,
getDataset,
updateDataset,
listDatasetFiles,
listDatasetVersions,
normalizeChannels,
} from '@ump/shared';
import type { DatasetCase } from '../../data-import/domain/nnunet/core';
import {
buildWorkspaceModel,
caseFiles,
caseStatus,
channelPresence,
datasetJsonPreview,
formatBytes,
type CaseStatus,
type WorkspaceModel,
} from '../domain/workspaceModel';
import { FolderTreeView } from './FolderTreeView';
import { FolderUploadButtons } from './FolderUploadButtons';
import { toast } from 'sonner';
import type { AvailableMask } from '../../../components/DatasetFileViewerDialog';
// VTK-free image-sequence quad-viewer (same light subpath, no lazy boundary needed).
import { ImageSequenceViewer } from '@ump/shared/video-viewer';
const DatasetFileViewerDialog = lazy(() => import('../../../components/DatasetFileViewerDialog'));
/** A case whose image channel is a flat 2D raster opens in the image-sequence viewer, not VTK. */
const is2dImage = (filename: string): boolean => /\.(png|jpe?g|bmp|webp)$/i.test(filename);
const STATUS_META: Record<CaseStatus, { label: string; cls: string; Icon: typeof CheckCircle2 }> = {
valid: { label: 'Hợp lệ', cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', Icon: CheckCircle2 },
warn: { label: 'Cảnh báo', cls: 'bg-amber-50 text-amber-700 border-amber-200', Icon: AlertTriangle },
error: { label: 'Lỗi', cls: 'bg-rose-50 text-rose-700 border-rose-200', Icon: XCircle },
};
const PAGE_SIZE = 8;
function StatusPill({ status }: { status: CaseStatus }) {
const m = STATUS_META[status];
return (
<Badge variant="outline" className={cn('gap-1 font-medium', m.cls)}>
<m.Icon className="h-3.5 w-3.5" /> {m.label}
</Badge>
);
}
/**
* Normalize image filenames to the single-channel `{case}_0000.{ext}` training-layout
* convention (labels stay bare). Logical rename only — no file move.
*/
function NormalizeFilesButton({ datasetId }: { datasetId: string }) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [prefix, setPrefix] = useState('');
const [running, setRunning] = useState(false);
// The sanitized prefix that the server will actually use (uppercase alphanumeric).
const clean = prefix.trim().toUpperCase().replace(/[^A-Z0-9]/g, '');
const run = async () => {
if (!clean) return;
setRunning(true);
try {
const r = await normalizeChannels(datasetId, clean);
qc.invalidateQueries({ queryKey: ['imagehub'] });
toast.success(
r.renamed > 0
? `Đã chuẩn hoá ${r.renamed} tệp${r.skipped ? ` (bỏ qua ${r.skipped})` : ''}.`
: 'Tất cả tệp đã đúng định dạng.',
);
setOpen(false);
} catch {
toast.error('Không chuẩn hoá được tên tệp.');
} finally {
setRunning(false);
}
};
const sample = clean || 'POLYP25';
return (
<>
<Button
variant="outline"
onClick={() => setOpen(true)}
title="Chuẩn hoá tên tệp theo định dạng huấn luyện"
>
<Wand2 className="mr-1.5 h-4 w-4" /> Chuẩn hoá tên tệp
</Button>
<Dialog open={open} onOpenChange={(o) => !running && setOpen(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Chuẩn hoá tên tệp</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<span className="text-sm font-medium"> tổn thương (tiền tố)</span>
<Input
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && run()}
placeholder="VD: POLYP25"
autoFocus
/>
</div>
<p className="text-xs text-muted-foreground">
nh đi thành <span className="font-mono">{sample}_00001_0000.png</span>, nhãn thành{' '}
<span className="font-mono">{sample}_00001.png</span> (số ca đưc điền 5 chữ số).
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={running}>
Huỷ
</Button>
<Button onClick={run} disabled={running || !clean}>
{running ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
Chuẩn hoá
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/* ------------------------------- Cases tab ------------------------------- */
function CasesTab({ model, datasetId }: { model: WorkspaceModel; datasetId: string }) {
const [query, setQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | CaseStatus>('all');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);
const [detailId, setDetailId] = useState<string | null>(null);
const [viewer, setViewer] = useState<{ file: File; name: string; masks: AvailableMask[] } | null>(null);
const [imageViewer, setImageViewer] = useState<
{ caseId: string; original: string[]; depth: string[]; segmentation: string[] } | null
>(null);
const [openingCase, setOpeningCase] = useState<string | null>(null);
const qc = useQueryClient();
// The dataset's per-value label map (migration 027) — shared cache key with the main view.
const labelMap = useQuery({
queryKey: ['imagehub', 'dataset', datasetId],
queryFn: () => getDataset(datasetId),
enabled: !!datasetId,
}).data?.labelMap;
// Persist a renamed multi-label organ to the dataset's label_map, then refresh the cache.
const renameLabel = (value: number, name: string) => {
const next = { ...(labelMap || {}), [String(value)]: name };
updateDataset(datasetId, { labelMap: next })
.then((ds) => {
qc.setQueryData(['imagehub', 'dataset', datasetId], ds);
toast.success('Đã đổi tên nhãn.');
})
.catch(() => toast.error('Không đổi được tên nhãn.'));
};
// Open one case in the 3D viewer: fetch its (first) image channel as a File, and pass its
// labelsTr mask — if any — as a single toggleable overlay. Image-only cases open without overlay.
const openCaseViewer = async (c: DatasetCase) => {
const channels = model.meta.channels;
const imgSource = channels.map((ch) => c.channelFiles[ch.index]).find(Boolean);
if (!imgSource) {
toast.error('Ca này chưa có ảnh gốc để xem.');
return;
}
setOpeningCase(c.caseId);
try {
if (is2dImage(imgSource.filename)) {
// 2D PNG case → the image-sequence quad-viewer. A channel whose FILE is named "…depth…"
// feeds Q2 (channel names here are inferred "Kênh N", so match the filename, not the name);
// the first non-depth channel feeds Q1 (original), and labelFile feeds Q3 (segmentation).
// <img> loads the presigned URLs directly, so no fetch-to-File is needed.
const depthCh = channels.find((ch) => {
const f = c.channelFiles[ch.index];
return f != null && /depth/i.test(f.filename);
});
const origCh = channels.find((ch) => c.channelFiles[ch.index] && ch !== depthCh) ?? channels[0];
const origFile = c.channelFiles[origCh.index];
const depthFile = depthCh ? c.channelFiles[depthCh.index] : undefined;
const [orig, depth, seg] = await Promise.all([
origFile ? fileDownloadUrl(datasetId, origFile.id) : Promise.resolve(null),
depthFile ? fileDownloadUrl(datasetId, depthFile.id) : Promise.resolve(null),
c.labelFile ? fileDownloadUrl(datasetId, c.labelFile.id) : Promise.resolve(null),
]);
setViewer(null); // ensure only one viewer dialog is mounted at a time
setImageViewer({
caseId: c.caseId,
original: orig ? [orig.url] : [],
depth: depth ? [depth.url] : [],
segmentation: seg ? [seg.url] : [],
});
return;
}
// 3D volume (NIfTI/DICOM) → the VTK viewer with the labelsTr mask as a toggleable overlay.
const { url } = await fileDownloadUrl(datasetId, imgSource.id);
const file = await fetchAsFile(url, imgSource.filename);
const masks: AvailableMask[] = c.labelFile
? [{ id: c.labelFile.id, organLabel: 'Mặt nạ ROI', logicalPath: c.labelFile.filename, multiLabel: true }]
: [];
setImageViewer(null); // ensure only one viewer dialog is mounted at a time
setViewer({ file, name: imgSource.filename, masks });
} catch {
toast.error('Không tải được ảnh để xem.');
} finally {
setOpeningCase(null);
}
};
const filtered = useMemo(() => {
return model.cases.filter((c) => {
const mq = !query.trim() || c.caseId.toLowerCase().includes(query.trim().toLowerCase());
const ms = statusFilter === 'all' || caseStatus(c, model.meta) === statusFilter;
return mq && ms;
});
}, [model, query, statusFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const pageRows = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
const detail = detailId ? model.cases.find((c) => c.caseId === detailId) ?? null : null;
const toggle = (id: string) => {
setExpanded((s) => {
const n = new Set(s);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
};
return (
<>
<div className="flex gap-4">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
placeholder="Tìm theo mã ca…"
className="pl-9"
/>
</div>
<div className="flex items-center gap-1">
{(['all', 'valid', 'warn', 'error'] as const).map((s) => (
<Button
key={s}
size="sm"
variant={statusFilter === s ? 'default' : 'outline'}
onClick={() => {
setStatusFilter(s);
setPage(1);
}}
>
{s === 'all' ? 'Tất cả' : STATUS_META[s].label}
</Button>
))}
</div>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Ca dữ liệu</TableHead>
<TableHead>Nhãn</TableHead>
<TableHead>Tình trạng</TableHead>
<TableHead className="w-12 text-right">Xem</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pageRows.map((c) => {
const status = caseStatus(c, model.meta);
const isOpen = expanded.has(c.caseId);
const files = caseFiles(c, model.meta);
return (
<Fragment key={c.caseId}>
<TableRow
className="cursor-pointer"
onClick={() => setDetailId(c.caseId)}
>
<TableCell onClick={(e) => { e.stopPropagation(); toggle(c.caseId); }}>
<button className="rounded p-0.5 text-muted-foreground hover:bg-muted">
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</TableCell>
<TableCell>
<span className="inline-flex items-center gap-2">
<Boxes className="h-4 w-4 text-muted-foreground" />
<span className="font-mono font-medium text-foreground">{c.caseId}</span>
</span>
</TableCell>
<TableCell>
{c.labelFile ? (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600">
<CheckCircle2 className="h-3.5 w-3.5" />
</span>
) : (
<span className="text-xs text-muted-foreground">chưa </span>
)}
</TableCell>
<TableCell><StatusPill status={status} /></TableCell>
<TableCell className="text-right">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void openCaseViewer(c);
}}
disabled={openingCase === c.caseId}
title="Xem"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{openingCase === c.caseId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</TableCell>
</TableRow>
{isOpen &&
files.map((f) => (
<TableRow key={f.id} className="bg-muted/40">
<TableCell />
<TableCell>
<span className="font-mono text-[11px] text-muted-foreground">{f.filename}</span>
</TableCell>
<TableCell>
<span className="text-[11px] text-muted-foreground">
{f.role === 'label' ? 'nhãn' : `kênh ${f.channelIndex ?? 0}`}
</span>
</TableCell>
<TableCell colSpan={2} className="text-right font-mono text-[11px] text-muted-foreground">
{formatBytes(f.sizeBytes)}
</TableCell>
</TableRow>
))}
</Fragment>
);
})}
{pageRows.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-12 text-center text-sm text-muted-foreground">
Không ca nào phù hợp.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
{filtered.length > PAGE_SIZE && (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{(safePage - 1) * PAGE_SIZE + 1}{Math.min(safePage * PAGE_SIZE, filtered.length)} / {filtered.length} ca
</span>
<div className="flex items-center gap-1">
<Button size="sm" variant="outline" disabled={safePage === 1} onClick={() => setPage((p) => p - 1)}>
Trước
</Button>
<span className="px-2 font-mono">{safePage}/{totalPages}</span>
<Button size="sm" variant="outline" disabled={safePage === totalPages} onClick={() => setPage((p) => p + 1)}>
Sau
</Button>
</div>
</div>
)}
</div>
{detail && <DetailAside c={detail} model={model} onClose={() => setDetailId(null)} />}
</div>
{viewer && (
<Suspense fallback={null}>
<DatasetFileViewerDialog
open
onOpenChange={(o) => !o && setViewer(null)}
file={viewer.file}
fileName={viewer.name}
datasetId={datasetId}
masks={viewer.masks}
labelMap={labelMap}
onRenameLabel={renameLabel}
/>
</Suspense>
)}
{imageViewer && (
<Dialog open onOpenChange={(o) => !o && setImageViewer(null)}>
<DialogContent className="w-[96vw] max-w-[1500px] p-0">
<DialogHeader className="border-b border-border px-4 py-3">
<DialogTitle>Xem ca: {imageViewer.caseId}</DialogTitle>
</DialogHeader>
<div className="h-[85vh] w-full bg-black">
<ImageSequenceViewer
original={
imageViewer.original.length ? { urls: imageViewer.original, label: 'Ảnh gốc' } : undefined
}
depth={imageViewer.depth.length ? { urls: imageViewer.depth, label: 'Bản đồ độ sâu' } : undefined}
segmentation={
imageViewer.segmentation.length
? { urls: imageViewer.segmentation, label: 'Mặt nạ phân vùng', opacity: 0.6 }
: undefined
}
/>
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
function DetailAside({ c, model, onClose }: { c: DatasetCase; model: WorkspaceModel; onClose: () => void }) {
const issues = model.report.issues.filter((i) => i.caseId === c.caseId);
return (
<Card className="w-80 shrink-0">
<CardContent className="space-y-4 p-4">
<div className="flex items-start justify-between">
<span className="inline-flex items-center gap-2">
<Boxes className="h-4 w-4 text-muted-foreground" />
<span className="font-mono font-semibold text-foreground">{c.caseId}</span>
</span>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<XCircle className="h-4 w-4" />
</button>
</div>
<div>
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<ScanLine className="h-3.5 w-3.5" /> Kênh
</div>
<div className="space-y-1">
{channelPresence(c, model.meta).map(({ channel, present }) => (
<div key={channel.index} className="flex items-center justify-between rounded-md border border-border px-2.5 py-1.5 text-xs">
<span className="font-mono">{String(channel.index).padStart(4, '0')} · {channel.name}</span>
{present ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
) : (
<span className="text-[11px] font-medium text-rose-600">thiếu</span>
)}
</div>
))}
</div>
</div>
<div>
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<FileText className="h-3.5 w-3.5" /> Tệp
</div>
<div className="space-y-1">
{caseFiles(c, model.meta).map((f) => (
<div key={f.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-mono text-[11px] text-muted-foreground">{f.filename}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">{formatBytes(f.sizeBytes)}</span>
</div>
))}
</div>
</div>
{issues.length > 0 && (
<div>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Vấn đ
</div>
<ul className="space-y-1">
{issues.map((i, k) => (
<li key={k} className={cn('text-xs', i.level === 'error' ? 'text-rose-700' : 'text-amber-700')}>
· {i.message}
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
);
}
/* ----------------------------- Config tab ------------------------------- */
function ConfigTab({ model }: { model: WorkspaceModel }) {
const json = useMemo(() => datasetJsonPreview(model), [model]);
return (
<div className="space-y-4">
<InferredBanner />
<Card>
<CardContent className="p-0">
<pre className="overflow-auto rounded-xl bg-foreground p-4 font-mono text-[11px] leading-relaxed text-background">
{JSON.stringify(json, null, 2)}
</pre>
</CardContent>
</Card>
</div>
);
}
/* --------------------------- Validation tab ----------------------------- */
function ValidationTab({ model }: { model: WorkspaceModel }) {
const { issues } = model.report;
if (issues.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-2 py-16 text-center">
<CheckCircle2 className="h-6 w-6 text-emerald-600" />
<p className="text-sm font-medium text-foreground">Mọi ca đu hợp lệ.</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-3">
<InferredBanner />
<Card>
<CardContent className="p-0">
{issues.map((i, k) => (
<div key={k} className={cn('flex items-start gap-3 px-4 py-3', k ? 'border-t border-border' : '')}>
{i.level === 'error' ? (
<XCircle className="mt-0.5 h-4 w-4 shrink-0 text-rose-600" />
) : (
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
)}
<div className="min-w-0">
<div className="text-sm text-foreground">{i.message}</div>
<div className="font-mono text-[11px] text-muted-foreground">
{i.code}
{i.caseId ? ` · ${i.caseId}` : ''}
</div>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}
/* ---------------------------- Versions tab ------------------------------ */
function VersionsTab({ datasetId }: { datasetId: string }) {
const q = useQuery({
queryKey: ['imagehub', 'dataset', datasetId, 'versions'],
queryFn: () => listDatasetVersions(datasetId),
enabled: !!datasetId,
});
if (q.isLoading) return <Skeleton className="h-32 w-full" />;
const versions = q.data ?? [];
if (versions.length === 0) {
return (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
Chưa phiên bản nào đưc lưu.
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent className="p-0">
{versions.map((v, k) => (
<div key={v.id} className={cn('flex items-start gap-3 px-4 py-3', k ? 'border-t border-border' : '')}>
<Badge variant="outline" className="font-mono">v{v.seq}</Badge>
<div className="min-w-0 flex-1">
<div className="text-sm text-foreground">{v.message || '(không có mô tả)'}</div>
<div className="font-mono text-[11px] text-muted-foreground">
{v.fileCount} tệp{v.createdAt ? ` · ${new Date(v.createdAt).toLocaleString('vi-VN')}` : ''}
</div>
</div>
</div>
))}
</CardContent>
</Card>
);
}
function InferredBanner() {
return (
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
tả dữ liệu (kênh / nhãn) đưc <strong>suy ra từ tên tệp</strong>, chưa đưc lưu cùng bộ dữ liệu. Dùng
trình tổ chức dữ liệu đ khai báo chính thức.
</span>
</div>
);
}
/* ------------------------------- main view ------------------------------ */
export function DatasetWorkspaceView({ datasetId }: { datasetId: string }) {
const dsQ = useQuery({
queryKey: ['imagehub', 'dataset', datasetId],
queryFn: () => getDataset(datasetId),
enabled: !!datasetId,
});
const filesQ = useQuery({
queryKey: ['imagehub', 'dataset', datasetId, 'files'],
queryFn: () => listDatasetFiles(datasetId),
enabled: !!datasetId,
});
const model = useMemo(
() => buildWorkspaceModel(dsQ.data?.name ?? '', filesQ.data ?? []),
[dsQ.data?.name, filesQ.data],
);
if (filesQ.isLoading || dsQ.isLoading) {
return <Skeleton className="h-64 w-full" />;
}
const fileCount = filesQ.data?.length ?? 0;
if (fileCount === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<Layers className="h-7 w-7 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">Bộ dữ liệu này chưa tệp nào.</p>
<p className="text-xs text-muted-foreground">Tải nh gốc nhãn đ bắt đu.</p>
<FolderUploadButtons datasetId={datasetId} />
</CardContent>
</Card>
);
}
return (
<div className="space-y-4">
<Tabs defaultValue="files">
<TabsList>
<TabsTrigger value="files">
<FolderTree className="mr-1.5 h-4 w-4" /> Thư mục
</TabsTrigger>
<TabsTrigger value="cases">
<Boxes className="mr-1.5 h-4 w-4" /> Ca dữ liệu
</TabsTrigger>
<TabsTrigger value="config">
<Braces className="mr-1.5 h-4 w-4" /> dataset.json
</TabsTrigger>
<TabsTrigger value="validation">
<AlertTriangle className="mr-1.5 h-4 w-4" /> Kiểm tra
{model.report.issues.length > 0 && (
<span className="ml-1.5 rounded-full bg-muted px-1.5 text-[11px]">{model.report.issues.length}</span>
)}
</TabsTrigger>
<TabsTrigger value="versions">
<Layers className="mr-1.5 h-4 w-4" /> Phiên bản
</TabsTrigger>
</TabsList>
<TabsContent value="files" className="mt-4 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<FolderUploadButtons datasetId={datasetId} />
<NormalizeFilesButton datasetId={datasetId} />
</div>
<FolderTreeView files={filesQ.data ?? []} />
</TabsContent>
<TabsContent value="cases" className="mt-4">
<CasesTab model={model} datasetId={datasetId} />
</TabsContent>
<TabsContent value="config" className="mt-4">
<ConfigTab model={model} />
</TabsContent>
<TabsContent value="validation" className="mt-4">
<ValidationTab model={model} />
</TabsContent>
<TabsContent value="versions" className="mt-4">
<VersionsTab datasetId={datasetId} />
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,101 @@
/**
* Folder tree view — the "Folder Content" pane of a dataset (Option B).
*
* Renders the real folder hierarchy persisted via folder_path (migration 026): expandable folders,
* image files, and segmentation masks nested under the scan they segment (parentFileId). Built from
* the flat file list by the pure buildFolderTree adapter.
*/
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen, Layers, Library, ScanLine } from 'lucide-react';
import { Card, CardContent, cn } from '@ump/shared';
import type { ImagehubFile } from '@ump/shared';
import { formatBytes } from '../domain/workspaceModel';
import { buildFolderTree, type TreeFile, type TreeFolder } from '../domain/folderTree';
function FileRow({ file, depth }: { file: TreeFile; depth: number }) {
const isMask = file.fileKind === 'segmentation';
return (
<>
<div className="flex items-center gap-2 py-1.5 pr-2" style={{ paddingLeft: depth * 18 + 26 }}>
{isMask ? (
<Layers className="h-4 w-4 shrink-0 text-accent" />
) : (
<ScanLine className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{file.name}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{isMask ? file.organLabel || 'nhãn' : 'ảnh'} · {formatBytes(file.size)}
</span>
</div>
{file.masks.map((m) => (
<FileRow key={m.id} file={m} depth={depth + 1} />
))}
</>
);
}
function FolderRow({ folder, depth }: { folder: TreeFolder; depth: number }) {
const [open, setOpen] = useState(depth === 0);
return (
<>
<button
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center gap-2 py-1.5 pr-2 text-left hover:bg-muted/50"
style={{ paddingLeft: depth * 18 + 6 }}
aria-expanded={open}
>
{open ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{open ? (
<FolderOpen className="h-4 w-4 shrink-0 text-accent" />
) : (
<Folder className="h-4 w-4 shrink-0 text-accent" />
)}
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{folder.name}</span>
<span className="shrink-0 font-mono text-[11px] text-muted-foreground">{folder.fileCount} tệp</span>
</button>
{open && (
<>
{folder.folders.map((sub) => (
<FolderRow key={sub.path} folder={sub} depth={depth + 1} />
))}
{folder.files.map((file) => (
<FileRow key={file.id} file={file} depth={depth} />
))}
</>
)}
</>
);
}
export function FolderTreeView({ files }: { files: ImagehubFile[] }) {
const root = useMemo(() => buildFolderTree(files), [files]);
const isEmpty = root.folders.length === 0 && root.files.length === 0;
return (
<Card>
<CardContent className={cn('p-2', isEmpty && 'p-0')}>
{isEmpty ? (
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-muted-foreground">
<Library className="h-6 w-6" />
Bộ dữ liệu này chưa tệp nào.
</div>
) : (
<div>
{root.folders.map((folder) => (
<FolderRow key={folder.path} folder={folder} depth={0} />
))}
{root.files.map((file) => (
<FileRow key={file.id} file={file} depth={0} />
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,58 @@
/**
* Two folder-targeted upload buttons for a dataset:
* • "Tải ảnh gốc" → original images, stored under imagesTr/
* • "Tải nhãn" → labels / masks, stored under labelsTr/
*
* Both persist via uploadDatasetFiles(id, files, folder) (folder_path, migration 026) and refresh
* the dataset's file queries so the folder tree reflects the upload immediately.
*/
import { useRef, type ChangeEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ImagePlus, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { Button, detailFromApiError, uploadDatasetFiles } from '@ump/shared';
export function FolderUploadButtons({ datasetId }: { datasetId: string }) {
const qc = useQueryClient();
const imagesInput = useRef<HTMLInputElement>(null);
const labelsInput = useRef<HTMLInputElement>(null);
const upload = useMutation({
mutationFn: ({ files, folder }: { files: File[]; folder: 'imagesTr' | 'labelsTr' }) =>
uploadDatasetFiles(datasetId, files, folder),
onSuccess: (res, vars) => {
const what = vars.folder === 'imagesTr' ? 'ảnh gốc' : 'nhãn';
const dups = res.files.filter((f) => f.deduped).length;
toast.success(
`Đã tải lên ${res.files.length} tệp ${what}${dups ? ` (${dups} trùng nội dung)` : ''}`,
);
qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', datasetId] });
},
onError: (e: unknown) => toast.error(detailFromApiError(e, 'Tải tệp thất bại')),
});
const onPick = (folder: 'imagesTr' | 'labelsTr') => (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length) upload.mutate({ files, folder });
e.target.value = '';
};
const busy = upload.isPending;
const pending = upload.variables?.folder;
return (
<div className="flex flex-wrap items-center gap-2">
<input ref={imagesInput} type="file" multiple className="hidden" onChange={onPick('imagesTr')} />
<input ref={labelsInput} type="file" multiple className="hidden" onChange={onPick('labelsTr')} />
<Button size="sm" disabled={busy} onClick={() => imagesInput.current?.click()}>
<ImagePlus className="mr-1.5 h-4 w-4" />
{busy && pending === 'imagesTr' ? 'Đang tải…' : 'Tải ảnh gốc'}
</Button>
<Button size="sm" variant="outline" disabled={busy} onClick={() => labelsInput.current?.click()}>
<Tag className="mr-1.5 h-4 w-4" />
{busy && pending === 'labelsTr' ? 'Đang tải…' : 'Tải nhãn'}
</Button>
</div>
);
}
@@ -0,0 +1,124 @@
/**
* Workspace cockpit tab — the imaging-dataset browser for one research project ("workspace").
*
* The dataset list ("Bộ dữ liệu") lives in the cockpit sidebar (see DatasetNav, rendered by
* CockpitPage near "Mã đề tài"); WorkspacePanel renders the SELECTED dataset's contents
* (folder tree / cases / dataset.json / validation / versions). The dataset query + selection
* are owned by CockpitPage and passed in, so the sidebar nav and this pane stay in sync.
* Datasets are linked to the project via research_project_id (migration 024).
*/
import { Link } from 'react-router-dom';
import { Folder, Info, Plus } from 'lucide-react';
import { Button, Card, CardContent, Skeleton, cn, type ImagehubDataset } from '@ump/shared';
import { DatasetWorkspaceView } from './DatasetWorkspaceView';
/** The "Bộ dữ liệu" selector list — rendered inside the cockpit sidebar. */
export function DatasetNav({
datasets,
activeId,
onSelect,
projectId,
loading,
error,
}: {
datasets: ImagehubDataset[];
activeId: string | null;
onSelect: (id: string) => void;
projectId: string;
loading?: boolean;
error?: boolean;
}) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Bộ dữ liệu</span>
<Button asChild variant="outline" size="sm">
<Link to={`/dashboard/datasets/new?project=${projectId}`}>
<Plus className="mr-1 h-3.5 w-3.5" /> Thêm
</Link>
</Button>
</div>
{loading ? (
<Skeleton className="h-24 w-full" />
) : error ? (
<div className="text-xs text-destructive">Không tải đưc danh sách bộ dữ liệu.</div>
) : datasets.length === 0 ? (
<div className="rounded-lg border border-dashed border-sidebar-border px-2.5 py-3 text-[11px] text-muted-foreground">
Chưa bộ dữ liệu nào.
</div>
) : (
<Card>
<CardContent className="space-y-0.5 p-1.5">
{datasets.map((d) => {
const active = d.id === activeId;
return (
<button
key={d.id}
onClick={() => onSelect(d.id)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors',
active ? 'bg-muted' : 'hover:bg-muted/50',
)}
style={active ? { boxShadow: 'inset 2px 0 0 hsl(var(--accent))' } : undefined}
>
<Folder className={cn('h-4 w-4 shrink-0', active ? 'text-accent' : 'text-muted-foreground')} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{d.name}</span>
<span className="block text-[11px] text-muted-foreground">{d.fileCount} tệp</span>
</span>
</button>
);
})}
</CardContent>
</Card>
)}
<div className="flex items-start gap-2 rounded-lg border border-border bg-muted/40 px-2.5 py-2 text-[11px] text-muted-foreground">
<Info className="mt-0.5 h-3 w-3 shrink-0" />
<span>Các bộ dữ liệu hình nh thuộc đ tài này.</span>
</div>
</div>
);
}
/** Right pane: the contents of the currently-selected dataset (or load/empty states). */
export function WorkspacePanel({
projectId,
datasetId,
loading,
error,
isEmpty,
}: {
projectId: string;
datasetId: string | null;
loading?: boolean;
error?: boolean;
isEmpty?: boolean;
}) {
if (loading) return <Skeleton className="h-64 w-full" />;
if (error) return <div className="text-sm text-destructive">Không tải đưc danh sách bộ dữ liệu.</div>;
if (isEmpty || !datasetId) {
return (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
<Folder className="h-7 w-7 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">Đ tài này chưa bộ dữ liệu hình nh nào.</p>
<p className="max-w-sm text-xs text-muted-foreground">
Tạo một bộ dữ liệu đ bắt đu tổ chức kiểm tra dữ liệu hình nh.
</p>
<Button asChild size="sm">
<Link to={`/dashboard/datasets/new?project=${projectId}`}>
<Plus className="mr-1.5 h-4 w-4" /> Tạo bộ dữ liệu
</Link>
</Button>
</CardContent>
</Card>
);
}
return <DatasetWorkspaceView key={datasetId} datasetId={datasetId} />;
}
@@ -0,0 +1,220 @@
import { describe, it, expect } from 'vitest';
import type { DatasetTask } from '@ump/shared';
import {
allowedActions,
clampPriority,
PIPELINE_STATE_META,
QUEUE_STATUS_META,
matchesScope,
matchesAssignee,
searchTasks,
groupTasks,
paginate,
tasksAssignedByStage,
shortId,
stageTone,
type TaskActionInput,
} from './taskView';
function mkTask(over: Partial<DatasetTask> = {}): DatasetTask {
return {
id: 'task-0000-1111-2222-3333',
name: 'scan.nii',
fileId: 'file-1',
fileLogicalPath: 'scan.nii',
currentStageId: 'stage-label',
currentStageName: 'Gán nhãn',
pipelineState: 'inLabel',
queueStatus: 'assigned',
assigneeUserId: null,
assigneeName: null,
assignmentMode: 'manual',
priority: 0,
isReferenceStandard: false,
...over,
};
}
const base: TaskActionInput = {
pipelineState: 'inLabel',
queueStatus: 'assigned',
assigneeUserId: null,
};
describe('allowedActions', () => {
it('an unassigned Label task can be finalized, skipped and claimed (not reviewed)', () => {
const a = allowedActions(base);
expect(a).toMatchObject({
canFinalize: true,
canReview: false,
canSkip: true,
canAssign: true,
canUnassign: false,
canSetReference: false,
isTerminal: false,
});
});
it('an assigned Review task can be reviewed and unassigned (not finalized)', () => {
const a = allowedActions({ ...base, pipelineState: 'inReview', assigneeUserId: 'u1' });
expect(a.canReview).toBe(true);
expect(a.canFinalize).toBe(false);
expect(a.canUnassign).toBe(true);
expect(a.canAssign).toBe(false);
expect(a.canSkip).toBe(true);
});
it('a Ground Truth task is terminal — only reference-standard toggling', () => {
const a = allowedActions({ ...base, pipelineState: 'groundTruth' });
expect(a).toMatchObject({
canFinalize: false,
canReview: false,
canSkip: false,
canAssign: false,
canSetReference: true,
isTerminal: true,
});
});
it('an already-skipped task cannot be skipped again but can still be finalized', () => {
const a = allowedActions({ ...base, queueStatus: 'skipped' });
expect(a.canSkip).toBe(false);
expect(a.canFinalize).toBe(true);
});
});
describe('clampPriority', () => {
it('clamps to [0, 1] and defaults NaN to 0', () => {
expect(clampPriority(0.5)).toBe(0.5);
expect(clampPriority(2)).toBe(1);
expect(clampPriority(-1)).toBe(0);
expect(clampPriority(Number.NaN)).toBe(0);
});
});
describe('display metadata', () => {
it('has a Vietnamese label + tone for every pipeline state and queue status', () => {
for (const meta of Object.values(PIPELINE_STATE_META)) {
expect(meta.label.length).toBeGreaterThan(0);
expect(meta.tone).toBeTruthy();
}
for (const meta of Object.values(QUEUE_STATUS_META)) {
expect(meta.label.length).toBeGreaterThan(0);
expect(meta.tone).toBeTruthy();
}
});
});
describe('matchesScope', () => {
const mine = mkTask({ assigneeUserId: 'u1' });
const unassigned = mkTask({ assigneeUserId: null });
const review = mkTask({ pipelineState: 'inReview' });
it('all → everything; mine → only the caller; unassigned → only empty assignee', () => {
expect(matchesScope(mine, 'all', 'u1')).toBe(true);
expect(matchesScope(mine, 'mine', 'u1')).toBe(true);
expect(matchesScope(unassigned, 'mine', 'u1')).toBe(false);
expect(matchesScope(unassigned, 'unassigned', 'u1')).toBe(true);
expect(matchesScope(mine, 'unassigned', 'u1')).toBe(false);
});
it('a pipeline-state scope matches by state', () => {
expect(matchesScope(review, 'inReview', null)).toBe(true);
expect(matchesScope(review, 'inLabel', null)).toBe(false);
});
it('mine is false when there is no current user', () => {
expect(matchesScope(mine, 'mine', null)).toBe(false);
});
});
describe('matchesAssignee', () => {
const t = mkTask({ assigneeUserId: 'u2' });
it('all passes, unassigned needs empty, an id must match', () => {
expect(matchesAssignee(t, 'all')).toBe(true);
expect(matchesAssignee(t, 'unassigned')).toBe(false);
expect(matchesAssignee(t, 'u2')).toBe(true);
expect(matchesAssignee(t, 'u9')).toBe(false);
expect(matchesAssignee(mkTask({ assigneeUserId: null }), 'unassigned')).toBe(true);
});
});
describe('searchTasks', () => {
const list = [
mkTask({ id: 'aaaa', name: 'Brain.nii', fileLogicalPath: 'brain.nii' }),
mkTask({ id: 'bbbb', name: 'Liver.dcm', fileLogicalPath: 'liver.dcm' }),
];
it('matches name, logical path or id, case-insensitively; blank returns all', () => {
expect(searchTasks(list, '').length).toBe(2);
expect(searchTasks(list, 'brain').map((t) => t.id)).toEqual(['aaaa']);
expect(searchTasks(list, 'BBBB').map((t) => t.id)).toEqual(['bbbb']);
expect(searchTasks(list, 'liver.dcm').map((t) => t.id)).toEqual(['bbbb']);
expect(searchTasks(list, 'nomatch')).toEqual([]);
});
});
describe('groupTasks', () => {
const list = [
mkTask({ id: '1', currentStageId: 's1', currentStageName: 'Gán nhãn', assigneeUserId: 'u1', assigneeName: 'A' }),
mkTask({ id: '2', currentStageId: 's1', currentStageName: 'Gán nhãn', assigneeUserId: null, assigneeName: null }),
mkTask({ id: '3', currentStageId: 's2', currentStageName: 'Rà soát', pipelineState: 'inReview', assigneeUserId: 'u1', assigneeName: 'A' }),
];
it('none yields a single flat group', () => {
const g = groupTasks(list, 'none');
expect(g).toHaveLength(1);
expect(g[0].tasks).toHaveLength(3);
});
it('by stage / assignee / state partitions the list and preserves counts', () => {
expect(groupTasks(list, 'stage').map((g) => g.tasks.length).sort()).toEqual([1, 2]);
const byAssignee = groupTasks(list, 'assignee');
expect(byAssignee.find((g) => g.label === 'Chưa giao')?.tasks).toHaveLength(1);
const byState = groupTasks(list, 'state');
expect(byState.map((g) => g.key).sort()).toEqual(['inLabel', 'inReview']);
});
});
describe('paginate', () => {
const items = Array.from({ length: 25 }, (_, i) => i);
it('slices a page and reports 1-based from/to + clamps overflow', () => {
const p1 = paginate(items, 1, 10);
expect(p1.items).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect([p1.from, p1.to, p1.pageCount, p1.total]).toEqual([1, 10, 3, 25]);
const last = paginate(items, 3, 10);
expect(last.items).toEqual([20, 21, 22, 23, 24]);
expect([last.from, last.to]).toEqual([21, 25]);
expect(paginate(items, 99, 10).page).toBe(3); // clamped
});
it('empty input reports from/to 0 and one page', () => {
expect(paginate([], 1, 20)).toMatchObject({ from: 0, to: 0, pageCount: 1, total: 0 });
});
});
describe('tasksAssignedByStage', () => {
const list = [
mkTask({ assigneeUserId: 'u1', currentStageId: 's1', currentStageName: 'Gán nhãn' }),
mkTask({ assigneeUserId: 'u1', currentStageId: 's1', currentStageName: 'Gán nhãn' }),
mkTask({ assigneeUserId: 'u1', currentStageId: 's2', currentStageName: 'Rà soát' }),
mkTask({ assigneeUserId: 'u2', currentStageId: 's1', currentStageName: 'Gán nhãn' }),
];
it('counts only the users tasks, grouped by stage', () => {
const r = tasksAssignedByStage(list, 'u1');
expect(r.total).toBe(3);
expect(r.rows.find((x) => x.stageId === 's1')?.count).toBe(2);
expect(r.rows.find((x) => x.stageId === 's2')?.count).toBe(1);
});
it('returns empty for a null user', () => {
expect(tasksAssignedByStage(list, null)).toEqual({ rows: [], total: 0 });
});
});
describe('shortId + stageTone', () => {
it('shortId truncates long ids with an ellipsis and leaves short ones', () => {
expect(shortId('abc')).toBe('abc');
expect(shortId('0123456789-0123456789-0123456789')).toBe('0123456789-0123456…');
});
it('stageTone maps review→review and everything else→label', () => {
expect(stageTone('review')).toBe('review');
expect(stageTone('label')).toBe('label');
expect(stageTone(undefined)).toBe('label');
});
});
@@ -0,0 +1,216 @@
/**
* Project-workflow — task view-model (pure domain).
*
* The server owns the pipeline state machine (be0 imagehub_task_pipeline); this module owns the
* *display* derivations the Data Page needs: the Vietnamese label + badge tone for each state, and
* which actions are valid for a task in a given state. Pure + framework-free so it is unit-tested
* with plain data (mirrors the data-import feature's pure domain layer).
*/
import type { DatasetTask, TaskPipelineState, TaskQueueStatus } from '@ump/shared';
export type BadgeTone = 'label' | 'review' | 'done' | 'issue' | 'warn' | 'muted';
export const PIPELINE_STATE_META: Record<TaskPipelineState, { label: string; tone: BadgeTone }> = {
inLabel: { label: 'Gán nhãn', tone: 'label' },
inReview: { label: 'Rà soát', tone: 'review' },
groundTruth: { label: 'Hoàn tất', tone: 'done' },
issue: { label: 'Sự cố', tone: 'issue' },
};
export const QUEUE_STATUS_META: Record<TaskQueueStatus, { label: string; tone: BadgeTone }> = {
assigned: { label: 'Đã giao', tone: 'muted' },
saved: { label: 'Đang làm', tone: 'label' },
pendingFinalization: { label: 'Chờ hoàn tất', tone: 'warn' },
skipped: { label: 'Bỏ qua', tone: 'muted' },
};
/** The minimal task shape the action rules read. */
export type TaskActionInput = {
pipelineState: TaskPipelineState;
queueStatus: TaskQueueStatus;
assigneeUserId: string | null;
};
export type TaskActions = {
canFinalize: boolean;
canReview: boolean;
canSkip: boolean;
canAssign: boolean;
canUnassign: boolean;
canSetReference: boolean;
/** Ground Truth — terminal; the pipeline is done. */
isTerminal: boolean;
};
/** Which controls a task row should offer, derived purely from its state. */
export function allowedActions(task: TaskActionInput): TaskActions {
const inLabel = task.pipelineState === 'inLabel';
const inReview = task.pipelineState === 'inReview';
const isGroundTruth = task.pipelineState === 'groundTruth';
const skipped = task.queueStatus === 'skipped';
return {
canFinalize: inLabel,
canReview: inReview,
canSkip: (inLabel || inReview) && !skipped,
canAssign: !task.assigneeUserId && !isGroundTruth,
canUnassign: !!task.assigneeUserId,
canSetReference: isGroundTruth,
isTerminal: isGroundTruth,
};
}
/** Clamp a user-entered priority to the server's accepted range [0, 1]. */
export function clampPriority(value: number): number {
if (Number.isNaN(value)) return 0;
return Math.max(0, Math.min(1, value));
}
// ---------------------------------------------------------------------------
// Data Page list view-model — scope / search / assignee / grouping / paging.
//
// The server returns the dataset's whole task array (no server-side filtering,
// grouping or paging yet), so the Data Page derives every view here, purely, on
// the client. Fine at current dataset sizes; if a dataset outgrows one page,
// promote these to `?q=&offset=&limit=` query params on GET /tasks.
// ---------------------------------------------------------------------------
/** Region 2 "Assigned tasks" scope: everything / my queue / unassigned / by pipeline state. */
export type TaskScope = 'all' | 'mine' | 'unassigned' | TaskPipelineState;
export const SCOPE_OPTIONS: Array<{ value: TaskScope; label: string }> = [
{ value: 'all', label: 'Tất cả công việc' },
{ value: 'mine', label: 'Việc của tôi' },
{ value: 'unassigned', label: 'Chưa giao' },
{ value: 'inLabel', label: 'Đang gán nhãn' },
{ value: 'inReview', label: 'Đang rà soát' },
{ value: 'groundTruth', label: 'Đã hoàn tất' },
];
export function matchesScope(task: DatasetTask, scope: TaskScope, currentUserId: string | null): boolean {
switch (scope) {
case 'all':
return true;
case 'mine':
return !!currentUserId && task.assigneeUserId === currentUserId;
case 'unassigned':
return !task.assigneeUserId;
default:
return task.pipelineState === scope; // any TaskPipelineState
}
}
/** Region 3 search — case-insensitive substring over name, logical path, and task id. */
export function searchTasks(tasks: DatasetTask[], query: string): DatasetTask[] {
const q = query.trim().toLowerCase();
if (!q) return tasks;
return tasks.filter(
(t) =>
(t.name ?? '').toLowerCase().includes(q) ||
(t.fileLogicalPath ?? '').toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q),
);
}
/** Region 2 "All Users": every assignee / unassigned only / a specific member id. */
export type AssigneeFilter = 'all' | 'unassigned' | string;
export function matchesAssignee(task: DatasetTask, filter: AssigneeFilter): boolean {
if (filter === 'all') return true;
if (filter === 'unassigned') return !task.assigneeUserId;
return task.assigneeUserId === filter;
}
/** Region 2 "No grouping" → group the visible rows by a facet. */
export type GroupBy = 'none' | 'stage' | 'assignee' | 'state';
export const GROUP_OPTIONS: Array<{ value: GroupBy; label: string }> = [
{ value: 'none', label: 'Không nhóm' },
{ value: 'stage', label: 'Theo giai đoạn' },
{ value: 'assignee', label: 'Theo phụ trách' },
{ value: 'state', label: 'Theo trạng thái' },
];
export type TaskGroup = { key: string; label: string; tasks: DatasetTask[] };
/** Group tasks for display. 'none' yields a single unlabelled group (caller renders it flat). */
export function groupTasks(tasks: DatasetTask[], groupBy: GroupBy): TaskGroup[] {
if (groupBy === 'none') return [{ key: '__all__', label: '', tasks }];
const groups = new Map<string, TaskGroup>();
for (const t of tasks) {
let key: string;
let label: string;
if (groupBy === 'stage') {
key = t.currentStageId ?? '__none__';
label = t.currentStageName ?? 'Chưa có giai đoạn';
} else if (groupBy === 'assignee') {
key = t.assigneeUserId ?? '__none__';
label = t.assigneeName ?? 'Chưa giao';
} else {
key = t.pipelineState;
label = PIPELINE_STATE_META[t.pipelineState].label;
}
const g = groups.get(key);
if (g) g.tasks.push(t);
else groups.set(key, { key, label, tasks: [t] });
}
return Array.from(groups.values());
}
/** Region 7 pagination over a flat list. Page is 1-based and clamped to [1, pageCount]. */
export type PageSlice<T> = {
items: T[];
page: number;
pageCount: number;
total: number;
/** 1-based index of the first item shown (0 when empty). */
from: number;
/** 1-based index of the last item shown (0 when empty). */
to: number;
};
export function paginate<T>(items: T[], page: number, pageSize: number): PageSlice<T> {
const total = items.length;
const size = Math.max(1, pageSize);
const pageCount = Math.max(1, Math.ceil(total / size));
const safePage = Math.min(Math.max(1, page), pageCount);
const start = (safePage - 1) * size;
const slice = items.slice(start, start + size);
return {
items: slice,
page: safePage,
pageCount,
total,
from: total === 0 ? 0 : start + 1,
to: start + slice.length,
};
}
/** Region 1 sidebar "Tasks assigned" — count a user's tasks by current stage. */
export type StageCount = { stageId: string; stageName: string; count: number };
export function tasksAssignedByStage(
tasks: DatasetTask[],
userId: string | null,
): { rows: StageCount[]; total: number } {
if (!userId) return { rows: [], total: 0 };
const mine = tasks.filter((t) => t.assigneeUserId === userId);
const byStage = new Map<string, StageCount>();
for (const t of mine) {
const stageId = t.currentStageId ?? '__none__';
const stageName = t.currentStageName ?? 'Hoàn tất';
const row = byStage.get(stageId);
if (row) row.count += 1;
else byStage.set(stageId, { stageId, stageName, count: 1 });
}
return { rows: Array.from(byStage.values()), total: mine.length };
}
/** Region 5 — a short, stable prefix of a task UUID for the "Task Id" column. */
export function shortId(id: string, len = 18): string {
return id.length > len ? `${id.slice(0, len)}` : id;
}
/** Tone a stage chip by its kind (label vs review); used when the stage's kind is known. */
export function stageTone(kind: 'label' | 'review' | undefined): BadgeTone {
return kind === 'review' ? 'review' : 'label';
}
@@ -0,0 +1,343 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
ArrowLeft,
MousePointer2,
Square,
Circle,
PenLine,
Brush,
Hexagon,
Trash2,
Save,
CheckCircle2,
Check,
X,
SkipForward,
Star,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { UnifiedQuadViewRenderer } from '@ump/shared/viewer';
import type { Annotation, AnnotationTool as AnnTool } from '@ump/shared/viewer';
import {
Badge,
Button,
TooltipProvider,
cn,
fileDownloadUrl,
fetchAsFile,
getTask,
saveTask,
finalizeTask,
reviewTask,
skipTask,
setTaskReference,
} from '@ump/shared';
import {
PIPELINE_STATE_META,
QUEUE_STATUS_META,
allowedActions,
type BadgeTone,
} from '../domain/taskView';
const DEFAULTS = { windowWidth: 400, windowLevel: 40 };
const TONE_CLASS: Record<BadgeTone, string> = {
label: 'border-primary/30 bg-primary/10 text-primary',
review: 'border-accent/30 bg-accent/10 text-accent',
done: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
issue: 'border-destructive/30 bg-destructive/10 text-destructive',
warn: 'border-amber-500/30 bg-amber-500/10 text-amber-600',
muted: 'border-border bg-muted text-muted-foreground',
};
function ToneBadge({ tone, children }: { tone: BadgeTone; children: ReactNode }) {
return (
<Badge variant="outline" className={cn('font-normal', TONE_CLASS[tone])}>
{children}
</Badge>
);
}
const TOOLS: { id: AnnTool; label: string; Icon: typeof Square }[] = [
{ id: 'none', label: 'Chọn', Icon: MousePointer2 },
{ id: 'bbox', label: 'Khung', Icon: Square },
{ id: 'points', label: 'Điểm', Icon: Circle },
{ id: 'pen', label: 'Bút', Icon: PenLine },
{ id: 'brush', label: 'Cọ', Icon: Brush },
{ id: 'polygon', label: 'Đa giác', Icon: Hexagon },
];
const TOOL_LABEL: Record<string, string> = {
bbox: 'Khung', points: 'Điểm', pen: 'Bút', brush: 'Cọ', polygon: 'Đa giác',
};
const VIEW_LABEL: Record<string, string> = { axial: 'Axial', coronal: 'Coronal', sagittal: 'Sagittal' };
/**
* AnnotationTool — work a single task in the 3D viewer (project-workflow §12.3).
*
* Loads the task + its image, renders the shared VTK quad-view + the 5-tool annotation overlay,
* and drives the queue/pipeline from here: Save (Q3) / Hoàn tất (TP1 finalize) / Chấp nhận (TP2) /
* Từ chối (TP3) / Bỏ qua (Q2). Annotations persist as JSON (load on open, save on Save/transition).
* Ground-Truth tasks open read-only. Statically imports `@ump/shared/viewer` (VTK), so it is
* lazy-loaded as a route in App.tsx.
*/
export function AnnotationTool() {
const { id = '', taskId = '' } = useParams();
const navigate = useNavigate();
const taskQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'task', taskId],
queryFn: () => getTask(id, taskId),
enabled: !!id && !!taskId,
});
const task = taskQ.data;
const [file, setFile] = useState<File | null>(null);
const [fileError, setFileError] = useState(false);
const [tool, setTool] = useState<AnnTool>('none');
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const [windowWidth, setWindowWidth] = useState(DEFAULTS.windowWidth);
const [windowLevel, setWindowLevel] = useState(DEFAULTS.windowLevel);
const [busy, setBusy] = useState(false);
// Load the task's saved annotations once per task — keyed on id so a refetch (after Save) doesn't
// clobber the in-canvas edits.
useEffect(() => {
if (task) setAnnotations(Array.isArray(task.annotations) ? (task.annotations as Annotation[]) : []);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [task?.id]);
// Fetch the task's image file for the viewer.
useEffect(() => {
if (!task) return;
let cancelled = false;
setFile(null);
setFileError(false);
(async () => {
try {
const { url, logicalPath } = await fileDownloadUrl(id, task.fileId);
const f = await fetchAsFile(url, logicalPath);
if (!cancelled) setFile(f);
} catch {
if (!cancelled) setFileError(true);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [task?.fileId, id]);
const actions = task ? allowedActions(task) : null;
const readOnly = actions?.isTerminal ?? false;
const effectiveTool: AnnTool = readOnly ? 'none' : tool;
const run = async (label: string, fn: () => Promise<unknown>, opts?: { back?: boolean }) => {
setBusy(true);
try {
await fn();
toast.success(label);
if (opts?.back) navigate(`/dashboard/datasets/${id}/tasks`);
else await taskQ.refetch();
} catch (e) {
toast.error((e as { message?: string })?.message || 'Thao tác không thành công');
} finally {
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Top bar: identity + status, window/level, actions */}
<div className="flex shrink-0 flex-wrap items-center justify-between gap-x-6 gap-y-2 border-b px-4 py-2.5">
<div className="flex min-w-0 items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate(`/dashboard/datasets/${id}/tasks`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Công việc
</Button>
<span className="truncate font-mono text-sm" title={task?.fileLogicalPath}>
{task?.name || task?.fileLogicalPath || '…'}
</span>
{task && (
<ToneBadge tone={PIPELINE_STATE_META[task.pipelineState].tone}>
{PIPELINE_STATE_META[task.pipelineState].label}
</ToneBadge>
)}
{task && !readOnly && (
<ToneBadge tone={QUEUE_STATUS_META[task.queueStatus].tone}>
{QUEUE_STATUS_META[task.queueStatus].label}
</ToneBadge>
)}
{task?.currentStageName && (
<span className="text-xs text-muted-foreground">· {task.currentStageName}</span>
)}
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-xs text-muted-foreground">
<label className="flex items-center gap-2">
<span>WW</span>
<input type="range" min={1} max={4000} value={windowWidth}
onChange={(e) => setWindowWidth(Number(e.target.value))} className="w-24 accent-primary" />
<span className="w-10 tabular-nums text-foreground">{windowWidth}</span>
</label>
<label className="flex items-center gap-2">
<span>WL</span>
<input type="range" min={-1000} max={3000} value={windowLevel}
onChange={(e) => setWindowLevel(Number(e.target.value))} className="w-24 accent-primary" />
<span className="w-10 tabular-nums text-foreground">{windowLevel}</span>
</label>
</div>
<div className="flex items-center gap-2">
{actions && !readOnly && (
<Button variant="outline" size="sm" disabled={busy}
onClick={() => run('Đã lưu chú thích', () => saveTask(id, taskId, annotations, false))}>
<Save className="mr-1 h-4 w-4" /> Lưu
</Button>
)}
{actions?.canFinalize && (
<Button size="sm" disabled={busy}
onClick={() => run('Đã hoàn tất', async () => {
await saveTask(id, taskId, annotations, true);
await finalizeTask(id, taskId);
}, { back: true })}>
<CheckCircle2 className="mr-1 h-4 w-4" /> Hoàn tất
</Button>
)}
{actions?.canReview && (
<>
<Button size="sm" disabled={busy}
onClick={() => run('Đã chấp nhận', async () => {
await saveTask(id, taskId, annotations, false);
await reviewTask(id, taskId, 'accept');
}, { back: true })}>
<Check className="mr-1 h-4 w-4" /> Chấp nhận
</Button>
<Button variant="outline" size="sm" disabled={busy}
onClick={() => run('Đã từ chối', () => reviewTask(id, taskId, 'reject'), { back: true })}>
<X className="mr-1 h-4 w-4" /> Từ chối
</Button>
</>
)}
{actions?.canSkip && (
<Button variant="ghost" size="icon" className="h-9 w-9" title="Bỏ qua" disabled={busy}
onClick={() => run('Đã bỏ qua', () => skipTask(id, taskId), { back: true })}>
<SkipForward className="h-4 w-4" />
</Button>
)}
{actions?.canSetReference && (
<Button variant={task?.isReferenceStandard ? 'secondary' : 'outline'} size="sm" disabled={busy}
onClick={() => run(task?.isReferenceStandard ? 'Đã bỏ chuẩn' : 'Đã đặt chuẩn',
() => setTaskReference(id, taskId, !task?.isReferenceStandard))}>
<Star className={cn('mr-1 h-4 w-4', task?.isReferenceStandard && 'fill-amber-400 text-amber-500')} />
{task?.isReferenceStandard ? 'Bỏ chuẩn' : 'Đặt chuẩn'}
</Button>
)}
</div>
</div>
{/* Body: viewer + annotation side panel */}
<div className="flex min-h-0 flex-1">
<TooltipProvider>
<div className="relative min-w-0 flex-1 bg-black">
{taskQ.isLoading && (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> Đang tải công việc
</div>
)}
{taskQ.isError && (
<div className="flex h-full items-center justify-center text-destructive">Không tải đưc công việc.</div>
)}
{fileError && (
<div className="flex h-full items-center justify-center text-destructive">Không tải đưc tệp nh.</div>
)}
{task && file && !fileError && (
<UnifiedQuadViewRenderer
files={[file]}
windowWidth={windowWidth}
windowLevel={windowLevel}
opacity={0.8}
annotationTool={effectiveTool}
annotations={annotations}
onAnnotationsChange={setAnnotations}
/>
)}
{task && !file && !fileError && (
<div className="flex h-full items-center justify-center text-muted-foreground">
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> Đang tải nh
</div>
)}
</div>
</TooltipProvider>
<aside className="flex w-72 shrink-0 flex-col border-l bg-background">
<div className="border-b px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">Công cụ chú thích</h3>
<p className="mt-0.5 text-[11px] text-muted-foreground">
{readOnly
? 'Tác vụ đã hoàn tất — chỉ xem.'
: 'Vẽ trên các lát cắt 2D (axial / coronal / sagittal).'}
</p>
</div>
{!readOnly && (
<div className="grid grid-cols-3 gap-2 p-3">
{TOOLS.map(({ id: tid, label, Icon }) => (
<button key={tid} type="button" onClick={() => setTool(tid)}
className={cn(
'flex flex-col items-center gap-1 rounded-md border px-2 py-2 text-[11px] transition',
tool === tid
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:bg-accent',
)}>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
)}
<div className="flex items-center justify-between border-y px-4 py-2">
<span className="text-xs font-medium text-foreground">Chú thích ({annotations.length})</span>
{!readOnly && annotations.length > 0 && (
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => setAnnotations([])}>
<Trash2 className="mr-1 h-3.5 w-3.5" /> Xóa hết
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{annotations.length === 0 ? (
<p className="px-2 py-6 text-center text-[11px] leading-relaxed text-muted-foreground">
{readOnly
? 'Không có chú thích.'
: 'Chọn một công cụ rồi vẽ trên lát cắt. (Đa giác: bấm từng đỉnh, nhấn đúp để đóng.)'}
</p>
) : (
<ul className="space-y-1">
{annotations.map((an) => (
<li key={an.id} className="flex items-center justify-between gap-2 rounded border px-2 py-1 text-[11px]">
<span className="flex min-w-0 items-center gap-1.5">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: an.color }} />
<span className="truncate">
{TOOL_LABEL[an.tool]} · {VIEW_LABEL[an.view]} · lát {an.sliceIndex + 1}
</span>
</span>
{!readOnly && (
<button type="button" onClick={() => setAnnotations(annotations.filter((x) => x.id !== an.id))}
className="shrink-0 text-muted-foreground hover:text-destructive" title="Xóa">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</li>
))}
</ul>
)}
</div>
</aside>
</div>
</div>
);
}
export default AnnotationTool;
@@ -0,0 +1,538 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ClipboardCheck,
Download,
LayoutDashboard,
ListChecks,
Play,
RefreshCw,
Search,
Settings,
Sparkles,
Upload,
X,
} from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
Card,
CardContent,
Checkbox,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
cn,
detailFromApiError,
useAuth,
getDataset,
listDatasetStages,
listMembers,
listTasks,
generateTasks,
assignTask,
VISIBILITY_LABEL,
type DatasetTask,
} from '@ump/shared';
import { UploadDataDialog } from '../../data-import/presentation/components/UploadDataDialog';
import {
SCOPE_OPTIONS,
GROUP_OPTIONS,
matchesScope,
matchesAssignee,
searchTasks,
groupTasks,
paginate,
type TaskScope,
type GroupBy,
type AssigneeFilter,
} from '../domain/taskView';
import { ProductivitySidebar } from './ProductivitySidebar';
import { TaskRow } from './TaskRow';
const PAGE_SIZES = [10, 20, 50, 100];
/** Build a CSV of the (filtered) task list and trigger a download. */
function downloadCsv(filename: string, tasks: DatasetTask[]) {
const header = ['Datapoint', 'Giai đoạn', 'Task Id', 'Phụ trách', 'Trạng thái'];
const esc = (v: string) => `"${v.replace(/"/g, '""')}"`;
const lines = tasks.map((t) =>
[t.name || t.fileLogicalPath, t.currentStageName ?? '', t.id, t.assigneeName ?? '', t.pipelineState]
.map((c) => esc(String(c)))
.join(','),
);
const csv = [header.map(esc).join(','), ...lines].join('\n');
const url = URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url;
a.download = `${filename || 'cong-viec'}.csv`;
a.click();
URL.revokeObjectURL(url);
}
/** The Data Page — a project's task list (project-workflow §12.3) in the RedBrick-style layout. */
export function DataPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const { user } = useAuth();
const userId = user?.id ?? null;
const [scope, setScope] = useState<TaskScope>('all');
const [groupBy, setGroupBy] = useState<GroupBy>('none');
const [assigneeFilter, setAssigneeFilter] = useState<AssigneeFilter>('all');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [uploadOpen, setUploadOpen] = useState(false);
const dsQ = useQuery({ queryKey: ['imagehub', 'dataset', id], queryFn: () => getDataset(id), enabled: !!id });
const stagesQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'stages'],
queryFn: () => listDatasetStages(id),
enabled: !!id,
});
const membersQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'members'],
queryFn: () => listMembers(id),
enabled: !!id,
});
// The whole task array — all filtering / grouping / paging is derived on the client.
const tasksQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'tasks'],
queryFn: () => listTasks(id),
enabled: !!id,
});
const ds = dsQ.data;
const stages = useMemo(() => stagesQ.data ?? [], [stagesQ.data]);
const members = useMemo(() => membersQ.data ?? [], [membersQ.data]);
const tasks = useMemo(() => tasksQ.data ?? [], [tasksQ.data]);
const noStages = stagesQ.isSuccess && stages.length === 0;
const generateMut = useMutation({
mutationFn: () => generateTasks(id),
onSuccess: (r) => {
toast.success(r.created > 0 ? `Đã tạo ${r.created} công việc` : 'Tất cả tệp đã có công việc');
qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id, 'tasks'] });
},
onError: (e: unknown) => toast.error(detailFromApiError(e, 'Không tạo được công việc')),
});
const bulkAssignMut = useMutation({
mutationFn: (assignee: string) => Promise.all([...selected].map((tid) => assignTask(id, tid, assignee))),
onSuccess: (_r, assignee) => {
const who = members.find((m) => m.userId === assignee);
toast.success(`Đã giao ${selected.size} công việc cho ${who?.fullName || who?.email || 'thành viên'}`);
setSelected(new Set());
qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id, 'tasks'] });
},
onError: (e: unknown) => toast.error(detailFromApiError(e, 'Giao việc hàng loạt thất bại')),
});
// Region 2/3 — scope → assignee → search, then region 7 paginate the flat list, then group the page.
const filtered = useMemo(() => {
const scoped = tasks.filter((t) => matchesScope(t, scope, userId) && matchesAssignee(t, assigneeFilter));
return searchTasks(scoped, query);
}, [tasks, scope, assigneeFilter, query, userId]);
const pageData = useMemo(() => paginate(filtered, page, pageSize), [filtered, page, pageSize]);
const groups = useMemo(() => groupTasks(pageData.items, groupBy), [pageData.items, groupBy]);
// Reset to page 1 whenever the result set changes shape.
useEffect(() => setPage(1), [scope, assigneeFilter, query, pageSize]);
const pageIds = pageData.items.map((t) => t.id);
const allSelected = pageIds.length > 0 && pageIds.every((i) => selected.has(i));
const someSelected = pageIds.some((i) => selected.has(i)) && !allSelected;
const toggleAll = (checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
pageIds.forEach((i) => (checked ? next.add(i) : next.delete(i)));
return next;
});
const toggleOne = (taskId: string, checked: boolean) =>
setSelected((prev) => {
const next = new Set(prev);
if (checked) next.add(taskId);
else next.delete(taskId);
return next;
});
const openFirst = (predicate: (t: DatasetTask) => boolean, emptyMsg: string) => {
const t = tasks.find(predicate);
if (!t) return toast.info(emptyMsg);
navigate(`/dashboard/datasets/${id}/tasks/${t.id}`);
};
const startLabeling = () =>
openFirst(
(t) => t.pipelineState === 'inLabel' && (t.assigneeUserId === userId || !t.assigneeUserId),
'Không có công việc cần gán nhãn.',
);
const startReview = () =>
openFirst(
(t) => t.pipelineState === 'inReview' && (t.assigneeUserId === userId || !t.assigneeUserId),
'Không có công việc cần rà soát.',
);
const colSpan = 8;
return (
<TooltipProvider delayDuration={200}>
<div className="mx-auto max-w-[1400px] space-y-5">
<Button variant="ghost" size="sm" onClick={() => navigate(`/dashboard/datasets/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> {ds?.name ?? 'Bộ dữ liệu'}
</Button>
{/* Header: title + tab nav (left) · toolbar (right) — region 4 */}
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<h1 className="font-serif text-2xl font-semibold text-foreground">{ds?.name ?? 'Công việc'}</h1>
{ds && <Badge variant="outline">{VISIBILITY_LABEL[ds.visibility]}</Badge>}
</div>
<nav className="flex items-center gap-1 text-sm">
{[
{ label: 'Tổng quan', to: `/dashboard/datasets/${id}`, active: false },
{ label: 'Dữ liệu', to: `/dashboard/datasets/${id}/tasks`, active: true },
{ label: 'Cài đặt', to: `/dashboard/datasets/${id}/settings`, active: false },
].map((t) => (
<button
key={t.label}
type="button"
onClick={() => !t.active && navigate(t.to)}
className={cn(
'rounded-md px-3 py-1 font-medium transition-colors',
t.active
? 'bg-foreground text-background'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
{t.label}
</button>
))}
</nav>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-full border border-border px-1">
{[
{ icon: RefreshCw, label: 'Tải lại', onClick: () => tasksQ.refetch(), spin: tasksQ.isFetching },
{
icon: LayoutDashboard,
label: 'Tổng quan',
onClick: () => navigate(`/dashboard/datasets/${id}`),
},
{
icon: Settings,
label: 'Cài đặt',
onClick: () => navigate(`/dashboard/datasets/${id}/settings`),
},
].map((b) => (
<Tooltip key={b.label}>
<TooltipTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8 rounded-full" onClick={b.onClick}>
<b.icon className={cn('h-4 w-4', b.spin && 'animate-spin')} />
</Button>
</TooltipTrigger>
<TooltipContent>{b.label}</TooltipContent>
</Tooltip>
))}
</div>
<Button variant="outline" onClick={() => setUploadOpen(true)}>
<Upload className="mr-2 h-4 w-4" /> Tải dữ liệu
</Button>
<Button variant="outline" onClick={startReview}>
<ClipboardCheck className="mr-2 h-4 w-4" /> soát
</Button>
<Button onClick={startLabeling}>
<Play className="mr-2 h-4 w-4" /> Bắt đu gán nhãn
</Button>
</div>
</div>
{dsQ.isError && <p className="text-sm text-destructive">Không tải đưc bộ dữ liệu.</p>}
<div className="flex flex-col gap-5 lg:flex-row">
<ProductivitySidebar
datasetId={id}
tasks={tasks}
members={members}
currentUser={user ? { id: user.id, name: user.name || user.email, role: 'Tôi' } : null}
/>
<div className="min-w-0 flex-1 space-y-4">
{noStages ? (
<Card>
<CardContent className="space-y-3 py-12 text-center">
<ListChecks className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mx-auto max-w-md text-sm text-muted-foreground">
Bộ dữ liệu chưa giai đoạn quy trình. Hãy thêm giai đoạn gán nhãn / kiểm duyệt trong Cài đt
trước khi tạo công việc.
</p>
<Button onClick={() => navigate(`/dashboard/datasets/${id}/settings`)}>
<Settings className="mr-2 h-4 w-4" /> Mở Cài đt
</Button>
</CardContent>
</Card>
) : (
<>
{/* Filter bar (region 2) + search (region 3) */}
<div className="flex flex-wrap items-center gap-2">
<Select value={scope} onValueChange={(v) => setScope(v as TaskScope)}>
<SelectTrigger className="h-9 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as GroupBy)}>
<SelectTrigger className="h-9 w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GROUP_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={assigneeFilter} onValueChange={(v) => setAssigneeFilter(v)}>
<SelectTrigger className="h-9 w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tất cả người dùng</SelectItem>
<SelectItem value="unassigned">Chưa giao</SelectItem>
{members.map((m) => (
<SelectItem key={m.userId} value={m.userId}>
{m.fullName || m.email}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative ml-auto">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Tìm theo Task Id hoặc tên"
className="h-9 w-64 pl-8"
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
disabled={filtered.length === 0}
onClick={() => downloadCsv(ds?.name ?? 'cong-viec', filtered)}
>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Xuất CSV</TooltipContent>
</Tooltip>
<Button onClick={() => generateMut.mutate()} disabled={generateMut.isPending}>
<Sparkles className="mr-2 h-4 w-4" />
{generateMut.isPending ? 'Đang tạo…' : 'Tạo công việc'}
</Button>
</div>
{/* Bulk-selection bar (region 6) */}
{selected.size > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-sm">
<span className="font-medium text-foreground">Đã chọn {selected.size}</span>
<Select onValueChange={(v) => bulkAssignMut.mutate(v)} disabled={bulkAssignMut.isPending}>
<SelectTrigger className="h-8 w-48">
<SelectValue placeholder="Giao cho…" />
</SelectTrigger>
<SelectContent>
{members.map((m) => (
<SelectItem key={m.userId} value={m.userId}>
{m.fullName || m.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8" onClick={() => setSelected(new Set())}>
<X className="mr-1 h-4 w-4" /> Bỏ chọn
</Button>
</div>
)}
{tasksQ.isLoading ? (
<p className="text-sm text-muted-foreground">Đang tải</p>
) : filtered.length === 0 ? (
<Card>
<CardContent className="space-y-3 py-12 text-center">
<ListChecks className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{tasks.length === 0
? 'Chưa có công việc nào. Nhấn “Tạo công việc” để tạo từ các tệp ảnh trong bộ dữ liệu.'
: 'Không có công việc nào khớp bộ lọc.'}
</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Chọn tất cả trên trang"
/>
</TableHead>
<TableHead>Datapoint</TableHead>
<TableHead>Giai đoạn</TableHead>
<TableHead className="w-16">Tệp</TableHead>
<TableHead>Task Id</TableHead>
<TableHead>Phụ trách</TableHead>
<TableHead className="w-16">Điểm</TableHead>
<TableHead className="text-right">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((g) => (
<GroupRows
key={g.key}
label={groupBy === 'none' ? null : `${g.label} (${g.tasks.length})`}
colSpan={colSpan}
>
{g.tasks.map((t) => (
<TaskRow
key={t.id}
datasetId={id}
task={t}
members={members}
stages={stages}
selected={selected.has(t.id)}
onSelectedChange={(c) => toggleOne(t.id, c)}
/>
))}
</GroupRows>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Pagination footer (region 7) */}
{filtered.length > 0 && (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={pageData.page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span>
Trang {pageData.page} / {pageData.pageCount}
</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
disabled={pageData.page >= pageData.pageCount}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="ml-2">
Hiển thị
<Select value={String(pageSize)} onValueChange={(v) => setPageSize(Number(v))}>
<SelectTrigger className="mx-2 inline-flex h-8 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
/ trang
</span>
</div>
<span>Tổng {filtered.length} mục</span>
</div>
)}
</>
)}
</div>
</div>
</div>
{uploadOpen && (
<UploadDataDialog
open
onOpenChange={setUploadOpen}
datasetId={id}
onCompleted={() => qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id] })}
/>
)}
</TooltipProvider>
);
}
/** Renders an optional group-header row followed by its task rows (region 2 grouping). */
function GroupRows({
label,
colSpan,
children,
}: {
label: string | null;
colSpan: number;
children: React.ReactNode;
}) {
return (
<>
{label && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<td colSpan={colSpan} className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</td>
</TableRow>
)}
{children}
</>
);
}
export default DataPage;
@@ -0,0 +1,51 @@
import { cn } from '@ump/shared';
// A deterministic palette so the same person keeps the same colour across the page.
const COLORS = [
'bg-rose-500',
'bg-orange-500',
'bg-amber-500',
'bg-emerald-500',
'bg-teal-500',
'bg-sky-500',
'bg-indigo-500',
'bg-fuchsia-500',
];
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return '?';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function colorFor(seed: string): string {
let hash = 0;
for (let i = 0; i < seed.length; i += 1) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
return COLORS[Math.abs(hash) % COLORS.length];
}
/** A small initials chip standing in for a user avatar (assignee column + sidebar header). */
export function InitialsAvatar({
name,
seed,
className,
}: {
name: string;
/** Stable colour key — pass a user id so renames don't change the colour. */
seed?: string;
className?: string;
}) {
return (
<span
className={cn(
'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[10px] font-semibold text-white',
colorFor(seed ?? name),
className,
)}
aria-hidden
>
{initials(name)}
</span>
);
}
@@ -0,0 +1,201 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ChevronDown, ChevronUp } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
getReviewStats,
type DatasetMember,
type DatasetTask,
} from '@ump/shared';
import { tasksAssignedByStage } from '../domain/taskView';
import { InitialsAvatar } from './InitialsAvatar';
type SidebarUser = { id: string; name: string; role: string };
const DATE_RANGES = [
{ value: '7', label: '7 ngày qua' },
{ value: '30', label: '30 ngày qua' },
{ value: '90', label: '90 ngày qua' },
];
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(true);
return (
<div className="border-t border-border pt-3">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-wide text-muted-foreground"
>
{title}
{open ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
</button>
{open && <div className="mt-2 space-y-1.5">{children}</div>}
</div>
);
}
function MetricRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums text-foreground">{value}</span>
</div>
);
}
/**
* Region 1 — the per-user productivity panel. The user picker, "Tasks assigned" by stage, and the
* accept/reject/corrections counts (from the review-events backend, migration 025) are real; the
* time-based metrics remain placeholders until the backend records per-action labeling/review time.
*/
export function ProductivitySidebar({
datasetId,
tasks,
members,
currentUser,
}: {
datasetId: string;
tasks: DatasetTask[];
members: DatasetMember[];
currentUser: SidebarUser | null;
}) {
const [days, setDays] = useState('7');
// Whoever the picker is pointed at — defaults to the signed-in user. The option list merges the
// current user with the dataset members (deduped by id).
const people = useMemo<SidebarUser[]>(() => {
const map = new Map<string, SidebarUser>();
if (currentUser) map.set(currentUser.id, currentUser);
for (const m of members) {
if (!map.has(m.userId)) {
map.set(m.userId, { id: m.userId, name: m.fullName || m.email, role: m.role });
}
}
return Array.from(map.values());
}, [currentUser, members]);
const [userId, setUserId] = useState<string>(currentUser?.id ?? '');
const selected = people.find((p) => p.id === userId) ?? currentUser ?? people[0] ?? null;
const assigned = useMemo(
() => tasksAssignedByStage(tasks, selected?.id ?? null),
[tasks, selected?.id],
);
const rangeLabel = DATE_RANGES.find((r) => r.value === days)?.label ?? '7 ngày qua';
// Real review-decision tallies for the selected reviewer over the chosen window.
const reviewStatsQ = useQuery({
queryKey: ['imagehub', 'review-stats', datasetId, selected?.id ?? null, days],
queryFn: () => getReviewStats(datasetId, { userId: selected?.id, days: Number(days) }),
enabled: !!datasetId && !!selected,
});
const rs = reviewStatsQ.data;
const reviewTotal = (rs?.accepted ?? 0) + (rs?.acceptWithCorrections ?? 0) + (rs?.rejected ?? 0);
const metrics: Array<{ label: string; value: string }> = [
{ label: 'Lượt được chấp nhận', value: String(rs?.accepted ?? 0) },
{ label: 'Lượt chỉnh sửa', value: String(rs?.acceptWithCorrections ?? 0) },
{ label: 'Lượt bị từ chối', value: String(rs?.rejected ?? 0) },
// Time-based metrics need per-action timing events the backend does not record yet.
{ label: 'TG gán nhãn TB', value: '0s' },
{ label: 'Tổng TG gán nhãn', value: '0s' },
{ label: 'TG rà soát TB', value: '0s' },
{ label: 'Tổng TG rà soát', value: '0s' },
];
return (
<aside className="w-full shrink-0 space-y-4 rounded-xl border border-border bg-card p-4 lg:w-72">
{/* User header + picker */}
<div className="space-y-3">
{selected ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-2 rounded-md p-1 text-left transition-colors hover:bg-muted"
>
<InitialsAvatar name={selected.name} seed={selected.id} className="h-9 w-9 text-xs" />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground">{selected.name}</span>
<span className="truncate text-xs text-muted-foreground">{selected.role || 'Thành viên'}</span>
</span>
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{people.map((p) => (
<DropdownMenuItem key={p.id} onClick={() => setUserId(p.id)}>
<InitialsAvatar name={p.name} seed={p.id} className="mr-2 h-5 w-5" />
<span className="truncate">{p.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<p className="text-sm text-muted-foreground">Không người dùng.</p>
)}
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Khoảng thời gian</span>
<Select value={days} onValueChange={setDays}>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_RANGES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Section title="Công việc được giao">
{assigned.rows.length === 0 ? (
<p className="text-sm text-muted-foreground">Chưa công việc nào đưc giao.</p>
) : (
<>
{assigned.rows.map((r) => (
<div key={r.stageId} className="flex items-center justify-between text-sm">
<span className="text-foreground">{r.stageName}</span>
<span className="tabular-nums font-medium text-foreground">{r.count}</span>
</div>
))}
<div className="flex items-center justify-between border-t border-border pt-1.5 text-sm">
<span className="text-muted-foreground">Tổng</span>
<span className="tabular-nums font-semibold text-foreground">{assigned.total}</span>
</div>
</>
)}
</Section>
<Section title="Năng suất">
{reviewTotal === 0 && (
<p className="text-xs italic text-muted-foreground">Không lượt soát trong {rangeLabel}</p>
)}
{metrics.map((m) => (
<MetricRow key={m.label} label={m.label} value={m.value} />
))}
</Section>
</aside>
);
}
export default ProductivitySidebar;
@@ -0,0 +1,306 @@
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Ban, Check, Copy, FileImage, Maximize2, MoreHorizontal, Star } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
Checkbox,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
TableCell,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
cn,
detailFromApiError,
assignTask,
unassignTask,
finalizeTask,
reviewTask,
skipTask,
setTaskPriority,
setTaskReference,
type DatasetMember,
type DatasetStage,
type DatasetTask,
} from '@ump/shared';
import { PIPELINE_STATE_META, allowedActions, shortId, stageTone, type BadgeTone } from '../domain/taskView';
import { InitialsAvatar } from './InitialsAvatar';
const TONE_CLASS: Record<BadgeTone, string> = {
label: 'border-primary/30 bg-primary/10 text-primary',
review: 'border-accent/30 bg-accent/10 text-accent',
done: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600',
issue: 'border-destructive/30 bg-destructive/10 text-destructive',
warn: 'border-amber-500/30 bg-amber-500/10 text-amber-600',
muted: 'border-border bg-muted text-muted-foreground',
};
const NONE = '__none__';
/** Map a free priority float onto the three presets offered in the overflow menu. */
function priorityBucket(p: number): 'high' | 'normal' | 'low' {
if (p >= 0.75) return 'high';
if (p <= 0.25) return 'low';
return 'normal';
}
const PRESET_VALUE: Record<'high' | 'normal' | 'low', number> = { high: 1, normal: 0.5, low: 0 };
/** One task row — region 5 columns + region 6 inline assignee, score, expand and overflow actions. */
export function TaskRow({
datasetId,
task,
members,
stages,
selected,
onSelectedChange,
}: {
datasetId: string;
task: DatasetTask;
members: DatasetMember[];
stages: DatasetStage[];
selected: boolean;
onSelectedChange: (checked: boolean) => void;
}) {
const qc = useQueryClient();
const navigate = useNavigate();
const invalidate = () => qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', datasetId, 'tasks'] });
// One mutation drives every row action; the specific API call is passed in. Server 409 guards
// surface via the toast.
const runMut = useMutation({
mutationFn: (fn: () => Promise<DatasetTask>) => fn(),
onSuccess: () => invalidate(),
onError: (e: unknown) => toast.error(detailFromApiError(e, 'Thao tác không thành công')),
});
const run = (fn: () => Promise<DatasetTask>) => runMut.mutate(fn);
const busy = runMut.isPending;
const a = allowedActions(task);
const pstate = PIPELINE_STATE_META[task.pipelineState];
const stageKind = stages.find((s) => s.id === task.currentStageId)?.kind;
const name = task.name || task.fileLogicalPath;
const hasMenu = a.canFinalize || a.canReview || a.canSkip || a.canUnassign || a.canSetReference || !a.isTerminal;
const copyId = () => {
navigator.clipboard?.writeText(task.id).then(
() => toast.success('Đã sao chép Task Id'),
() => toast.error('Không sao chép được'),
);
};
return (
<TableRow data-state={selected ? 'selected' : undefined}>
<TableCell className="w-10">
<Checkbox
checked={selected}
onCheckedChange={(v) => onSelectedChange(v === true)}
aria-label={`Chọn ${name}`}
/>
</TableCell>
{/* Datapoint */}
<TableCell>
<div className="flex items-center gap-2">
<FileImage className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="max-w-[220px] truncate font-medium" title={name}>
{name}
</span>
{task.isReferenceStandard && (
<Star className="h-3.5 w-3.5 fill-amber-400 text-amber-500" aria-label="Chuẩn tham chiếu" />
)}
</div>
</TableCell>
{/* Stage — current stage chip, or a terminal "Hoàn tất" chip at Ground Truth */}
<TableCell>
{a.isTerminal || !task.currentStageName ? (
<Badge variant="outline" className={cn('font-normal', TONE_CLASS[a.isTerminal ? 'done' : pstate.tone])}>
{a.isTerminal ? 'Hoàn tất' : pstate.label}
</Badge>
) : (
<Badge variant="outline" className={cn('font-normal', TONE_CLASS[stageTone(stageKind)])}>
{task.currentStageName}
</Badge>
)}
</TableCell>
{/* Files — one file per task in this data model */}
<TableCell className="tabular-nums text-muted-foreground">1</TableCell>
{/* Task Id — short, copy on click */}
<TableCell>
<button
type="button"
onClick={copyId}
title={`${task.id} — bấm để sao chép`}
className="inline-flex items-center gap-1 font-mono text-xs text-muted-foreground hover:text-foreground"
>
{shortId(task.id)}
<Copy className="h-3 w-3 opacity-0 transition-opacity group-hover/row:opacity-100" />
</button>
</TableCell>
{/* Assignee — inline reassignment (read-only once terminal) */}
<TableCell>
{a.isTerminal ? (
<span className="flex items-center gap-2 text-sm">
{task.assigneeName ? (
<>
<InitialsAvatar name={task.assigneeName} seed={task.assigneeUserId ?? ''} className="h-5 w-5" />
{task.assigneeName}
</>
) : (
<span className="text-muted-foreground"></span>
)}
</span>
) : (
<Select
value={task.assigneeUserId ?? NONE}
onValueChange={(v) =>
v === NONE ? run(() => unassignTask(datasetId, task.id)) : run(() => assignTask(datasetId, task.id, v))
}
>
<SelectTrigger className="h-8 w-44 gap-2" disabled={busy}>
<span className="flex items-center gap-2 truncate">
{task.assigneeName && (
<InitialsAvatar name={task.assigneeName} seed={task.assigneeUserId ?? ''} className="h-5 w-5" />
)}
<SelectValue placeholder="Chưa giao" />
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Chưa giao</SelectItem>
{members.map((m) => (
<SelectItem key={m.userId} value={m.userId}>
{m.fullName || m.email}
</SelectItem>
))}
{task.assigneeUserId && !members.some((m) => m.userId === task.assigneeUserId) && (
<SelectItem value={task.assigneeUserId}>{task.assigneeName || 'Người khác'}</SelectItem>
)}
</SelectContent>
</Select>
)}
</TableCell>
{/* Score — no scoring backend yet; shows the "not scored" state (matches the reference) */}
<TableCell>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Ban className="h-4 w-4 text-muted-foreground/50" aria-label="Chưa chấm điểm" />
</span>
</TooltipTrigger>
<TooltipContent>Chưa chấm điểm</TooltipContent>
</Tooltip>
</TableCell>
{/* Actions — open in the tool + overflow menu */}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
title="Mở để gán nhãn"
onClick={() => navigate(`/dashboard/datasets/${datasetId}/tasks/${task.id}`)}
>
<Maximize2 className="h-4 w-4" />
</Button>
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="h-8 w-8" disabled={busy} title="Thao tác">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
{a.canFinalize && (
<DropdownMenuItem onClick={() => run(() => finalizeTask(datasetId, task.id))}>
<Check className="mr-2 h-4 w-4" /> Hoàn tất giai đoạn
</DropdownMenuItem>
)}
{a.canReview && (
<>
<DropdownMenuItem onClick={() => run(() => reviewTask(datasetId, task.id, 'accept'))}>
<Check className="mr-2 h-4 w-4" /> Chấp nhận
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => run(() => reviewTask(datasetId, task.id, 'acceptWithCorrections'))}
>
Chấp nhận kèm chỉnh sửa
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => run(() => reviewTask(datasetId, task.id, 'reject'))}
>
Từ chối trả lại gán nhãn
</DropdownMenuItem>
</>
)}
{a.canSkip && (
<DropdownMenuItem onClick={() => run(() => skipTask(datasetId, task.id))}>
Bỏ qua (xuống cuối hàng đi)
</DropdownMenuItem>
)}
{a.canUnassign && (
<DropdownMenuItem onClick={() => run(() => unassignTask(datasetId, task.id))}>
Bỏ phân công
</DropdownMenuItem>
)}
{a.canSetReference && (
<DropdownMenuItem
onClick={() => run(() => setTaskReference(datasetId, task.id, !task.isReferenceStandard))}
>
<Star
className={cn('mr-2 h-4 w-4', task.isReferenceStandard && 'fill-amber-400 text-amber-500')}
/>
{task.isReferenceStandard ? 'Bỏ chuẩn tham chiếu' : 'Đặt làm chuẩn tham chiếu'}
</DropdownMenuItem>
)}
{!a.isTerminal && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Đ ưu tiên
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={priorityBucket(task.priority ?? 0)}
onValueChange={(v) =>
run(() => setTaskPriority(datasetId, task.id, PRESET_VALUE[v as 'high' | 'normal' | 'low']))
}
>
<DropdownMenuRadioItem value="high">Cao</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="normal">Thường</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="low">Thấp</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={copyId}>
<Copy className="mr-2 h-4 w-4" /> Sao chép Task Id
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</TableCell>
</TableRow>
);
}
export default TaskRow;
+285
View File
@@ -0,0 +1,285 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
@layer base {
:root {
/* Warm canvas + cool institutional primary — clearer layers than flat gray-on-cream */
--background: 38 32% 96%;
--foreground: 222 28% 16%;
--card: 0 0% 100%;
--card-foreground: 222 28% 16%;
--popover: 0 0% 100%;
--popover-foreground: 222 28% 16%;
--primary: 217 52% 38%;
--primary-foreground: 210 40% 98%;
--secondary: 220 18% 92%;
--secondary-foreground: 222 28% 22%;
--muted: 36 22% 92%;
--muted-foreground: 220 12% 42%;
--accent: 168 42% 38%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 48%;
--destructive-foreground: 210 40% 98%;
--border: 220 14% 88%;
--input: 220 14% 88%;
--ring: 217 52% 38%;
--radius: 1rem;
/* Custom design tokens — tuned to sit cleanly on the new base */
--tag-financing: 215 48% 78%;
--tag-lifestyle: 162 36% 72%;
--tag-community: 32 42% 62%;
--tag-wellness: 278 32% 74%;
--tag-travel: 198 48% 72%;
--tag-creativity: 328 38% 76%;
--tag-growth: 48 46% 68%;
/* Polish design tokens */
--cream: 40 38% 92%;
--cream-foreground: 222 28% 16%;
--surface-elevated: 0 0% 99%;
--shadow-soft: 222 28% 16%;
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar tokens */
--sidebar-background: 220 16% 97%;
--sidebar-foreground: 222 28% 16%;
--sidebar-primary: 217 52% 38%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 220 18% 93%;
--sidebar-accent-foreground: 222 28% 22%;
--sidebar-border: 220 14% 88%;
--sidebar-ring: 217 52% 38%;
}
.dark {
--background: 222 24% 12%;
--foreground: 210 36% 96%;
--card: 222 22% 16%;
--card-foreground: 210 36% 96%;
--popover: 222 24% 12%;
--popover-foreground: 210 36% 96%;
--primary: 213 62% 58%;
--primary-foreground: 222 28% 12%;
--secondary: 217 18% 22%;
--secondary-foreground: 210 36% 96%;
--muted: 220 16% 20%;
--muted-foreground: 220 12% 64%;
--accent: 168 40% 44%;
--accent-foreground: 210 36% 98%;
--destructive: 0 63% 48%;
--destructive-foreground: 210 36% 98%;
--border: 217 16% 24%;
--input: 217 16% 24%;
--ring: 213 62% 58%;
/* Dark mode tag colors */
--tag-financing: 215 42% 42%;
--tag-lifestyle: 162 32% 40%;
--tag-community: 32 34% 42%;
--tag-wellness: 278 28% 46%;
--tag-travel: 198 42% 44%;
--tag-creativity: 328 32% 46%;
--tag-growth: 48 38% 44%;
/* Dark mode polish tokens */
--cream: 222 18% 22%;
--cream-foreground: 210 36% 96%;
--surface-elevated: 222 22% 18%;
--shadow-soft: 222 28% 6%;
/* Sidebar tokens */
--sidebar-background: 222 22% 14%;
--sidebar-foreground: 210 36% 96%;
--sidebar-primary: 213 62% 58%;
--sidebar-primary-foreground: 222 28% 12%;
--sidebar-accent: 217 18% 20%;
--sidebar-accent-foreground: 210 36% 96%;
--sidebar-border: 217 16% 24%;
--sidebar-ring: 213 62% 58%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
html {
scroll-behavior: smooth;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif tracking-tight;
}
}
@layer utilities {
.card-hover {
@apply transition-all duration-500 hover:scale-[1.02];
box-shadow: 0 4px 20px -4px hsl(var(--shadow-soft) / 0.1);
}
.card-hover:hover {
box-shadow: 0 20px 40px -10px hsl(var(--shadow-soft) / 0.15);
}
.pill-nav {
@apply rounded-full bg-[hsl(var(--surface-elevated))] backdrop-blur-lg border border-border/50;
}
.floating-button {
@apply w-12 h-12 rounded-full bg-[hsl(var(--cream)/0.9)] backdrop-blur-sm flex items-center justify-center text-[hsl(var(--cream-foreground))] hover:bg-[hsl(var(--cream))] hover:scale-110 transition-all duration-300;
box-shadow: 0 4px 12px -2px hsl(var(--shadow-soft) / 0.15);
}
/* Animation keyframes */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-slide-up {
animation: slideUp 0.6s ease-out;
}
.animate-slide-down {
animation: slideDown 0.6s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.5s ease-out;
}
/* Stagger animations */
.stagger-1 {
animation-delay: 0.1s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-2 {
animation-delay: 0.2s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-3 {
animation-delay: 0.3s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-4 {
animation-delay: 0.4s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-5 {
animation-delay: 0.5s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-6 {
animation-delay: 0.6s;
opacity: 0;
animation-fill-mode: forwards;
}
.tag-financing {
@apply bg-[hsl(var(--tag-financing))] text-primary;
}
.tag-lifestyle {
@apply bg-[hsl(var(--tag-lifestyle))] text-primary;
}
.tag-community {
@apply bg-[hsl(var(--tag-community))] text-primary;
}
.tag-wellness {
@apply bg-[hsl(var(--tag-wellness))] text-primary;
}
.tag-travel {
@apply bg-[hsl(var(--tag-travel))] text-primary;
}
.tag-creativity {
@apply bg-[hsl(var(--tag-creativity))] text-primary;
}
.tag-growth {
@apply bg-[hsl(var(--tag-growth))] text-primary;
}
}
@@ -0,0 +1,62 @@
import { Outlet } from "react-router-dom";
import { Search } from "lucide-react";
import { Input, useAuth, getRoleDisplayName, type Role } from "@ump/shared";
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
import { ApplicantDashboardSidebar } from "@/components/applicant/DashboardSidebar";
/** Initials from a full name — first + last word (e.g. "Thinh Hong Lam" → "TL"). */
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
return (parts[0][0] + last).toUpperCase();
}
/**
* Applicant dashboard shell — sidebar + header + routed content (<Outlet/>).
*
* Mirrors fe0/src/layouts/DashboardLayout.tsx, but hardcodes the applicant sidebar
* (this is the applicant app — fe0 swapped admin-vs-applicant at runtime). The header
* carries the SidebarTrigger + search and the current user's name/roles (moved here
* from the sidebar). fe0's NotificationBell / UserMenu are deferred with the deep feature pages.
*/
export function DashboardLayout() {
const { user, roles } = useAuth();
const roleLabel = roles.map((r: Role) => getRoleDisplayName(r)).join(", ");
return (
<SidebarProvider>
<div className="flex overflow-hidden h-screen w-full">
<ApplicantDashboardSidebar />
<SidebarInset>
<header className="flex h-16 absolute top-0 left-0 right-0 items-center gap-4 border-b border-border bg-card px-4">
<SidebarTrigger />
<div className="flex-1">
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input type="search" placeholder="Tìm kiếm..." className="pl-9 bg-background" />
</div>
</div>
{user && (user.name || roleLabel) && (
<div className="flex items-center gap-2.5">
<div className="hidden text-right leading-tight sm:block">
<p className="truncate text-sm font-medium text-foreground">{user.name}</p>
{roleLabel && (
<p className="truncate text-xs text-muted-foreground">{roleLabel}</p>
)}
</div>
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{initialsOf(user.name || "?")}
</span>
</div>
)}
</header>
<main className="flex-1 overflow-auto p-6 mt-12">
<Outlet />
</main>
</SidebarInset>
</div>
</SidebarProvider>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,465 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
ArrowLeft,
Boxes,
CalendarClock,
ClipboardCheck,
Coins,
FileText,
FlaskConical,
Info,
Lock,
Plus,
ScrollText,
Search,
} from 'lucide-react';
import {
Button,
Card,
CardContent,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
createEntity,
deleteEntity,
getCockpit,
listDatasets,
updateEntity,
useAuth,
type CockpitBundle,
type CockpitEntity,
type EntityRow,
} from '@ump/shared';
import {
ENTITIES,
ENTITY_ORDER,
asNum,
asStr,
fmt,
pct,
type CockpitEntityKey,
type EntityConfig,
} from '../components/cockpit/cockpitConfig';
import { Bar, EntityCard, EntityDrawer } from '../components/cockpit/CockpitWidgets';
import { CockpitSidebar } from '../components/cockpit/CockpitSidebar';
import { DetailSectionPanel } from '../components/cockpit/CockpitDetail';
import { SECTION_BY_TAB } from '../components/cockpit/detailConfig';
import { TeamManagementView } from '../components/cockpit/TeamManagementView';
import { DatasetNav, WorkspacePanel } from '../features/dataset-workspace/presentation/WorkspacePanel';
/* ----------------------------- overview widgets ---------------------------- */
// Folded into the relevant admin-detail tabs (Thông tin chung / Kinh phí / Thời gian).
function MilestonesCard({ bundle }: { bundle: CockpitBundle }) {
return (
<Card>
<CardContent className="p-5">
<h3 className="mb-3 flex items-center gap-2 font-semibold text-foreground">
<CalendarClock size={16} className="text-rose-600" /> Tiến đ nội dung nghiên cứu
</h3>
{bundle.milestones.length === 0 ? (
<p className="text-sm text-muted-foreground">Chưa mốc tiến đ.</p>
) : (
<div className="space-y-3">
{bundle.milestones.map((m) => (
<div key={m.id}>
<div className="mb-1 flex justify-between text-sm">
<span className="truncate pr-2 text-foreground">{asStr(m.title)}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">{pct(m.progress)}%</span>
</div>
<Bar value={m.progress} tone={m.status === 'Trễ hạn' ? 'bg-rose-500' : m.status === 'Hoàn thành' ? 'bg-emerald-500' : 'bg-primary'} />
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
function BestModelCard({ bundle }: { bundle: CockpitBundle }) {
const best = [...bundle.models].sort((a, b) => asNum(b.auc) - asNum(a.auc))[0];
return (
<Card>
<CardContent className="p-5">
<h3 className="mb-2 flex items-center gap-2 font-semibold text-foreground">
<FlaskConical size={16} className="text-violet-600" /> hình tốt nhất
</h3>
{best && asNum(best.auc) > 0 ? (
<>
<div className="text-sm font-medium text-foreground">{asStr(best.name)}</div>
<div className="mb-2 text-xs text-muted-foreground">{asStr(best.task)}</div>
<div className="flex gap-3 text-sm">
<div>
<div className="text-2xl font-semibold tabular-nums text-violet-600">{asNum(best.auc).toFixed(2)}</div>
<div className="text-xs text-muted-foreground">AUC</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-foreground">{asNum(best.sensitivity).toFixed(2)}</div>
<div className="text-xs text-muted-foreground">Đ nhạy</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-foreground">{asNum(best.specificity).toFixed(2)}</div>
<div className="text-xs text-muted-foreground">Đ đc hiệu</div>
</div>
</div>
</>
) : (
<div className="text-sm text-muted-foreground">Chưa kết quả đánh giá.</div>
)}
</CardContent>
</Card>
);
}
function BudgetCard({ bundle }: { bundle: CockpitBundle }) {
const allocated = bundle.milestones.reduce((s, m) => s + asNum(m.budget), 0);
const budgetTotal = asNum(bundle.project.budgetTotal);
return (
<Card>
<CardContent className="p-5">
<h3 className="mb-2 font-semibold text-foreground">Ngân sách</h3>
<div className="mb-1 text-sm text-muted-foreground">Đã phân bổ theo nội dung</div>
<div className="text-2xl font-semibold tabular-nums text-foreground">
{fmt(allocated)} <span className="text-sm font-normal text-muted-foreground">/ {fmt(budgetTotal)} tr.đ</span>
</div>
<div className="mt-2">
<Bar value={budgetTotal ? (allocated / budgetTotal) * 100 : 0} tone="bg-amber-500" />
</div>
</CardContent>
</Card>
);
}
/* ------------------------------ audit view ------------------------------ */
function AuditView({ bundle }: { bundle: CockpitBundle }) {
return (
<div>
<p className="mb-4 max-w-2xl text-sm text-muted-foreground">
Nhật chỉ-thêm (append-only). Mỗi thay đi ghi lại ai thao tác, lúc nào nội dung cụ thể.
</p>
<Card>
<CardContent className="p-0">
{bundle.audit.length === 0 ? (
<div className="px-4 py-10 text-center text-sm text-muted-foreground">Chưa bản ghi.</div>
) : (
bundle.audit.map((e, i) => (
<div key={e.id} className={'flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3 text-sm ' + (i ? 'border-t border-border' : '')}>
<span className="w-40 shrink-0 font-mono text-xs text-muted-foreground">
{e.occurredAt ? new Date(e.occurredAt).toLocaleString('vi-VN') : ''}
</span>
<span className="w-48 shrink-0 text-foreground">
{e.actorName} <span className="text-muted-foreground">· {e.roleLabel}</span>
</span>
<span className="min-w-0 flex-1 text-muted-foreground">
{e.action} <span className="font-mono text-xs">{e.subject}</span>
</span>
{e.detail && <span className="shrink-0 font-mono text-xs text-muted-foreground">{e.detail}</span>}
</div>
))
)}
</CardContent>
</Card>
</div>
);
}
/* ------------------------------ entity view ----------------------------- */
function buildDraft(config: EntityConfig, item?: EntityRow): Record<string, string> {
const d: Record<string, string> = {};
for (const f of config.fields) {
if (item) {
const v = item[f.key];
d[f.key] = v === undefined || v === null ? '' : String(v);
} else {
d[f.key] = f.default ?? '';
}
}
return d;
}
function EntityView({
entityKey,
bundle,
projectId,
canEdit,
}: {
entityKey: CockpitEntityKey;
bundle: CockpitBundle;
projectId: string;
canEdit: boolean;
}) {
const config = ENTITIES[entityKey];
const items = bundle[entityKey];
const qc = useQueryClient();
const [q, setQ] = useState('');
const [statusFilter, setStatusFilter] = useState('Tất cả');
const [drawer, setDrawer] = useState<{ id: string | null; data: Record<string, string> } | null>(null);
const statuses = useMemo(() => {
const badgeField = config.fields.find((f) => f.key === config.badge);
return ['Tất cả', ...(badgeField?.options ?? [])];
}, [config]);
const invalidate = () => qc.invalidateQueries({ queryKey: ['cockpit', projectId] });
const saveMut = useMutation({
mutationFn: (vars: { id: string | null; data: Record<string, string> }) =>
vars.id
? updateEntity(projectId, entityKey as CockpitEntity, vars.id, vars.data)
: createEntity(projectId, entityKey as CockpitEntity, vars.data),
onSuccess: () => {
invalidate();
toast.success('Đã lưu.');
setDrawer(null);
},
onError: () => toast.error('Lưu thất bại.'),
});
const delMut = useMutation({
mutationFn: (itemId: string) => deleteEntity(projectId, entityKey as CockpitEntity, itemId),
onSuccess: () => {
invalidate();
toast.success('Đã xóa.');
},
onError: () => toast.error('Xóa thất bại.'),
});
const filtered = items.filter((it) => {
const mq = !q || JSON.stringify(it).toLowerCase().includes(q.toLowerCase());
const ms = statusFilter === 'Tất cả' || asStr(it[config.badge]) === statusFilter;
return mq && ms;
});
const remove = (it: EntityRow) => {
if (!window.confirm(`Xóa ${config.singular} "${asStr(it[config.primary])}"?`)) return;
delMut.mutate(it.id);
};
return (
<div>
{!canEdit && (
<div className="mb-4 flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<Lock size={15} /> Bạn chỉ đưc xem mục này. Thêm / sửa / xóa đã bị khóa.
</div>
)}
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search size={15} className="absolute left-3 top-2.5 text-muted-foreground" />
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={`Tìm ${config.singular}...`} className="pl-9" />
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{statuses.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => setDrawer({ id: null, data: buildDraft(config) })}
disabled={!canEdit}
title={canEdit ? '' : 'Không có quyền'}
>
{canEdit ? <Plus size={16} className="mr-1.5" /> : <Lock size={15} className="mr-1.5" />} Thêm
</Button>
</div>
{filtered.length === 0 ? (
<div className="rounded-xl border border-dashed border-border py-16 text-center text-sm text-muted-foreground">
Chưa {config.singular} nào phù hợp.
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((it) => (
<EntityCard
key={it.id}
config={config}
item={it}
canEdit={canEdit}
onEdit={() => setDrawer({ id: it.id, data: buildDraft(config, it) })}
onDelete={() => remove(it)}
/>
))}
</div>
)}
{drawer && (
<EntityDrawer
open
isNew={drawer.id === null}
config={config}
draft={drawer.data}
onChange={(k, v) => setDrawer((d) => (d ? { ...d, data: { ...d.data, [k]: v } } : d))}
onSave={() => saveMut.mutate(drawer)}
onClose={() => setDrawer(null)}
saving={saveMut.isPending}
/>
)}
</div>
);
}
/* --------------------------------- page --------------------------------- */
export function CockpitPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const { user, hasRole } = useAuth();
const [tab, setTab] = useState<string>('general');
const { data: bundle, isLoading, isError } = useQuery({
queryKey: ['cockpit', id],
queryFn: () => getCockpit(id),
enabled: !!id,
});
// The cockpit is for approved projects; bounce anything else back to the proposal view.
useEffect(() => {
if (bundle && bundle.project.status !== 'approved') {
navigate(`/dashboard/proposals/${id}`, { replace: true });
}
}, [bundle, id, navigate]);
// Imaging datasets for the "Dữ liệu hình ảnh" tab — the selector ("Bộ dữ liệu") lives in the
// sidebar, so this query + selection are owned here and shared with the sidebar nav + the pane.
const isWorkspace = tab === 'workspace';
const [selectedDataset, setSelectedDataset] = useState<string | null>(null);
const dsQuery = useQuery({
queryKey: ['imagehub', 'datasets', 'project', id],
queryFn: () => listDatasets({ projectId: id }),
enabled: isWorkspace,
});
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Đang tải</div>;
if (isError || !bundle) return <div className="p-6 text-sm text-destructive">Không tải đưc đ tài.</div>;
const canEdit = hasRole('admin') || user?.id === bundle.project.ownerUserId;
// Tab bar: the administrative-detail sheet first (Thông tin chung … Nghiệm thu),
// then the research-management tabs (members / data / models / assets / milestones / log).
const DETAIL_TABS = new Set(['general', 'contract', 'funding', 'timeline', 'acceptance']);
const tabs = [
{ key: 'general', title: 'Thông tin chung', icon: Info },
{ key: 'contract', title: 'Hợp đồng', icon: FileText },
{ key: 'funding', title: 'Kinh phí', icon: Coins },
{ key: 'timeline', title: 'Thời gian', icon: CalendarClock },
{ key: 'acceptance', title: 'Nghiệm thu', icon: ClipboardCheck },
// "Dữ liệu" routes straight to the imaging workspace ("Dữ liệu hình ảnh") — the generic
// datasets CRUD is folded in, since in ImageHub the project data IS the image data.
...ENTITY_ORDER.flatMap((k) =>
k === 'datasets'
? [{ key: 'workspace', title: 'Dữ liệu hình ảnh', icon: Boxes }]
: [{ key: k as string, title: ENTITIES[k].title, icon: ENTITIES[k].icon }],
),
{ key: 'audit', title: 'Nhật ký', icon: ScrollText },
];
const content = (bundle.project.content ?? {}) as Record<string, unknown>;
const datasets = dsQuery.data ?? [];
const activeDatasetId = selectedDataset ?? datasets[0]?.id ?? null;
return (
<div className="mx-auto max-w-7xl space-y-4">
<Link to="/dashboard/projects" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft size={15} /> Đ tài của tôi
</Link>
<div className="grid gap-5 lg:grid-cols-[300px_minmax(0,1fr)]">
<div className="lg:sticky lg:top-4 lg:self-start">
<CockpitSidebar
bundle={bundle}
datasetNav={
isWorkspace ? (
<DatasetNav
datasets={datasets}
activeId={activeDatasetId}
onSelect={setSelectedDataset}
projectId={id}
loading={dsQuery.isLoading}
error={dsQuery.isError}
/>
) : undefined
}
/>
</div>
<div className="min-w-0 space-y-5">
<div className="flex gap-1 overflow-x-auto border-b border-border">
{tabs.map((t) => {
const Icon = t.icon;
const active = tab === t.key;
const count =
t.key === 'audit'
? bundle.audit.length
: DETAIL_TABS.has(t.key) || t.key === 'workspace'
? null
: bundle[t.key as CockpitEntityKey].length;
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={
'inline-flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2.5 text-sm font-medium transition-colors ' +
(active
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground')
}
>
<Icon size={15} /> {t.title}
{count != null && (
<span
className={
'rounded-full px-1.5 text-xs ' +
(active ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground')
}
>
{count}
</span>
)}
</button>
);
})}
</div>
{DETAIL_TABS.has(tab) ? (
<div className="space-y-5">
<DetailSectionPanel
section={SECTION_BY_TAB[tab]}
content={content}
projectId={id}
canEdit={canEdit}
/>
{tab === 'general' && <BestModelCard bundle={bundle} />}
{tab === 'funding' && <BudgetCard bundle={bundle} />}
{tab === 'timeline' && <MilestonesCard bundle={bundle} />}
</div>
) : tab === 'workspace' ? (
<WorkspacePanel
projectId={id}
datasetId={activeDatasetId}
loading={dsQuery.isLoading}
error={dsQuery.isError}
isEmpty={datasets.length === 0}
/>
) : tab === 'audit' ? (
<AuditView bundle={bundle} />
) : tab === 'members' ? (
<TeamManagementView members={bundle.members} projectId={id} canEdit={canEdit} />
) : (
<EntityView entityKey={tab as CockpitEntityKey} bundle={bundle} projectId={id} canEdit={canEdit} />
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { Construction } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@ump/shared";
/**
* Placeholder for applicant sidebar targets whose feature pages have not been
* migrated into frontend_user yet (initiative draft, profile, notifications, …).
* Styled with the shared design tokens so the shell looks complete and nothing 404s.
*/
export function ComingSoonPage() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-md text-center">
<CardHeader>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Construction className="h-6 w-6 text-muted-foreground" />
</div>
<CardTitle className="font-serif">Tính năng đang phát triển</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Chức năng này sẽ sớm đưc hoàn thiện. Vui lòng quay lại sau.
</p>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,149 @@
import { useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
createDataset,
VISIBILITY_LABEL,
type DatasetVisibility,
} from '@ump/shared';
const VISIBILITIES: DatasetVisibility[] = ['private', 'internal', 'public'];
/** Create a new imaging dataset, then open the nnU-Net organizer to set it up + upload files. */
export function DatasetCreatePage() {
const navigate = useNavigate();
const qc = useQueryClient();
// Project context from the cockpit "Tạo bộ dữ liệu" bridge — attaches the new dataset to the
// project (research_project_id, migration 024) and drives the back-link + a context banner.
const [searchParams] = useSearchParams();
const projectId = searchParams.get('project');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [visibility, setVisibility] = useState<DatasetVisibility>('private');
const [tags, setTags] = useState('');
const mut = useMutation({
mutationFn: () =>
createDataset({
name: name.trim(),
description: description.trim(),
visibility,
modalityTags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
researchProjectId: projectId ?? undefined,
}),
onSuccess: (ds) => {
qc.invalidateQueries({ queryKey: ['imagehub', 'datasets'] });
toast.success('Đã tạo bộ dữ liệu — hãy tải ảnh gốc và nhãn để bắt đầu');
navigate(`/dashboard/datasets/${ds.id}/organize`);
},
onError: () => toast.error('Không tạo được bộ dữ liệu'),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (!name.trim()) {
toast.error('Vui lòng nhập tên bộ dữ liệu');
return;
}
mut.mutate();
};
return (
<div className="mx-auto max-w-2xl space-y-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(projectId ? `/dashboard/projects/${projectId}` : '/dashboard')}
>
<ArrowLeft className="mr-2 h-4 w-4" /> {projectId ? 'Quay lại đề tài' : 'Quay lại'}
</Button>
<Card>
<CardHeader>
<CardTitle className="font-serif">Tạo bộ dữ liệu mới</CardTitle>
{projectId && (
<p className="text-sm text-muted-foreground">
Bộ dữ liệu đưc tạo trong bối cảnh đ tài đang chọn, rồi tiếp tục sang bước tổ chức dữ liệu.
</p>
)}
</CardHeader>
<CardContent>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ds-name">Tên bộ dữ liệu *</Label>
<Input
id="ds-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="VD: CT ngực ung thư phổi 2026"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ds-desc"> tả</Label>
<Textarea
id="ds-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
placeholder="Mục đích, nguồn dữ liệu, phạm vi…"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Mức hiển thị</Label>
<Select value={visibility} onValueChange={(v) => setVisibility(v as DatasetVisibility)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{VISIBILITIES.map((v) => (
<SelectItem key={v} value={v}>
{VISIBILITY_LABEL[v]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ds-tags">Nhãn phương thức</Label>
<Input
id="ds-tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="CT, MRI (cách nhau bởi dấu phẩy)"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={() => navigate('/dashboard')}>
Hủy
</Button>
<Button type="submit" disabled={mut.isPending}>
{mut.isPending ? 'Đang tạo…' : 'Tạo bộ dữ liệu'}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,381 @@
import { Fragment, lazy, Suspense, useMemo, useRef, useState, type ChangeEvent } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Upload, GitBranch, Download, FileImage, Eye, Layers, FolderInput, Settings, ListChecks } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
getDataset,
listDatasetFiles,
listDatasetVersions,
uploadDatasetFiles,
createDatasetVersion,
fileDownloadUrl,
isViewableImagingFile,
fetchAsFile,
VISIBILITY_LABEL,
formatBytes,
type ImagehubFile,
} from '@ump/shared';
import { SegmentationUploadDialog } from '../components/SegmentationUploadDialog';
import { UploadDataDialog } from '../features/data-import/presentation/components/UploadDataDialog';
// Lazy so @kitware/vtk.js loads only when a viewer actually opens — keeps it out
// of the dataset page's initial bundle (it lands in its own async chunk).
const DatasetFileViewerDialog = lazy(() => import('../components/DatasetFileViewerDialog'));
/** Human-readable imaging summary from the best-effort sniff metadata. */
function modalityOf(meta: Record<string, unknown>): string {
if (!meta || typeof meta !== 'object') return '';
const fmt = meta.format;
if (fmt === 'dicom') {
const mod = meta.modality ? String(meta.modality) : '';
const dim = meta.rows && meta.columns ? `${String(meta.rows)}×${String(meta.columns)}` : '';
return [mod, dim].filter(Boolean).join(' · ');
}
if (fmt === 'nifti' && Array.isArray(meta.shape)) {
return `NIfTI ${(meta.shape as unknown[]).map(String).join('×')}`;
}
return '';
}
export function DatasetDetailPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const fileInput = useRef<HTMLInputElement>(null);
const [viewer, setViewer] = useState<
{ file: File; name: string; masks: { id: string; organLabel: string; logicalPath: string }[] } | null
>(null);
const [viewerLoadingId, setViewerLoadingId] = useState<string | null>(null);
const [segTarget, setSegTarget] = useState<ImagehubFile | null>(null);
const [importOpen, setImportOpen] = useState(false);
const dsQ = useQuery({ queryKey: ['imagehub', 'dataset', id], queryFn: () => getDataset(id), enabled: !!id });
const filesQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'files'],
queryFn: () => listDatasetFiles(id),
enabled: !!id,
});
const versionsQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'versions'],
queryFn: () => listDatasetVersions(id),
enabled: !!id,
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id] });
const uploadMut = useMutation({
mutationFn: (files: File[]) => uploadDatasetFiles(id, files),
onSuccess: (res) => {
const dups = res.files.filter((f) => f.deduped).length;
toast.success(
`Đã tải lên ${res.files.length} tệp${dups ? ` (${dups} trùng nội dung, đã khử trùng lặp)` : ''}`,
);
invalidate();
},
onError: () => toast.error('Tải tệp thất bại'),
});
const versionMut = useMutation({
mutationFn: () => createDatasetVersion(id),
onSuccess: (v) => {
toast.success(`Đã tạo phiên bản v${v.seq}`);
invalidate();
},
onError: () => toast.error('Không tạo được phiên bản'),
});
const onPick = (e: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (files.length) uploadMut.mutate(files);
e.target.value = '';
};
const download = async (f: ImagehubFile) => {
try {
const { url } = await fileDownloadUrl(id, f.id);
window.open(url, '_blank', 'noopener');
} catch {
toast.error('Không lấy được liên kết tải');
}
};
/** Resolve a presigned URL, fetch the bytes as a File, and open the 3D viewer. */
const openViewer = async (f: ImagehubFile) => {
setViewerLoadingId(f.id);
try {
const { url } = await fileDownloadUrl(id, f.id);
const file = await fetchAsFile(url, f.logicalPath);
// The organ masks linked to this image become the viewer's selectable overlays.
const masks = (filesQ.data ?? [])
.filter((m) => m.fileKind === 'segmentation' && m.parentFileId === f.id)
.map((m) => ({ id: m.id, organLabel: m.organLabel || m.logicalPath, logicalPath: m.logicalPath }));
setViewer({ file, name: f.logicalPath, masks });
} catch {
toast.error('Không mở được trình xem ảnh');
} finally {
setViewerLoadingId(null);
}
};
const ds = dsQ.data;
const files = filesQ.data ?? [];
const versions = versionsQ.data ?? [];
// Group segmentation masks under the image they segment; only image rows render at top level.
const masksByParent = useMemo(() => {
const m = new Map<string, ImagehubFile[]>();
for (const f of files) {
if (f.fileKind === 'segmentation' && f.parentFileId) {
const arr = m.get(f.parentFileId);
if (arr) arr.push(f);
else m.set(f.parentFileId, [f]);
}
}
return m;
}, [files]);
const imageRows = files.filter((f) => f.fileKind !== 'segmentation');
return (
<div className="mx-auto max-w-5xl space-y-6">
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
<ArrowLeft className="mr-2 h-4 w-4" /> Bộ dữ liệu của tôi
</Button>
{dsQ.isLoading && <p className="text-sm text-muted-foreground">Đang tải</p>}
{dsQ.isError && <p className="text-sm text-destructive">Không tải đưc bộ dữ liệu.</p>}
{ds && (
<>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="font-serif text-2xl font-semibold text-foreground">{ds.name}</h1>
<Badge variant="outline">{VISIBILITY_LABEL[ds.visibility]}</Badge>
</div>
{ds.description && <p className="mt-1 text-sm text-muted-foreground">{ds.description}</p>}
{ds.modalityTags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{ds.modalityTags.map((t) => (
<Badge key={t} variant="secondary" className="text-[11px]">
{t}
</Badge>
))}
</div>
)}
</div>
<div className="flex gap-2">
<input ref={fileInput} type="file" multiple className="hidden" onChange={onPick} />
<Button
variant="outline"
onClick={() => fileInput.current?.click()}
disabled={uploadMut.isPending}
>
<Upload className="mr-2 h-4 w-4" /> {uploadMut.isPending ? 'Đang tải…' : 'Tải tệp lên'}
</Button>
<Button variant="outline" onClick={() => setImportOpen(true)}>
<FolderInput className="mr-2 h-4 w-4" /> Nhập dữ liệu
</Button>
<Button variant="outline" onClick={() => navigate(`/dashboard/datasets/${id}/tasks`)}>
<ListChecks className="mr-2 h-4 w-4" /> Công việc
</Button>
<Button
onClick={() => versionMut.mutate()}
disabled={versionMut.isPending || files.length === 0}
>
<GitBranch className="mr-2 h-4 w-4" /> Tạo phiên bản
</Button>
<Button
variant="outline"
size="icon"
onClick={() => navigate(`/dashboard/datasets/${id}/settings`)}
title="Cài đặt"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Tệp ({files.length})</CardTitle>
</CardHeader>
<CardContent>
{filesQ.isLoading ? (
<p className="py-6 text-center text-sm text-muted-foreground">Đang tải</p>
) : files.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Chưa tệp nào. Bấm Tải tệp lên đ thêm dữ liệu hình nh.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tên tệp</TableHead>
<TableHead>Hình nh</TableHead>
<TableHead className="text-right">Kích thước</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{imageRows.map((f) => {
const masks = masksByParent.get(f.id) ?? [];
const viewable = isViewableImagingFile(f.logicalPath);
return (
<Fragment key={f.id}>
<TableRow>
<TableCell className="font-medium">
<span className="flex items-center gap-2">
<FileImage className="h-4 w-4 shrink-0 text-muted-foreground" />
{f.logicalPath}
</span>
</TableCell>
<TableCell className="text-muted-foreground">
{modalityOf(f.imagingMeta) || '—'}
</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatBytes(f.size)}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
{viewable && (
<Button
variant="ghost"
size="icon"
onClick={() => openViewer(f)}
disabled={viewerLoadingId === f.id}
title="Xem ảnh 3D"
>
<Eye className="h-4 w-4" />
</Button>
)}
{viewable && (
<Button
variant="ghost"
size="icon"
onClick={() => setSegTarget(f)}
title="Gắn mặt nạ phân vùng"
>
<Layers className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => download(f)} title="Tải xuống">
<Download className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
{masks.map((mk) => (
<TableRow key={mk.id} className="bg-muted/20">
<TableCell className="font-medium">
<span className="flex items-center gap-2 pl-6">
<Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="text-muted-foreground">{mk.organLabel || mk.logicalPath}</span>
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">Mặt nạ phân vùng</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatBytes(mk.size)}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => download(mk)}
title="Tải xuống"
>
<Download className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</Fragment>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Phiên bản ({versions.length})</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{versions.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Chưa phiên bản nào. Tải tệp rồi bấm Tạo phiên bản đ chốt một bản chụp (snapshot).
</p>
) : (
versions.map((v) => (
<div
key={v.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-muted-foreground" />
<strong>v{v.seq}</strong>
{v.message && <span className="text-muted-foreground"> {v.message}</span>}
</span>
<span className="text-muted-foreground">
{v.fileCount} tệp
{v.createdAt ? ` · ${new Date(v.createdAt).toLocaleString('vi-VN')}` : ''}
</span>
</div>
))
)}
</CardContent>
</Card>
</>
)}
{viewer && (
<Suspense fallback={null}>
<DatasetFileViewerDialog
open
onOpenChange={(o) => {
if (!o) setViewer(null);
}}
file={viewer.file}
fileName={viewer.name}
datasetId={id}
masks={viewer.masks}
/>
</Suspense>
)}
{segTarget && (
<SegmentationUploadDialog
open
onOpenChange={(o) => {
if (!o) setSegTarget(null);
}}
datasetId={id}
parentFileId={segTarget.id}
parentName={segTarget.logicalPath}
onUploaded={invalidate}
/>
)}
{importOpen && (
<UploadDataDialog open onOpenChange={setImportOpen} datasetId={id} onCompleted={invalidate} />
)}
</div>
);
}
@@ -0,0 +1,612 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Settings2,
AlertTriangle,
Copy,
Check,
Trash2,
Plus,
Save,
PenLine,
ShieldCheck,
UserPlus,
Users,
} from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
Label,
Textarea,
Switch,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
cn,
getDataset,
updateDataset,
deleteDataset,
listDatasetStages,
addDatasetStage,
updateDatasetStage,
deleteDatasetStage,
listMembers,
addMember,
removeMember,
VISIBILITY_LABEL,
type DatasetMember,
type DatasetStage,
type DatasetVisibility,
type StageKind,
type StageUpdateInput,
} from '@ump/shared';
/** One row of the stages table — inline-editable name + review %, the auto-assign Switch, delete. */
function StageRow({ datasetId, stage }: { datasetId: string; stage: DatasetStage }) {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', datasetId, 'stages'] });
const [name, setName] = useState(stage.name);
const [percent, setPercent] = useState(stage.reviewPercent == null ? '' : String(stage.reviewPercent));
useEffect(() => setName(stage.name), [stage.name]);
useEffect(() => setPercent(stage.reviewPercent == null ? '' : String(stage.reviewPercent)), [stage.reviewPercent]);
const updateMut = useMutation({
mutationFn: (input: StageUpdateInput) => updateDatasetStage(datasetId, stage.id, input),
onSuccess: invalidate,
onError: () => toast.error('Không cập nhật được giai đoạn'),
});
const deleteMut = useMutation({
mutationFn: () => deleteDatasetStage(datasetId, stage.id),
onSuccess: () => {
toast.success('Đã xóa giai đoạn');
invalidate();
},
onError: () => toast.error('Không xóa được giai đoạn'),
});
const saveName = () => {
const v = name.trim();
if (v && v !== stage.name) updateMut.mutate({ name: v });
else setName(stage.name);
};
const savePercent = () => {
const v = percent.trim() === '' ? null : Math.max(0, Math.min(100, parseInt(percent, 10) || 0));
if (v !== stage.reviewPercent) updateMut.mutate({ reviewPercent: v });
};
const isReview = stage.kind === 'review';
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-2">
<span className={isReview ? 'text-accent' : 'text-primary'}>
{isReview ? <ShieldCheck className="h-4 w-4" /> : <PenLine className="h-4 w-4" />}
</span>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={saveName}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
className="h-8 w-44"
aria-label="Tên giai đoạn"
/>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">{isReview ? 'Kiểm duyệt' : 'Gán nhãn'}</Badge>
</TableCell>
<TableCell>
{isReview ? (
<div className="flex items-center gap-1">
<Input
value={percent}
onChange={(e) => setPercent(e.target.value.replace(/[^0-9]/g, ''))}
onBlur={savePercent}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
className="h-8 w-16 text-right"
aria-label="Phần trăm kiểm duyệt"
/>
<span className="text-muted-foreground">%</span>
</div>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<Switch
checked={stage.autoAssign}
onCheckedChange={(c) => updateMut.mutate({ autoAssign: c })}
aria-label={`Tự động gán việc cho ${stage.name}`}
/>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
title="Xóa giai đoạn"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
}
/** Members management — add labelers by email, list them, remove. Owner/admin only (BE-gated). */
function MembersCard({ datasetId }: { datasetId: string }) {
const qc = useQueryClient();
const [email, setEmail] = useState('');
const [newRole, setNewRole] = useState<'member' | 'project_admin'>('member');
const membersQ = useQuery({
queryKey: ['imagehub', 'dataset', datasetId, 'members'],
queryFn: () => listMembers(datasetId),
enabled: !!datasetId,
});
const invalidate = () =>
qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', datasetId, 'members'] });
const addMut = useMutation({
mutationFn: () => addMember(datasetId, email.trim(), newRole),
onSuccess: () => {
toast.success('Đã thêm thành viên');
setEmail('');
invalidate();
},
onError: (e: unknown) =>
toast.error((e as { message?: string })?.message || 'Không thêm được thành viên'),
});
const removeMut = useMutation({
mutationFn: (userId: string) => removeMember(datasetId, userId),
onSuccess: () => {
toast.success('Đã xóa thành viên');
invalidate();
},
onError: () => toast.error('Không xóa được thành viên'),
});
const members: DatasetMember[] = membersQ.data ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Users className="h-4 w-4" /> Thành viên
</CardTitle>
<p className="text-sm text-muted-foreground">
Mời người gán nhãn vào bộ dữ liệu. Thành viên thể xem bộ dữ liệu xử công việc đưc
giao cho họ; quản bộ dữ liệu vẫn thuộc về chủ sở hữu.
</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex gap-2">
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && email.trim()) addMut.mutate();
}}
placeholder="email@ump.edu.vn"
type="email"
aria-label="Email thành viên"
/>
<Select value={newRole} onValueChange={(v) => setNewRole(v as 'member' | 'project_admin')}>
<SelectTrigger className="h-10 w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member">Thành viên</SelectItem>
<SelectItem value="project_admin">Quản trị</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => addMut.mutate()} disabled={!email.trim() || addMut.isPending}>
<UserPlus className="mr-1 h-4 w-4" /> Thêm
</Button>
</div>
{members.length === 0 ? (
<p className="py-2 text-center text-sm text-muted-foreground">Chưa thành viên nào.</p>
) : (
<ul className="divide-y rounded-md border">
{members.map((m) => (
<li key={m.userId} className="flex items-center justify-between gap-2 px-3 py-2">
<span className="min-w-0">
<span className="block truncate text-sm text-foreground">{m.fullName || m.email}</span>
{m.fullName && (
<span className="block truncate text-xs text-muted-foreground">{m.email}</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2">
<Badge variant="secondary">
{m.role === 'project_admin' ? 'Quản trị' : 'Thành viên'}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title="Xóa thành viên"
onClick={() => removeMut.mutate(m.userId)}
disabled={removeMut.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
function SubNavItem({
icon,
label,
active,
danger,
onClick,
}: {
icon: ReactNode;
label: string;
active: boolean;
danger?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
active
? danger
? 'bg-destructive/10 font-medium text-destructive'
: 'bg-primary/10 font-medium text-primary'
: 'text-muted-foreground hover:bg-muted',
)}
>
{icon}
{label}
</button>
);
}
function MetaRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5 text-sm text-foreground">{children}</dd>
</div>
);
}
export function DatasetSettingsPage() {
const { id = '' } = useParams();
const navigate = useNavigate();
const qc = useQueryClient();
const [section, setSection] = useState<'general' | 'danger'>('general');
const [copied, setCopied] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const dsQ = useQuery({ queryKey: ['imagehub', 'dataset', id], queryFn: () => getDataset(id), enabled: !!id });
const stagesQ = useQuery({
queryKey: ['imagehub', 'dataset', id, 'stages'],
queryFn: () => listDatasetStages(id),
enabled: !!id,
});
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [visibility, setVisibility] = useState<DatasetVisibility>('private');
const [tags, setTags] = useState('');
useEffect(() => {
const d = dsQ.data;
if (!d) return;
setName(d.name);
setDescription(d.description);
setVisibility(d.visibility);
setTags(d.modalityTags.join(', '));
}, [dsQ.data]);
const saveMut = useMutation({
mutationFn: () =>
updateDataset(id, {
name: name.trim(),
description: description.trim(),
visibility,
modalityTags: tags
.split(',')
.map((t) => t.trim())
.filter(Boolean),
}),
onSuccess: () => {
toast.success('Đã lưu cài đặt');
qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id] });
},
onError: () => toast.error('Không lưu được cài đặt'),
});
const deleteMut = useMutation({
mutationFn: () => deleteDataset(id),
onSuccess: () => {
toast.success('Đã xóa bộ dữ liệu');
navigate('/dashboard');
},
onError: () => toast.error('Không xóa được bộ dữ liệu'),
});
const addStageMut = useMutation({
mutationFn: (kind: StageKind) => {
const existing = (stagesQ.data ?? []).map((s) => s.name);
const base = kind === 'label' ? 'Label' : 'Review';
let nm = base;
let i = 1;
while (existing.includes(nm)) {
i += 1;
nm = `${base}_${i}`;
}
return addDatasetStage(id, { name: nm, kind, reviewPercent: kind === 'review' ? 100 : null });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['imagehub', 'dataset', id, 'stages'] }),
onError: () => toast.error('Không thêm được giai đoạn'),
});
const ds = dsQ.data;
const stages = stagesQ.data ?? [];
const dirty =
!!ds &&
(name.trim() !== ds.name ||
description.trim() !== ds.description ||
visibility !== ds.visibility ||
tags
.split(',')
.map((t) => t.trim())
.filter(Boolean)
.join(',') !== ds.modalityTags.join(','));
const copyId = async () => {
try {
await navigator.clipboard.writeText(id);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error('Không sao chép được');
}
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<Button variant="ghost" size="sm" onClick={() => navigate(`/dashboard/datasets/${id}`)}>
<ArrowLeft className="mr-2 h-4 w-4" /> {ds?.name ?? 'Bộ dữ liệu'}
</Button>
<div>
<h1 className="font-serif text-2xl font-semibold text-foreground">Cài đt</h1>
<p className="text-sm text-muted-foreground">Xem chỉnh sửa cài đt của bộ dữ liệu.</p>
</div>
{dsQ.isLoading && <p className="text-sm text-muted-foreground">Đang tải</p>}
{dsQ.isError && <p className="text-sm text-destructive">Không tải đưc bộ dữ liệu.</p>}
{ds && (
<div className="grid gap-6 sm:grid-cols-[200px_1fr]">
<nav className="space-y-1">
<SubNavItem
icon={<Settings2 className="h-4 w-4" />}
label="Cài đặt chung"
active={section === 'general'}
onClick={() => setSection('general')}
/>
<SubNavItem
icon={<AlertTriangle className="h-4 w-4" />}
label="Vùng nguy hiểm"
active={section === 'danger'}
danger
onClick={() => setSection('danger')}
/>
</nav>
<div className="space-y-6">
{section === 'general' && (
<>
{/* Read-only metadata */}
<Card>
<CardHeader>
<CardTitle className="text-base">Thông tin chung</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-4">
<MetaRow label="Mã bộ dữ liệu">
<span className="flex items-center gap-1">
<span className="truncate font-mono text-xs">{ds.id}</span>
<button
type="button"
onClick={copyId}
className="text-muted-foreground hover:text-foreground"
title="Sao chép"
>
{copied ? <Check className="h-3.5 w-3.5 text-accent" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</span>
</MetaRow>
<MetaRow label="Chủ sở hữu">{ds.ownerEmail || ds.ownerUserId}</MetaRow>
<MetaRow label="Ngày tạo">
{ds.createdAt ? new Date(ds.createdAt).toLocaleString('vi-VN') : '—'}
</MetaRow>
<MetaRow label="Nhánh mặc định">
<span className="font-mono text-xs">{ds.defaultBranch}</span>
</MetaRow>
<MetaRow label="Số tệp">{ds.fileCount}</MetaRow>
<MetaRow label="Số phiên bản">{ds.versionCount}</MetaRow>
</dl>
</CardContent>
</Card>
{/* Editable fields */}
<Card>
<CardHeader>
<CardTitle className="text-base">Chỉnh sửa</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ds-name">Tên bộ dữ liệu</Label>
<Input id="ds-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="ds-desc"> tả</Label>
<Textarea
id="ds-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label>Phạm vi</Label>
<Select value={visibility} onValueChange={(v) => setVisibility(v as DatasetVisibility)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">{VISIBILITY_LABEL.private}</SelectItem>
<SelectItem value="internal">{VISIBILITY_LABEL.internal}</SelectItem>
<SelectItem value="public">{VISIBILITY_LABEL.public}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="ds-tags">Thẻ phương thức</Label>
<Input
id="ds-tags"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="CT, MRI, Kidney…"
/>
</div>
</div>
<div className="flex justify-end">
<Button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending}>
<Save className="mr-2 h-4 w-4" /> {saveMut.isPending ? 'Đang lưu…' : 'Lưu thay đổi'}
</Button>
</div>
</CardContent>
</Card>
{/* Stages pipeline — the labeling workflow */}
<Card>
<CardHeader>
<CardTitle className="text-base">Giai đoạn xử </CardTitle>
<p className="text-sm text-muted-foreground">
Quy trình gán nhãn kiểm duyệt cho bộ dữ liệu này. Bật Tự đng gán việc đ hệ thống
phân công nhiệm vụ mỗi giai đoạn.
</p>
</CardHeader>
<CardContent>
{stages.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Chưa giai đoạn nào. Thêm giai đoạn gán nhãn hoặc kiểm duyệt bên dưới.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tên giai đoạn</TableHead>
<TableHead>Loại</TableHead>
<TableHead>Review %</TableHead>
<TableHead>Tự đng gán việc</TableHead>
<TableHead className="w-12" />
</TableRow>
</TableHeader>
<TableBody>
{stages.map((s) => (
<StageRow key={s.id} datasetId={id} stage={s} />
))}
</TableBody>
</Table>
)}
<div className="mt-3 flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => addStageMut.mutate('label')}
disabled={addStageMut.isPending}
>
<Plus className="mr-1 h-4 w-4" /> Giai đoạn gán nhãn
</Button>
<Button
variant="outline"
size="sm"
onClick={() => addStageMut.mutate('review')}
disabled={addStageMut.isPending}
>
<Plus className="mr-1 h-4 w-4" /> Giai đoạn kiểm duyệt
</Button>
</div>
</CardContent>
</Card>
<MembersCard datasetId={id} />
</>
)}
{section === 'danger' && (
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-base text-destructive">Xóa bộ dữ liệu</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
Hành đng này không thể hoàn tác. Toàn bộ tệp, phiên bản, giai đoạn nhật của bộ dữ liệu
sẽ bị xóa vĩnh viễn.
</p>
{!confirmDelete ? (
<Button variant="destructive" onClick={() => setConfirmDelete(true)}>
<Trash2 className="mr-2 h-4 w-4" /> Xóa bộ dữ liệu
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Bạn chắc chắn?</span>
<Button
variant="destructive"
onClick={() => deleteMut.mutate()}
disabled={deleteMut.isPending}
>
{deleteMut.isPending ? 'Đang xóa…' : 'Xóa vĩnh viễn'}
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Hủy
</Button>
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
)}
</div>
);
}
export default DatasetSettingsPage;
@@ -0,0 +1,92 @@
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Database, Plus, FileStack, GitBranch } from 'lucide-react';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
listDatasets,
VISIBILITY_LABEL,
} from '@ump/shared';
/** Landing page — the investigator's own imaging datasets (their research data). */
export function DatasetsListPage() {
const navigate = useNavigate();
const { data, isLoading, isError } = useQuery({
queryKey: ['imagehub', 'datasets', 'mine'],
queryFn: () => listDatasets({ scope: 'mine' }),
});
return (
<div className="mx-auto max-w-5xl space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-serif text-2xl font-semibold text-foreground">Bộ dữ liệu của tôi</h1>
<p className="text-sm text-muted-foreground">
Quản phiên bản hóa dữ liệu hình nh nghiên cứu của bạn.
</p>
</div>
<Button asChild>
<Link to="/dashboard/datasets/new">
<Plus className="mr-2 h-4 w-4" /> Tạo bộ dữ liệu mới
</Link>
</Button>
</div>
{isLoading && <p className="text-sm text-muted-foreground">Đang tải</p>}
{isError && <p className="text-sm text-destructive">Không tải đưc danh sách bộ dữ liệu.</p>}
{data && data.length === 0 && (
<Card className="text-center">
<CardContent className="py-12">
<Database className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Bạn chưa bộ dữ liệu nào. Bấm Tạo bộ dữ liệu mới đ bắt đu.
</p>
</CardContent>
</Card>
)}
<div className="grid gap-4 sm:grid-cols-2">
{data?.map((d) => (
<Card
key={d.id}
className="card-hover cursor-pointer"
onClick={() => navigate(`/dashboard/datasets/${d.id}`)}
>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base leading-snug">{d.name || '(Chưa đặt tên)'}</CardTitle>
<Badge variant="outline">{VISIBILITY_LABEL[d.visibility]}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-1 text-sm text-muted-foreground">
{d.description ? <p className="line-clamp-2">{d.description}</p> : null}
<div className="flex items-center gap-4 pt-1">
<span className="flex items-center gap-1">
<FileStack className="h-3.5 w-3.5" /> {d.fileCount} tệp
</span>
<span className="flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" /> {d.versionCount} phiên bản
</span>
</div>
{d.modalityTags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{d.modalityTags.map((t) => (
<Badge key={t} variant="secondary" className="text-[11px]">
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
);
}
@@ -0,0 +1,103 @@
import { useState } from 'react';
import { Images, Upload } from 'lucide-react';
import { Button, Card, CardContent } from '@ump/shared';
import { ImageSequenceViewer } from '@ump/shared/video-viewer';
/**
* Demo surface for the 2D image-sequence quad-viewer (no be0 image-sequence model yet).
* Pick a FOLDER of PNG/JPG frames per channel (original / depth / segmentation); the viewer
* natural-sorts them and the frame slider scrubs all panes in sync. Q4 fuses seg over original.
*/
const IMAGE_RE = /\.(png|jpe?g|bmp|webp)$/i;
export function ImageSequenceDemoPage() {
const [original, setOriginal] = useState<File[]>([]);
const [depth, setDepth] = useState<File[]>([]);
const [seg, setSeg] = useState<File[]>([]);
return (
<div className="mx-auto flex h-full max-w-6xl flex-col gap-4">
<div className="flex items-center gap-2.5">
<Images className="h-6 w-6 shrink-0 text-primary" />
<div>
<h1 className="font-serif text-2xl font-semibold text-foreground">Trình xem chuỗi nh 2D</h1>
<p className="text-sm text-muted-foreground">
Bộ xem 4 khung cho chuỗi nh PNG: ảnh gốc · bản đ đ sâu · mặt nạ phân vùng · phủ phân
vùng. Thanh trượt khung chạy như video.
</p>
</div>
</div>
<Card>
<CardContent className="flex flex-wrap gap-3 p-4">
<FolderPicker label="Ảnh gốc" files={original} onPick={setOriginal} />
<FolderPicker label="Bản đồ độ sâu" files={depth} onPick={setDepth} />
<FolderPicker label="Mặt nạ phân vùng" files={seg} onPick={setSeg} />
</CardContent>
</Card>
<div className="min-h-0 flex-1">
{original.length > 0 ? (
<div className="h-[66vh] w-full overflow-hidden rounded-md bg-black">
<ImageSequenceViewer
original={{ files: original, label: 'Ảnh gốc' }}
depth={depth.length ? { files: depth, label: 'Bản đồ độ sâu' } : undefined}
segmentation={seg.length ? { files: seg, label: 'Mặt nạ phân vùng', opacity: 0.6 } : undefined}
fps={10}
/>
</div>
) : (
<Card className="flex h-[66vh] items-center justify-center text-center">
<CardContent className="py-12">
<Images className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Chọn một thư mục nh gốc đ bắt đu. Bản đ đ sâu mặt nạ phân vùng tuỳ chọn.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}
function FolderPicker({
label,
files,
onPick,
}: {
label: string;
files: File[];
onPick: (f: File[]) => void;
}) {
const inputId = `pick-${label}`;
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<input
id={inputId}
type="file"
multiple
accept="image/*"
className="hidden"
// webkitdirectory is non-standard; set via ref so TS/JSX stays happy.
ref={(el) => {
if (el) el.setAttribute('webkitdirectory', '');
}}
onChange={(e) => {
const picked = Array.from(e.target.files ?? []).filter((f) => IMAGE_RE.test(f.name));
onPick(picked);
}}
/>
<Button asChild variant="outline" size="sm" className="h-9">
<label htmlFor={inputId} className="cursor-pointer">
<Upload className="mr-1.5 h-4 w-4" />
<span className="max-w-[160px] truncate">
{files.length ? `${files.length} ảnh` : 'Chọn thư mục…'}
</span>
</label>
</Button>
</div>
);
}
@@ -0,0 +1,5 @@
import { LoginRegisterCard } from '@ump/shared';
export function LoginPage() {
return <LoginRegisterCard registerPath="/register" registerLabel="Đăng ký" />;
}
@@ -0,0 +1,248 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowUpRight, FileText, FlaskConical, Plus, Search } from 'lucide-react';
import {
Button,
Card,
CardContent,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
cn,
listMyProjects,
PROJECT_STATUS_LABEL,
type ProjectStatus,
type ResearchProject,
} from '@ump/shared';
/**
* Semantic status pill — keyed by the lifecycle enum (the proposal labels differ slightly
* from the cockpit status vocabulary, so the shared `toneClass` map can't be reused as-is).
* Colours mirror the cockpit TONE_CLASS palette for visual consistency.
*/
const STATUS_TONE: Record<ProjectStatus, string> = {
approved: 'bg-emerald-100 text-emerald-700 border-emerald-200',
submitted: 'bg-amber-100 text-amber-700 border-amber-200',
draft: 'bg-slate-100 text-slate-600 border-slate-200',
rejected: 'bg-rose-100 text-rose-700 border-rose-200',
};
function StatusBadge({ status }: { status: ProjectStatus }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium',
STATUS_TONE[status],
)}
>
{PROJECT_STATUS_LABEL[status]}
</span>
);
}
type TabKey = 'all' | ProjectStatus;
// Filter tabs, ordered along the proposal lifecycle (mirrors the báo cáo list ordering).
const TABS: Array<{ key: TabKey; label: string }> = [
{ key: 'all', label: 'Tất cả' },
{ key: 'draft', label: PROJECT_STATUS_LABEL.draft },
{ key: 'submitted', label: PROJECT_STATUS_LABEL.submitted },
{ key: 'approved', label: PROJECT_STATUS_LABEL.approved },
{ key: 'rejected', label: PROJECT_STATUS_LABEL.rejected },
];
/**
* Landing page — the PI's research projects, presented as a filterable table (matching the
* "Danh sách báo cáo" layout). Approved projects open the cockpit; anything else opens the
* proposal (draft editor / read-only pending view).
*/
export function ProjectsListPage() {
const navigate = useNavigate();
const [tab, setTab] = useState<TabKey>('all');
const [query, setQuery] = useState('');
const { data: projects, isLoading, isError } = useQuery({
queryKey: ['research', 'projects', 'mine'],
queryFn: () => listMyProjects(),
});
const open = (p: ResearchProject) =>
navigate(p.status === 'approved' ? `/dashboard/projects/${p.id}` : `/dashboard/proposals/${p.id}`);
// Per-status counts for the filter-tab badges.
const counts = useMemo(() => {
const c: Record<TabKey, number> = { all: 0, draft: 0, submitted: 0, approved: 0, rejected: 0 };
for (const p of projects ?? []) {
c.all += 1;
c[p.status] += 1;
}
return c;
}, [projects]);
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return (projects ?? []).filter((p) => {
if (tab !== 'all' && p.status !== tab) return false;
if (!q) return true;
return (
(p.title || '').toLowerCase().includes(q) ||
(p.code || '').toLowerCase().includes(q) ||
(p.level || '').toLowerCase().includes(q)
);
});
}, [projects, tab, query]);
return (
<div className="mx-auto max-w-6xl space-y-5">
{/* Page header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<FlaskConical className="h-6 w-6 shrink-0 text-primary" />
<div>
<h1 className="font-serif text-2xl font-semibold text-foreground">Đ tài của tôi</h1>
<p className="text-sm text-muted-foreground">
Đăng thuyết minh quản tiến đ đ tài nghiên cứu.
</p>
</div>
</div>
<Button asChild>
<Link to="/dashboard/proposals/new">
<Plus className="mr-2 h-4 w-4" /> Đăng đ tài mới
</Link>
</Button>
</div>
{isLoading && <p className="text-sm text-muted-foreground">Đang tải</p>}
{isError && <p className="text-sm text-destructive">Không tải đưc danh sách đ tài.</p>}
{/* Empty (no projects at all) */}
{projects && projects.length === 0 && (
<Card className="text-center">
<CardContent className="py-12">
<FlaskConical className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Bạn chưa đ tài nào. Bấm Đăng đ tài mới đ bắt đu.
</p>
</CardContent>
</Card>
)}
{/* List panel: section header + count, search, filter tabs, table */}
{projects && projects.length > 0 && (
<Card>
<CardContent className="space-y-4 p-5">
{/* Section header: title + count (left) · search (right) */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-serif text-lg font-semibold text-foreground">Danh sách đ tài</h2>
<p className="text-sm text-muted-foreground">{projects.length} đ tài</p>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Tìm mã số, tên đề tài, cấp…"
className="h-9 w-64 pl-8"
/>
</div>
</div>
{/* Filter tabs */}
<div className="flex flex-wrap gap-1 border-b border-border pb-2">
{TABS.map((tb) => (
<button
key={tb.key}
type="button"
onClick={() => setTab(tb.key)}
className={cn(
'inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition',
tab === tb.key ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-muted',
)}
>
{tb.label}
<span
className={cn(
'rounded-full px-1.5 text-xs',
tab === tb.key ? 'bg-background/20' : 'bg-muted text-muted-foreground',
)}
>
{counts[tb.key]}
</span>
</button>
))}
</div>
{/* Table / filtered-empty */}
{visible.length === 0 ? (
<div className="py-12 text-center text-sm text-muted-foreground">
Không tìm thấy đ tài phù hợp.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[130px]"> số</TableHead>
<TableHead>Tên đ tài</TableHead>
<TableHead className="w-[140px]">Cấp</TableHead>
<TableHead className="w-[110px]">Thời gian</TableHead>
<TableHead className="w-[150px]">Trạng thái</TableHead>
<TableHead className="w-[110px] text-right">Thao tác</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((p) => (
<TableRow key={p.id} className="cursor-pointer" onClick={() => open(p)}>
<TableCell className="font-medium tabular-nums text-foreground">
{p.code || '—'}
</TableCell>
<TableCell className="max-w-[420px]">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">
{p.title || '(Chưa đặt tên đề tài)'}
</span>
</div>
{p.status === 'rejected' && p.reviewNote ? (
<div className="mt-0.5 truncate pl-5 text-xs text-destructive">
do từ chối: {p.reviewNote}
</div>
) : null}
</TableCell>
<TableCell className="text-sm text-muted-foreground">{p.level || '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{p.periodMonths ? `${p.periodMonths} tháng` : '—'}
</TableCell>
<TableCell>
<StatusBadge status={p.status} />
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={(e) => {
e.stopPropagation();
open(p);
}}
>
Mở <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
);
}
@@ -0,0 +1,232 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { toast } from 'sonner';
import { Save, Send } from 'lucide-react';
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
createProject,
getProject,
submitProject,
updateProject,
PROJECT_STATUS_LABEL,
type ProjectStatus,
} from '@ump/shared';
import {
buildInitial,
collectMissing,
schema,
type FormValues,
type MissingField,
type RepeatableRow,
} from '../components/proposal/proposalSchema';
import { FieldList, Repeatable } from '../components/proposal/ProposalFormFields';
/** Thuyết minh đề tài — schema-driven form over researchApi. New/draft is editable; once
* submitted/approved/rejected it renders read-only (approved redirects to the cockpit). */
export function ProposalFormPage() {
const { id: routeId } = useParams();
const navigate = useNavigate();
const [projectId, setProjectId] = useState<string | null>(routeId ?? null);
const [status, setStatus] = useState<ProjectStatus>('draft');
const [values, setValues] = useState<FormValues>(buildInitial);
const [errors, setErrors] = useState<Set<string>>(new Set());
const [missing, setMissing] = useState<MissingField[]>([]);
const [loading, setLoading] = useState<boolean>(!!routeId);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!routeId) return;
let cancelled = false;
setLoading(true);
getProject(routeId)
.then((p) => {
if (cancelled) return;
if (p.status === 'approved') {
navigate(`/dashboard/projects/${p.id}`, { replace: true });
return;
}
setProjectId(p.id);
setStatus(p.status);
setValues({ ...buildInitial(), ...(p.content as FormValues) });
})
.catch(() => toast.error('Không tải được đề tài.'))
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [routeId, navigate]);
const readOnly = status !== 'draft';
const setValue = (k: string, v: string | string[]) => setValues((p) => ({ ...p, [k]: v }));
const setRows = (k: string, rows: RepeatableRow[]) => setValues((p) => ({ ...p, [k]: rows }));
const persist = async (): Promise<string> => {
if (projectId) {
await updateProject(projectId, values);
return projectId;
}
const created = await createProject(values);
setProjectId(created.id);
window.history.replaceState(null, '', `/dashboard/proposals/${created.id}`);
return created.id;
};
const onSaveDraft = async () => {
setSaving(true);
try {
await persist();
toast.success('Đã lưu bản thảo.');
} catch {
toast.error('Lưu bản thảo thất bại.');
} finally {
setSaving(false);
}
};
const onSubmit = async () => {
const miss = collectMissing(values);
setErrors(new Set(miss.map((m) => m.key)));
setMissing(miss);
if (miss.length > 0) {
toast.error(`Còn ${miss.length} trường bắt buộc chưa nhập.`);
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
setSaving(true);
try {
const pid = await persist();
await submitProject(pid);
toast.success('Đã nộp đề tài. Vui lòng chờ phê duyệt.');
navigate('/dashboard/projects');
} catch {
toast.error('Nộp đề tài thất bại.');
} finally {
setSaving(false);
}
};
const subtitleVal = values[schema.meta.subtitleField];
const subtitle = subtitleVal ? ` CẤP ${String(subtitleVal).toUpperCase()}` : ' CẤP ...';
const actions = (
<div className="flex flex-wrap gap-2">
<Button onClick={onSaveDraft} variant="outline" disabled={saving}>
<Save className="mr-2 h-4 w-4" /> Lưu bản thảo
</Button>
<Button onClick={onSubmit} disabled={saving}>
<Send className="mr-2 h-4 w-4" /> Nộp đ tài
</Button>
</div>
);
if (loading) return <div className="p-6 text-sm text-muted-foreground">Đang tải</div>;
return (
<div className="mx-auto max-w-4xl space-y-5">
<Card>
<CardContent className="space-y-1 py-5 text-center">
<div className="text-xs text-muted-foreground">{schema.meta.formCode}</div>
<h1 className="font-serif text-xl font-bold tracking-wide text-foreground">
{schema.meta.title}
{subtitle}
</h1>
<div className="text-xs text-muted-foreground">{schema.meta.circular}</div>
</CardContent>
</Card>
{readOnly && (
<Alert>
<AlertDescription>
Đ tài đang trạng thái {PROJECT_STATUS_LABEL[status]} chỉ xem, không thể chỉnh sửa.
</AlertDescription>
</Alert>
)}
{!readOnly && actions}
{missing.length > 0 && (
<Alert variant="destructive">
<AlertDescription>
<strong>Còn {missing.length} trường bắt buộc chưa nhập:</strong>
<ul className="mt-2 list-disc pl-5">
{missing.map((m) => (
<li key={m.key}>{m.label}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{schema.topFields.length > 0 && (
<Card>
<CardContent className="py-5">
<fieldset disabled={readOnly}>
<FieldList fields={schema.topFields} values={values} errors={errors} onChange={setValue} />
</fieldset>
</CardContent>
</Card>
)}
{schema.sections.map((s) => (
<Card key={s.id} id={`sec-${s.id}`}>
<CardContent className="py-5">
<h2 className="mb-4 flex gap-2 border-b-2 border-primary pb-2.5 text-base font-bold text-foreground">
<span className="text-primary">{s.id}.</span>
<span>{s.title}</span>
</h2>
<fieldset disabled={readOnly}>
<FieldList fields={s.fields} values={values} errors={errors} onChange={setValue} />
{s.subsections?.map((sub) => (
<div key={sub.id} className="mt-5 border-t border-dashed border-border pt-4">
<h3 className="mb-3 text-sm font-semibold">{sub.title}</h3>
<FieldList
fields={sub.fields}
prefix={sub.keyPrefix}
values={values}
errors={errors}
onChange={setValue}
/>
</div>
))}
{s.repeatables?.map((rep) => (
<Repeatable
key={rep.key}
rep={rep}
rows={(values[rep.key] as RepeatableRow[]) ?? []}
onRows={setRows}
/>
))}
</fieldset>
</CardContent>
</Card>
))}
<Card>
<CardContent className="py-5">
<h2 className="mb-4 flex gap-2 border-b-2 border-primary pb-2.5 text-base font-bold text-foreground">
<span className="text-primary"></span>
<span>Xác nhận chữ </span>
</h2>
<fieldset disabled={readOnly}>
<FieldList
fields={Object.values(schema.signature)}
values={values}
errors={errors}
onChange={setValue}
/>
</fieldset>
</CardContent>
</Card>
{!readOnly && <div className="flex flex-wrap justify-end gap-2 pb-8">{actions}</div>}
</div>
);
}
@@ -0,0 +1,94 @@
import { useState } from 'react';
import { Film, Upload } from 'lucide-react';
import { Button, Card, CardContent } from '@ump/shared';
import { VideoQuadViewer } from '@ump/shared/video-viewer';
/**
* Demo surface for the video dataset quad-viewer (no be0 video dataset model yet).
* Pick a local original video (+ optional depth video + optional segmentation-mask video) and
* see the 2×2 quad layout. Q4 (3D reconstruction) is a placeholder until a recon source exists.
*/
export function VideoViewerDemoPage() {
const [video, setVideo] = useState<File | undefined>();
const [depth, setDepth] = useState<File | undefined>();
const [seg, setSeg] = useState<File | undefined>();
return (
<div className="mx-auto flex h-full max-w-6xl flex-col gap-4">
<div className="flex items-center gap-2.5">
<Film className="h-6 w-6 shrink-0 text-primary" />
<div>
<h1 className="font-serif text-2xl font-semibold text-foreground">Trình xem video</h1>
<p className="text-sm text-muted-foreground">
Bộ xem 4 khung: video gốc · bản đ đ sâu · mặt nạ phân vùng · tái tạo 3D (tạm đ trống).
</p>
</div>
</div>
{/* Source pickers */}
<Card>
<CardContent className="flex flex-wrap gap-3 p-4">
<FilePicker label="Video gốc" accept="video/*" file={video} onPick={setVideo} />
<FilePicker label="Bản đồ độ sâu" accept="video/*" file={depth} onPick={setDepth} />
<FilePicker label="Mặt nạ phân vùng" accept="video/*,image/*" file={seg} onPick={setSeg} />
</CardContent>
</Card>
{/* Quad viewer */}
<div className="min-h-0 flex-1">
{video ? (
<div className="h-[68vh] w-full overflow-hidden rounded-md bg-black">
<VideoQuadViewer
video={{ file: video, label: 'Video gốc' }}
depth={depth ? { file: depth, label: 'Bản đồ độ sâu' } : undefined}
segmentation={seg ? { file: seg, label: 'Mặt nạ phân vùng', opacity: 0.6 } : undefined}
recon={{ placeholderText: 'Tái tạo 3D — chưa có dữ liệu' }}
/>
</div>
) : (
<Card className="flex h-[68vh] items-center justify-center text-center">
<CardContent className="py-12">
<Film className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Chọn một video gốc đ bắt đu. Bản đ đ sâu mặt nạ phân vùng tuỳ chọn.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}
function FilePicker({
label,
accept,
file,
onPick,
}: {
label: string;
accept: string;
file?: File;
onPick: (f: File | undefined) => void;
}) {
const inputId = `pick-${label}`;
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">{label}</span>
<input
id={inputId}
type="file"
accept={accept}
className="hidden"
onChange={(e) => onPick(e.target.files?.[0])}
/>
<Button asChild variant="outline" size="sm" className="h-9">
<label htmlFor={inputId} className="cursor-pointer">
<Upload className="mr-1.5 h-4 w-4" />
<span className="max-w-[160px] truncate">{file ? file.name : 'Chọn tệp…'}</span>
</label>
</Button>
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}", "../shared/src/**/*.{ts,tsx}"],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
serif: ['Merriweather', 'Georgia', 'serif'],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@ump/shared/viewer": ["../shared/src/components/viewer/index.ts"],
"@ump/shared/video-viewer": ["../shared/src/components/video-viewer/index.ts"],
"@ump/shared": ["../shared/src/index.ts"]
},
"types": ["vite/client"]
},
"include": ["src"]
}
+49
View File
@@ -0,0 +1,49 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
// Proxy /api → be0 (locally :4402; in Docker set VITE_DEV_PROXY_TARGET=http://be0:4402).
const apiProxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:4402';
// Vite 5 checks the Host header; allow all when VITE_ALLOWED_HOSTS is unset, else the list.
const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? '')
.split(',')
.map((h) => h.trim())
.filter(Boolean);
export default defineConfig(({ mode }) => ({
server: {
host: '0.0.0.0',
port: 5175,
allowedHosts: allowedHosts.length > 0 ? allowedHosts : true,
proxy: {
'/api': { target: apiProxyTarget, changeOrigin: true, secure: false },
'/submitted-initiatives': { target: apiProxyTarget, changeOrigin: true, secure: false },
},
},
plugins: [react()],
resolve: {
// Array form so match order is explicit: the viewer subpath must be tried
// before the bare '@ump/shared' entry (which prefix-matches '@ump/shared/…').
// Consume the shared kernel as source (no build step) — Vite transpiles it.
alias: [
{
find: '@ump/shared/viewer',
replacement: path.resolve(__dirname, '../shared/src/components/viewer/index.ts'),
},
{
find: '@ump/shared/video-viewer',
replacement: path.resolve(__dirname, '../shared/src/components/video-viewer/index.ts'),
},
{ find: '@ump/shared', replacement: path.resolve(__dirname, '../shared/src/index.ts') },
{ find: '@', replacement: path.resolve(__dirname, './src') },
],
},
build: {
// Never ship source maps — keeps the original TypeScript un-reconstructable in DevTools.
sourcemap: false,
},
esbuild: {
// Strip console/debugger from production bundles so no debug/internal info leaks.
drop: mode === 'production' ? ['console', 'debugger'] : [],
},
}));
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
// Pure-logic unit tests for the data-import domain layer (features/data-import/domain/**).
// `node` env is enough — the domain is framework-free and uses only Web globals (no DOM,
// no React, no axios). Mirrors shared/vitest.config.ts so `npm test -w frontend_investigator`
// behaves like the rest of the monorepo.
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
},
});