sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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">Cơ 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 cơ quan và 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 1–2 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 lý thành viên, vai trò và 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ọ và 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 type’s 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 001–010 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 };
|
||||
}
|
||||
+22
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
+17
@@ -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 tư 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>
|
||||
);
|
||||
}
|
||||
+257
@@ -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" /> Có {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">… và {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ử lý 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ử lý…
|
||||
</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 };
|
||||
}
|
||||
+267
@@ -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 và {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 & 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="1–999. ID 001–010 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ỉ là 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 là 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,
|
||||
};
|
||||
}
|
||||
+728
@@ -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">Mã 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" /> có
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">chưa có</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 có 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 có 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>
|
||||
Mô 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 có tệp nào.</p>
|
||||
<p className="text-xs text-muted-foreground">Tải ảnh gốc và 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 có 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>
|
||||
);
|
||||
}
|
||||
+58
@@ -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 có 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 có 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 và 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 user’s 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" /> Rà 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 có 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>
|
||||
);
|
||||
}
|
||||
+201
@@ -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 có 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ó 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 có lượt rà 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 có 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" /> Mô 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 có 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 ký chỉ-thêm (append-only). Mỗi thay đổi ghi lại ai thao tác, lúc nào và 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 có 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 có {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">Mô 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 có 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 có 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 có thể xem bộ dữ liệu và xử lý công việc được
|
||||
giao cho họ; quản lý 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 có 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 và 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">Mô 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ử lý</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quy trình gán nhãn và 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 có 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 và nhật ký 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 lý và 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 có 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 và mặt nạ phân vùng là 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 ký thuyết minh và quản lý 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 ký đề 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 có đề tài nào. Bấm “Đăng ký đề 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]">Mã 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">
|
||||
Lý 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">Ký</span>
|
||||
<span>Xác nhận và chữ ký</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 và mặt nạ phân vùng là 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user