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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
{
"name": "@ump/shared",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Shared kernel for the UMP frontends: api client, auth, permissions, token, types, login UI.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./viewer": "./src/components/viewer/index.ts",
"./video-viewer": "./src/components/video-viewer/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@kitware/vtk.js": "^34.16.2",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"docx-preview": "^0.3.7",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"lucide-react": "^0.462.0",
"nifti-reader-js": "^0.8.0",
"pdf-lib": "^1.17.1",
"react-day-picker": "^8.10.1",
"react-resizable-panels": "^2.1.9",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0"
},
"peerDependencies": {
"axios": "^1.13.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@types/react": "^18.3.23",
"react-router-dom": "^6.30.1",
"typescript": "^5.8.3",
"vite": "^5.4.19",
"vitest": "^3.2.4"
}
}
+318
View File
@@ -0,0 +1,318 @@
/**
* API Client
* Centralized HTTP client with interceptors, error handling, and type safety.
* (Ported from fe0/src/shared/api/client.ts — only the two intra-package import paths changed.)
*/
import axios, {
AxiosInstance,
AxiosError,
InternalAxiosRequestConfig,
AxiosRequestConfig,
} from 'axios';
import { env } from '../config/env';
import { getAccessToken, clearAccessToken, setAccessToken } from '../utils/token';
export interface ApiError {
message: string;
status: number;
data?: any;
code?: string;
}
/** Thrown by {@link ApiClient.postArrayBuffer} on HTTP error responses so callers can inspect status (e.g. 501). */
export class ApiArrayBufferRequestError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiArrayBufferRequestError';
this.status = status;
}
}
/** True when the client rejected a response with 404 (Axios or normalized {@link ApiError}). */
export function isApiNotFoundError(err: unknown): boolean {
if (err instanceof AxiosError && err.response?.status === 404) return true;
if (err && typeof err === 'object') {
const s = (err as Partial<ApiError>).status;
if (typeof s === 'number' && s === 404) return true;
}
return false;
}
/** Used by QueryClient retry: never backoff-retry auth failures (avoids poll storms on expired sessions). */
export function isAuthQueryError(err: unknown): boolean {
if (err instanceof AxiosError) {
const s = err.response?.status;
return s === 401 || s === 403;
}
if (err && typeof err === 'object') {
const s = (err as Partial<ApiError>).status;
if (typeof s === 'number') return s === 401 || s === 403;
}
return false;
}
function summarizeResponseDataForDevLog(data: unknown): unknown {
if (data == null) return data;
if (typeof data !== 'object') return data;
const rec = data as Record<string, unknown>;
if (Array.isArray(rec.data)) {
return { ...rec, data: `[${rec.data.length} items]` };
}
return data;
}
function stringifyFastApiDetail(detail: unknown): string | undefined {
if (detail == null) return undefined;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
const parts = detail
.map((x) =>
typeof x === 'object' && x && 'msg' in x ? String((x as { msg: string }).msg) : String(x),
)
.filter(Boolean);
return parts.length ? parts.join(' ') : undefined;
}
return undefined;
}
/** True for the synthetic "HTTP <status>" message the interceptor attaches to 4xx rejections. */
function isSyntheticHttpMessage(message: unknown): boolean {
return typeof message === 'string' && /^HTTP \d+$/.test(message.trim());
}
/** Human-readable message from ApiClient errors, ApiError-shaped objects, or raw AxiosErrors. */
export function detailFromApiError(err: unknown, fallback = 'Lỗi không xác định.'): string {
if (err && typeof err === 'object') {
const e = err as ApiError & {
response?: { data?: { detail?: unknown; message?: string } };
};
// Prefer the FastAPI `{detail}` — on a raw AxiosError it lives at err.response.data.detail;
// on a normalized ApiError it lives at err.data.detail. Either beats the synthetic "HTTP <status>".
const detail = stringifyFastApiDetail(e.response?.data?.detail ?? e.data?.detail);
if (detail) return detail;
const respMessage = e.response?.data?.message;
if (typeof respMessage === 'string' && respMessage.trim()) return respMessage;
if (typeof e.message === 'string' && e.message.trim() && !isSyntheticHttpMessage(e.message)) {
return e.message;
}
}
if (err instanceof Error && err.message.trim() && !isSyntheticHttpMessage(err.message)) {
return err.message;
}
return fallback;
}
class ApiClient {
private client: AxiosInstance;
constructor() {
if (import.meta.env.DEV) {
console.log('API Client initialized with baseURL:', env.API_URL);
}
this.client = axios.create({
baseURL: env.API_URL,
timeout: 60000, // 60s for Ollama responses
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
});
this.setupInterceptors();
}
private setupInterceptors(): void {
this.client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
// Multipart uploads: let the browser set Content-Type (boundary). A global
// application/json default breaks FastAPI's UploadFile / Form parsing.
if (config.data instanceof FormData && config.headers) {
delete (config.headers as Record<string, unknown>)['Content-Type'];
}
return config;
},
(error) => Promise.reject(error),
);
this.client.interceptors.response.use(
(response) => {
// Default validateStatus allows 4xx; reject here so `.then(res => res.data)` never treats
// FastAPI `{ detail }` bodies as successful entity payloads.
if (response.status >= 400) {
const err = new AxiosError(
`HTTP ${response.status}`,
AxiosError.ERR_BAD_REQUEST,
response.config,
response.request,
response,
);
return Promise.reject(err);
}
if (import.meta.env.DEV) {
console.debug('✅ API OK', {
url: response.config.url,
status: response.status,
data: summarizeResponseDataForDevLog(response.data),
});
}
return response;
},
async (error: AxiosError) => {
if (import.meta.env.DEV) {
console.error('❌ API Error:', {
message: error.message,
code: error.code,
response: error.response
? { status: error.response.status, data: error.response.data }
: null,
config: {
url: error.config?.url,
baseURL: error.config?.baseURL,
method: error.config?.method,
},
});
}
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Handle 401 - Unauthorized → try one refresh.
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newToken = await this.refreshToken();
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.client(originalRequest);
}
} catch (refreshError) {
clearAccessToken();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(refreshError);
}
}
return Promise.reject(this.handleError(error));
},
);
}
private async refreshToken(): Promise<string | null> {
try {
const response = await axios.post(
`${env.API_URL}/api/v1/auth/refresh`,
{},
{ withCredentials: true, timeout: 15_000 },
);
const { accessToken } = response.data;
setAccessToken(accessToken);
return accessToken;
} catch (error) {
if (import.meta.env.DEV) {
console.error('Token refresh failed:', error);
}
return null;
}
}
private handleError(error: AxiosError): ApiError {
if (error.response) {
const data = error.response.data as any;
const detailStr = stringifyFastApiDetail(data?.detail);
return {
message: data?.message || detailStr || 'An error occurred',
status: error.response.status,
data,
code: data?.code,
};
} else if (error.request) {
return {
message: `Network error. Please check your connection. URL: ${error.config?.baseURL}${error.config?.url}`,
status: 0,
code: 'NETWORK_ERROR',
};
}
return {
message: error.message || 'An unexpected error occurred',
status: 0,
code: 'UNKNOWN_ERROR',
};
}
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.client.get<T>(url, config).then((res) => res.data);
}
public post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.post<T>(url, data, config).then((res) => res.data);
}
/** Binary POST (e.g. .docx). On 4xx/5xx, attempts to read JSON or text from the error body. */
public postArrayBuffer(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ArrayBuffer> {
const decodeErrorBody = (raw: ArrayBuffer, status: number) => {
const text = new TextDecoder().decode(new Uint8Array(raw));
let message = `HTTP ${status}`;
try {
const j = JSON.parse(text) as { detail?: unknown; message?: string };
const d = j.detail;
if (typeof d === 'string') message = d;
else if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') {
message = (d[0] as { msg: string }).msg;
} else if (j.message) message = j.message;
} catch {
if (text.trim()) message = text;
}
return new ApiArrayBufferRequestError(message, status);
};
return this.client
.post(url, data, { ...config, responseType: 'arraybuffer' })
.then((res) => {
if (res.status >= 400) {
return Promise.reject(decodeErrorBody(res.data as ArrayBuffer, res.status));
}
return res.data as ArrayBuffer;
})
.catch((err: unknown) => {
const ax = err as { response?: { status?: number; data?: ArrayBuffer } };
if (ax?.response?.data instanceof ArrayBuffer && ax.response?.status) {
return Promise.reject(decodeErrorBody(ax.response.data, ax.response.status));
}
if (err && typeof err === 'object' && 'status' in err) {
const ae = err as ApiError;
if (typeof ae.status === 'number' && ae.status >= 400) {
const msg =
typeof ae.message === 'string' && ae.message.trim()
? ae.message
: stringifyFastApiDetail(ae.data?.detail) || `HTTP ${ae.status}`;
return Promise.reject(new ApiArrayBufferRequestError(msg, ae.status));
}
}
return Promise.reject(err);
});
}
public put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.put<T>(url, data, config).then((res) => res.data);
}
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.client.delete<T>(url, config).then((res) => res.data);
}
public patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.patch<T>(url, data, config).then((res) => res.data);
}
}
export const apiClient = new ApiClient();
+105
View File
@@ -0,0 +1,105 @@
/**
* Lean auth state for the app shells (login + session bootstrap + RBAC helpers).
*
* Talks to the same backend contract as fe0: POST /api/v1/auth/login → { accessToken, user },
* GET /api/v1/auth/me, token in localStorage. The heavier fe0 `auth-service` (staff-profile
* machinery) migrates here with the profile pages in a later chunk.
*/
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { apiClient } from '../api/client';
import { clearAccessToken, getAccessToken, setAccessToken } from '../utils/token';
import { type Permission, type Role, hasPermission as roleHasPermission } from '../lib/permissions';
export interface AuthUser {
id: string;
email: string;
name: string;
roles: string[];
phone?: string | null;
emailVerified?: boolean;
}
interface LoginResponse {
accessToken: string;
user: AuthUser;
}
export interface AuthContextValue {
user: AuthUser | null;
roles: Role[];
isAuthenticated: boolean;
loading: boolean;
login: (email: string, password: string) => Promise<AuthUser>;
logout: () => void;
hasPermission: (permission: Permission) => boolean;
hasRole: (role: Role) => boolean;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const KNOWN_ROLES: ReadonlySet<string> = new Set(['admin', 'editor', 'viewer']);
function rolesOf(user: AuthUser | null): Role[] {
return ((user?.roles ?? []) as string[]).filter((r): r is Role => KNOWN_ROLES.has(r));
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
(async () => {
if (!getAccessToken()) {
setLoading(false);
return;
}
try {
const me = await apiClient.get<AuthUser>('/api/v1/auth/me');
if (active) setUser(me);
} catch {
clearAccessToken();
} finally {
if (active) setLoading(false);
}
})();
return () => {
active = false;
};
}, []);
const login = async (email: string, password: string): Promise<AuthUser> => {
const res = await apiClient.post<LoginResponse>('/api/v1/auth/login', { email, password });
setAccessToken(res.accessToken);
setUser(res.user);
return res.user;
};
const logout = (): void => {
clearAccessToken();
setUser(null);
};
const roles = rolesOf(user);
const value: AuthContextValue = {
user,
roles,
isAuthenticated: user !== null,
loading,
login,
logout,
hasPermission: (permission) => roleHasPermission(roles, permission),
hasRole: (role) => roles.includes(role),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) {
throw new Error('useAuth must be used within an <AuthProvider>');
}
return ctx;
}
+117
View File
@@ -0,0 +1,117 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { INSTITUTIONAL_EMAIL_HINT, validateInstitutionalEmail } from './institutionalEmail';
import { forgotPassword } from './authOperations';
import { detailFromApiError } from '../api/client';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Alert, AlertDescription } from '../components/ui/alert';
import { AlertCircle, Mail } from 'lucide-react';
import { Separator } from '../components/ui/separator';
/**
* Forgot-password page — visual port of fe0/src/pages/ForgotPasswordPage.tsx.
*
* Markup/className tree preserved verbatim. The data call is rewired from fe0's
* `authService.requestPasswordReset` (which returned `{ success, message, error }`)
* to the shared `forgotPassword(email)` which resolves with the server message and
* throws on error — failures surface via `detailFromApiError`.
*/
export function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccessMessage('');
const em = email.trim().toLowerCase();
if (!validateInstitutionalEmail(em)) {
setError('Vui lòng dùng email UMP hoặc UMC (đuôi @ump.edu.vn / @umc.edu.vn).');
return;
}
setIsLoading(true);
try {
const message = await forgotPassword(em);
setSuccessMessage(message || 'Nếu email tồn tại, hướng dẫn đặt lại mật khẩu đã được gửi.');
} catch (err) {
setError(detailFromApiError(err, 'Yêu cầu thất bại.'));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-foreground">Quên mật khẩu</h1>
<p className="text-muted-foreground mt-2">Nhập email UMP hoặc UMC đã đăng </p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Khôi phục tài khoản
</CardTitle>
<CardDescription>
<span className="text-xs text-muted-foreground">{INSTITUTIONAL_EMAIL_HINT}</span>
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="forgot-email">Email</Label>
<Input
id="forgot-email"
type="email"
autoComplete="email"
placeholder="ten@ump.edu.vn"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert>
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
)}
<div className="flex justify-center">
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Đang xử lý...' : 'Gửi hướng dẫn'}
</Button>
</div>
</form>
</CardContent>
</Card>
<Separator className="my-6" />
<div className="text-center space-y-2">
<Link to="/login" className="text-sm text-muted-foreground hover:text-foreground block">
Quay lại đăng nhập
</Link>
<Link to="/" className="text-sm text-muted-foreground hover:text-foreground block">
Trang chủ
</Link>
</div>
</div>
</div>
);
}
+180
View File
@@ -0,0 +1,180 @@
import { useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import { useAuth } from './AuthProvider';
import { detailFromApiError } from '../api/client';
import {
INSTITUTIONAL_EMAIL_HINT,
validateInstitutionalEmail,
} from './institutionalEmail';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Alert, AlertDescription } from '../components/ui/alert';
import { Separator } from '../components/ui/separator';
import { LogIn, AlertCircle } from 'lucide-react';
/**
* Login card — visual port of fe0/src/auth/LoginRegisterCard.tsx.
*
* The markup/className tree is preserved verbatim so the screen looks identical to fe0.
* Behavior is simplified to a working email+password login against the shared
* `useAuth().login` kernel (see the porting notes in the implementer report):
* - `login()` resolves to the user (and throws on failure) instead of fe0's
* `{ success, activeRole, error }` shape, so success navigates to `/` and
* failures surface via `detailFromApiError`.
* - fe0's role-based post-login routing (`resolvePostLoginPath`) is dropped —
* the app shells route everyone to `/`.
* - The forgot-password / home links are real `<Link>`s to the `/forgot-password`
* and `/` routes the app shells now mount. The register link is app-specific:
* each shell passes `registerPath`/`registerLabel` (frontend_user → `/register`,
* frontend_admin → `/admin/register`); when omitted the button stays disabled.
*/
export interface LoginRegisterCardProps {
/** Route the "Đăng ký" button links to. When omitted the button is rendered disabled. */
registerPath?: string;
/** Label for the register button (defaults to "Đăng ký - Quản trị" to match fe0). */
registerLabel?: string;
}
export function LoginRegisterCard({ registerPath, registerLabel = 'Đăng ký - Quản trị' }: LoginRegisterCardProps = {}) {
const navigate = useNavigate();
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const location = useLocation();
const fromPathname = (location.state as { from?: { pathname: string } })?.from?.pathname;
const loginEmailTrimmed = loginEmail.trim();
const loginPasswordFilled = loginPassword.trim().length > 0;
const canSubmitLogin = loginEmailTrimmed.length > 0 && loginPasswordFilled;
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const email = loginEmailTrimmed.toLowerCase();
if (!loginEmailTrimmed) {
setError('Vui lòng nhập email.');
return;
}
if (!loginPasswordFilled) {
setError('Vui lòng nhập mật khẩu.');
return;
}
if (!validateInstitutionalEmail(email)) {
setError('Vui lòng dùng email UMP hoặc UMC (đuôi @ump.edu.vn / @umc.edu.vn).');
return;
}
await login(email, loginPassword);
const target = fromPathname && fromPathname !== '/login' ? fromPathname : '/';
navigate(target, { replace: true });
} catch (err) {
setError(detailFromApiError(err, 'Đăng nhập thất bại.'));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-foreground">Tài khoản</h1>
<p className="text-muted-foreground mt-2">Đăng nhập với email UMP hoặc UMC</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LogIn className="h-5 w-5" />
Truy cập hệ thống
</CardTitle>
<CardDescription>
<p className="text-xs text-muted-foreground">{INSTITUTIONAL_EMAIL_HINT}</p>
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="login-email">Tài Khoản</Label>
<Input
id="login-email"
type="email"
autoComplete="username"
placeholder="Email UMP / UMC"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Mật khẩu</Label>
<Input
id="login-password"
type="password"
autoComplete="current-password"
placeholder="password"
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
required
/>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col items-center gap-3">
<Button type="submit" disabled={isLoading || !canSubmitLogin}>
{isLoading ? 'Đang xử lý...' : 'Đăng nhập'}
</Button>
<Link
to="/forgot-password"
className="text-sm text-muted-foreground hover:text-foreground underline-offset-4 hover:underline"
>
Quên mật khẩu?
</Link>
</div>
</form>
<Separator className="my-6" />
<div className="flex gap-2 text-center justify-center text-sm">
<span className="text-muted-foreground">Chưa tài khoản?</span>
<div className="flex flex-col sm:flex-row gap-2 justify-center">
{registerPath ? (
<Button variant="outline" asChild>
<Link to={registerPath}>{registerLabel}</Link>
</Button>
) : (
<Button variant="outline" disabled>
{registerLabel}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<Separator className="my-6" />
<div className="text-center space-y-2">
<Link to="/" className="text-sm text-muted-foreground hover:text-foreground block">
Quay về trang chủ
</Link>
</div>
</div>
</div>
);
}
+732
View File
@@ -0,0 +1,732 @@
import { useState, useEffect, useRef } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '../components/ui/card';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import {
AlertCircle,
ArrowLeft,
CheckCircle2,
KeyRound,
Loader2,
Mail,
} from 'lucide-react';
import { register, verifyOtp, resendOtp, AuthRegisterError } from './authOperations';
import { detailFromApiError } from '../api/client';
import {
StaffProfileFormFields,
getSubmitReadinessIssues,
normalizeEmployeeIdInput,
type StaffFieldState,
} from '../profile';
import { toast } from '../components/ui/sonner';
import { validateInstitutionalEmail, INSTITUTIONAL_EMAIL_HINT } from './institutionalEmail';
import { registrationPasswordIssue } from './registration/passwordPolicy';
import {
REGISTRATION_OTP_LENGTH,
REGISTRATION_OTP_RESEND_COOLDOWN_SECONDS,
REGISTRATION_OTP_VALID_SECONDS,
} from './registration/constants';
import { OtpSixInputs, emptyOtpDigits } from './registration/OtpSixInputs';
/**
* Registration + OTP verification — visual port of
* fe0/src/auth/registration/RegistrationWithOtp.tsx. The full markup/className tree
* (form / otp / success steps) is preserved verbatim.
*
* Data calls are rewired from fe0's `authService` (which returned
* `{ success, error, rejectionReasons, httpStatus, ... }` discriminated unions) to the
* shared `authOperations` (`register` / `verifyOtp` / `resendOtp`), which resolve on
* success and THROW on error:
* - `register` throws {@link AuthRegisterError} (carrying `httpStatus` + `reasons`),
* so the server-refusal Alert is rebuilt from the caught error.
* - `verifyOtp` / `resendOtp` throw a normalized `ApiError`; failures surface via
* `detailFromApiError`.
* fe0's dashboard-draft session hook (`useApplicantRegistrationState`) is unrelated to
* this screen and is intentionally not pulled in.
*/
function formatOtpCountdown(totalSeconds: number): string {
const m = Math.floor(Math.max(0, totalSeconds) / 60);
const s = Math.max(0, totalSeconds) % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function registrationRejectionHeading(httpStatus: number): string {
switch (httpStatus) {
case 400:
return 'Thông tin chưa đạt yêu cầu';
case 409:
return 'Dữ liệu trùng hoặc đã tồn tại';
case 422:
return 'Biểu mẫu cần chỉnh sửa';
case 503:
return 'Hệ thống tạm không nhận đăng ký';
default:
return 'Không thể hoàn tất đăng ký';
}
}
export type RegistrationPortalVariant = 'applicant' | 'admin';
export interface RegistrationWithOtpProps {
variant: RegistrationPortalVariant;
/** Route for primary login link (default "/login"). */
loginPath?: string;
}
type Step = 'form' | 'otp' | 'success';
interface FormState {
fullName: string;
email: string;
password: string;
passwordConfirm: string;
}
export function RegistrationWithOtp({
variant,
loginPath = '/login',
}: RegistrationWithOtpProps) {
const navigate = useNavigate();
const [step, setStep] = useState<Step>('form');
const [form, setForm] = useState<FormState>({
fullName: '',
email: '',
password: '',
passwordConfirm: '',
});
const [regStaff, setRegStaff] = useState<StaffFieldState>({
employeeId: null,
academicTitleCode: null,
academicTitleOther: null,
unitId: null,
unitNameFreetext: null,
jobTitle: null,
});
const [otp, setOtp] = useState<string[]>(() => emptyOtpDigits());
const otpInputRefs = useRef<Array<HTMLInputElement | null>>([]);
const otpValiditySecondsRef = useRef(REGISTRATION_OTP_VALID_SECONDS);
const [resendCountdown, setResendCountdown] = useState(0);
const [otpTtlRemaining, setOtpTtlRemaining] = useState(REGISTRATION_OTP_VALID_SECONDS);
const [error, setError] = useState('');
/** Server refusal with parsed reasons (HTTP 4xx/5xx from register). */
const [registerRejection, setRegisterRejection] = useState<{
title: string;
reasons: string[];
} | null>(null);
const [info, setInfo] = useState('');
const [loading, setLoading] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState<string | null>(null);
const [otpDeliveryChannel, setOtpDeliveryChannel] = useState<
'smtp' | 'log_only' | 'none' | 'smtp_failed' | null
>(null);
useEffect(() => {
if (resendCountdown <= 0) return;
const id = window.setTimeout(() => setResendCountdown((c) => c - 1), 1000);
return () => window.clearTimeout(id);
}, [resendCountdown]);
useEffect(() => {
if (step !== 'otp' || otpTtlRemaining <= 0) return;
const id = window.setTimeout(() => setOtpTtlRemaining((t) => t - 1), 1000);
return () => window.clearTimeout(id);
}, [step, otpTtlRemaining]);
useEffect(() => {
if (step === 'otp') otpInputRefs.current[0]?.focus();
}, [step]);
const registerStaffIssues = getSubmitReadinessIssues(regStaff);
const portalTitle =
variant === 'admin' ? 'Đăng ký tài khoản (quản trị / chính sách email)' : 'Đăng ký tài khoản';
const portalDescription =
variant === 'admin'
? 'Vai trò admin chỉ gán khi email thuộc danh sách chính sách máy chủ. Trường hợp còn lại là Người nộp đơn.'
: `Sử dụng email trường. ${INSTITUTIONAL_EMAIL_HINT}`;
const updateField =
(key: keyof FormState) => (e: React.ChangeEvent<HTMLInputElement>) => {
setForm((prev) => ({ ...prev, [key]: e.target.value }));
};
const handleSubmitForm = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setRegisterRejection(null);
setInfo('');
const fullName = form.fullName.trim();
const email = form.email.trim().toLowerCase();
if (fullName.length < 2) {
setError('Nhập họ và tên đầy đủ.');
return;
}
if (!validateInstitutionalEmail(email)) {
setError('Email phải là địa chỉ @ump.edu.vn hoặc @umc.edu.vn hợp lệ.');
return;
}
const pwdIssue = registrationPasswordIssue(form.password);
if (pwdIssue) {
setError(pwdIssue);
return;
}
if (form.password !== form.passwordConfirm) {
setError('Mật khẩu xác nhận không khớp.');
return;
}
if (registerStaffIssues.length > 0) {
setError(registerStaffIssues[0]);
return;
}
const employeeIdNorm = normalizeEmployeeIdInput(regStaff.employeeId ?? undefined);
if (!employeeIdNorm || !regStaff.academicTitleCode) {
setError('Vui lòng điền đủ hồ sơ nhân sự.');
return;
}
setLoading(true);
try {
const res = await register({
fullName,
email,
password: form.password,
passwordConfirm: form.passwordConfirm,
employeeId: employeeIdNorm,
academicTitleCode: regStaff.academicTitleCode,
academicTitleOther: regStaff.academicTitleOther ?? undefined,
unitId: regStaff.unitId ?? undefined,
unitNameFreetext: regStaff.unitNameFreetext?.trim() || undefined,
jobTitle: (regStaff.jobTitle ?? '').trim(),
});
const windowSec =
typeof res.otpTtlSeconds === 'number' && res.otpTtlSeconds > 0
? Math.floor(res.otpTtlSeconds)
: REGISTRATION_OTP_VALID_SECONDS;
otpValiditySecondsRef.current = windowSec;
setRegisteredEmail(email);
setStep('otp');
setOtpTtlRemaining(windowSec);
setResendCountdown(REGISTRATION_OTP_RESEND_COOLDOWN_SECONDS);
const minutesLabel = Math.max(1, Math.ceil(windowSec / 60));
const delivery = res.otpDeliveryChannel;
setOtpDeliveryChannel(delivery ?? null);
if (delivery === 'none') {
toast.warning('Chưa gửi email OTP', {
description:
'Máy chủ chưa cấu hình SMTP (SMTP_HOST). Cấu hình gửi thư trên be0, hoặc bật AUTH_MAIL_LOG_ONLY=1 và xem nhật ký container để lấy mã.',
});
setInfo(
res.message ||
'Tài khoản đã tạo nhưng email OTP chưa được gửi — cần cấu hình SMTP hoặc xem nhật ký be0.',
);
} else if (delivery === 'smtp_failed') {
toast.warning('Không gửi được email OTP', {
description:
'SMTP đã khai báo nhưng gửi thất bại — xem log be0 («register: OTP mail failed») để biết lỗi (xác thực, SMTP AUTH, firewall).',
});
setInfo(
res.message ||
'Đã ghi nhận đăng ký nhưng máy chủ không gửi được email OTP. Kiểm tra cấu hình SMTP và log be0.',
);
} else if (delivery === 'log_only') {
toast.info('Mã OTP chỉ có trong nhật ký máy chủ', {
description:
'AUTH_MAIL_LOG_ONLY đang bật. Trong log be0, tìm dòng «AUTH_MAIL_LOG_ONLY: registration OTP».',
});
setInfo(
res.message ||
`Mã OTP được ghi trong nhật ký dịch vụ be0 (không gửi email). Email đăng ký: ${email}.`,
);
} else {
toast.success('Đã gửi mã OTP đến email đăng ký', {
description: `Nhập đúng 6 chữ số trong ${minutesLabel} phút. Kiểm tra hộp thư và thư rác.`,
});
setInfo(res.message || `Mã OTP đã được gửi đến ${email}.`);
}
setForm((prev) => ({ ...prev, password: '', passwordConfirm: '' }));
} catch (err) {
if (err instanceof AuthRegisterError) {
setRegisterRejection({
title: registrationRejectionHeading(err.httpStatus),
reasons: err.reasons.length > 0 ? err.reasons : ['Đăng ký thất bại. Vui lòng thử lại.'],
});
return;
}
setError('Lỗi kết nối. Vui lòng kiểm tra mạng và thử lại.');
} finally {
setLoading(false);
}
};
const handleOtpChange = (index: number, value: string) => {
const digit = value.replace(/\D/g, '').slice(-1);
setOtp((prev) => {
const next = [...prev];
next[index] = digit;
return next;
});
if (digit && index < REGISTRATION_OTP_LENGTH - 1) {
otpInputRefs.current[index + 1]?.focus();
}
};
const handleOtpKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLInputElement>,
) => {
if (e.key === 'Backspace' && !otp[index] && index > 0) {
otpInputRefs.current[index - 1]?.focus();
} else if (e.key === 'ArrowLeft' && index > 0) {
e.preventDefault();
otpInputRefs.current[index - 1]?.focus();
} else if (e.key === 'ArrowRight' && index < REGISTRATION_OTP_LENGTH - 1) {
e.preventDefault();
otpInputRefs.current[index + 1]?.focus();
}
};
const handleOtpPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
const pasted = e.clipboardData
.getData('text')
.replace(/\D/g, '')
.slice(0, REGISTRATION_OTP_LENGTH);
if (!pasted) return;
e.preventDefault();
const next = emptyOtpDigits();
for (let i = 0; i < pasted.length; i++) next[i] = pasted[i];
setOtp(next);
const focusIndex = Math.min(pasted.length, REGISTRATION_OTP_LENGTH - 1);
otpInputRefs.current[focusIndex]?.focus();
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setInfo('');
const code = otp.join('');
if (otpTtlRemaining <= 0) {
setError('Mã OTP đã hết hạn. Nhấn «Gửi lại mã OTP» để nhận mã mới.');
return;
}
if (code.length !== REGISTRATION_OTP_LENGTH) {
setError(`Vui lòng nhập đủ ${REGISTRATION_OTP_LENGTH} chữ số.`);
return;
}
if (!registeredEmail) {
setError('Phiên xác thực đã hết hạn. Vui lòng đăng ký lại.');
setStep('form');
return;
}
setLoading(true);
try {
await verifyOtp({ email: registeredEmail, otp: code });
setStep('success');
} catch (err) {
setError(detailFromApiError(err, 'Mã OTP không đúng hoặc đã hết hạn.'));
setOtp(emptyOtpDigits());
otpInputRefs.current[0]?.focus();
} finally {
setLoading(false);
}
};
const handleResendOtp = async () => {
if (resendCountdown > 0 || !registeredEmail || loading) return;
setError('');
setInfo('');
setLoading(true);
try {
const message = await resendOtp(registeredEmail);
setInfo(message || 'Mã OTP mới đã được gửi.');
setResendCountdown(REGISTRATION_OTP_RESEND_COOLDOWN_SECONDS);
setOtpTtlRemaining(otpValiditySecondsRef.current);
toast.success('Đã gửi lại mã OTP', {
description: `Hiệu lực khoảng ${Math.max(1, Math.ceil(otpValiditySecondsRef.current / 60))} phút.`,
});
setOtp(emptyOtpDigits());
otpInputRefs.current[0]?.focus();
} catch (err) {
setError(detailFromApiError(err, 'Không thể gửi lại mã. Vui lòng thử lại sau.'));
} finally {
setLoading(false);
}
};
const handleBackToForm = () => {
setStep('form');
setError('');
setRegisterRejection(null);
setInfo('');
setOtpDeliveryChannel(null);
setOtp(emptyOtpDigits());
};
if (step === 'success') {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-background to-muted/40 p-4">
<Card className="w-full max-w-xl backdrop-blur-sm">
<CardHeader className="text-center space-y-3">
<div className="mx-auto h-12 w-12 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle2 className="h-7 w-7 text-green-600" />
</div>
<CardTitle>Đăng thành công</CardTitle>
<CardDescription>
Tài khoản <strong>{registeredEmail}</strong> đã đưc xác minh kích hoạt.
</CardDescription>
</CardHeader>
<CardFooter>
<Button className="w-full" onClick={() => navigate(loginPath)}>
Đăng nhập ngay
</Button>
</CardFooter>
</Card>
</div>
);
}
if (step === 'otp') {
const codeComplete = otp.every((d) => d !== '');
return (
<div className="min-h-screen flex items-center justify-center bg-muted/20 p-4">
<Card className="w-full max-w-xl">
<CardHeader className="space-y-3">
<div className="mx-auto h-12 w-12 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-center">Nhập xác minh</CardTitle>
<CardDescription className="text-center">
{otpDeliveryChannel === 'none' ? (
<>
Đăng đã ghi nhận. <strong>Email OTP chưa đưc gửi</strong> máy chủ chưa cấu hình
SMTP.
</>
) : otpDeliveryChannel === 'smtp_failed' ? (
<>
Đăng đã ghi nhận. <strong>Gửi email OTP thất bại</strong> SMTP thể sai cấu hình, bị
chặn mạng, hoặc nhà cung cấp từ chối đăng nhập (xem nhật be0).
</>
) : otpDeliveryChannel === 'log_only' ? (
<>
OTP đưc ghi trong <strong>nhật dịch vụ be0</strong> (chế đ AUTH_MAIL_LOG_ONLY), không
gửi qua hộp thư.
</>
) : (
<>
{REGISTRATION_OTP_LENGTH} chữ số đã gửi đến
<br />
<strong>{registeredEmail}</strong>
</>
)}
</CardDescription>
<p className="text-center text-sm text-muted-foreground">
{otpTtlRemaining > 0 ? (
<>
Hiệu lực còn <span className="font-mono font-medium text-foreground">{formatOtpCountdown(otpTtlRemaining)}</span>
</>
) : (
<span className="text-destructive"> đã hết hạn dùng «Gửi lại OTP» bên dưới.</span>
)}
</p>
</CardHeader>
<form onSubmit={handleVerifyOtp}>
<CardContent className="space-y-4">
{otpDeliveryChannel === 'none' && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Không nhận đưc email?</AlertTitle>
<AlertDescription className="text-sm space-y-2">
<p>
Trên dịch vụ <strong>be0</strong>, đt biến môi trường SMTP ( dụ{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">SMTP_HOST</code>,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">SMTP_PORT</code>, tài khoản nếu
cần), rồi đăng lại hoặc dùng «Gửi lại OTP» sau khi đã cấu hình.
</p>
<p>
Trên môi trường dev, thể đt{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">AUTH_MAIL_LOG_ONLY=1</code>
OTP sẽ in trong log be0 (tìm «registration OTP»).
</p>
</AlertDescription>
</Alert>
)}
{otpDeliveryChannel === 'smtp_failed' && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>SMTP không gửi đưc thư?</AlertTitle>
<AlertDescription className="text-sm space-y-2">
<p>
Lỗi <strong>535 / Authentication unsuccessful</strong> nghĩa <strong>sai tài khoản SMTP</strong>{' '}
(không phải lỗi giao diện). Kiểm tra <code className="rounded bg-muted px-1 py-0.5 text-xs">
SMTP_USER
</code>{' '}
{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">SMTP_PASSWORD</code> trong{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">.env</code>, rồi khởi đng lại container{' '}
<strong>be0</strong>.
</p>
<p>
Với <strong>Microsoft 365 / Outlook</strong> (<code className="rounded bg-muted px-1 py-0.5 text-xs">
smtp.office365.com
</code>
): dùng đúng email làm <code className="rounded bg-muted px-1 py-0.5 text-xs">SMTP_USER</code>;
nếu tài khoản MFA thường cần <strong>mật khẩu ng dụng</strong> (không phải mật khẩu đăng nhập web);
trong trung tâm quản trị cần bật <strong>Authenticated SMTP</strong> cho hộp thư đó.
</p>
<p>
Trong máy chủ chạy be0, xem log cụm{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">register: OTP mail failed</code> hoặc{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">SMTP login failed</code>.
</p>
<p>
Sau khi sửa <code className="rounded bg-muted px-1 py-0.5 text-xs">.env</code>, chạy lại{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">docker compose up -d be0</code>{' '}
(đ container nạp biến mới), rồi «Gửi lại OTP».
</p>
<p>
Dev nhanh:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">AUTH_MAIL_LOG_ONLY=1</code>
hiện trong log be0 (<code className="rounded bg-muted px-1 py-0.5 text-xs">
AUTH_MAIL_LOG_ONLY: registration OTP
</code>
).
</p>
</AlertDescription>
</Alert>
)}
{otpDeliveryChannel === 'log_only' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Xem OTP trong log</AlertTitle>
<AlertDescription className="text-sm">
dụ: <code className="rounded bg-muted px-1 py-0.5 text-xs">docker logs be0</code> dòng{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
AUTH_MAIL_LOG_ONLY: registration OTP for
</code>
</AlertDescription>
</Alert>
)}
{info && (
<Alert>
<Mail className="h-4 w-4" />
<AlertDescription>{info}</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<OtpSixInputs
otp={otp}
disabled={loading}
inputRefs={otpInputRefs}
onDigitChange={handleOtpChange}
onKeyDown={handleOtpKeyDown}
onPaste={handleOtpPaste}
/>
<div className="text-center text-sm text-muted-foreground">
Chưa nhận đưc ?{' '}
{resendCountdown > 0 ? (
<span>Gửi lại sau {resendCountdown}s</span>
) : (
<button
type="button"
onClick={handleResendOtp}
disabled={loading}
className="text-primary hover:underline font-medium disabled:opacity-50"
>
Gửi lại OTP
</button>
)}
</div>
</CardContent>
<CardFooter className="flex flex-col gap-2">
<Button
type="submit"
className="w-full"
disabled={loading || !codeComplete || otpTtlRemaining <= 0}
>
{loading && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Xác minh
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={handleBackToForm}
disabled={loading}
>
<ArrowLeft className="h-4 w-4 mr-2" />
Quay lại chỉnh thông tin
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-muted/20 p-4">
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>{portalTitle}</CardTitle>
<CardDescription>{portalDescription}</CardDescription>
</CardHeader>
<form onSubmit={handleSubmitForm}>
<CardContent className="space-y-4">
{registerRejection && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{registerRejection.title}</AlertTitle>
<AlertDescription>
{registerRejection.reasons.length === 1 ? (
<p className="mt-1.5 text-sm">{registerRejection.reasons[0]}</p>
) : (
<ul className="mt-1.5 list-disc pl-5 space-y-1 text-sm">
{registerRejection.reasons.map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
)}
</AlertDescription>
</Alert>
)}
{error && !registerRejection && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="reg-fullName">Họ tên</Label>
<Input
id="reg-fullName"
type="text"
value={form.fullName}
onChange={updateField('fullName')}
disabled={loading}
required
minLength={2}
autoComplete="name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-email">Email UMP / UMC</Label>
<Input
id="reg-email"
type="email"
value={form.email}
onChange={updateField('email')}
disabled={loading}
placeholder="ten@ump.edu.vn"
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-password">Mật khẩu</Label>
<Input
id="reg-password"
type="password"
value={form.password}
onChange={updateField('password')}
disabled={loading}
required
autoComplete="new-password"
/>
</div>
<div className="space-y-2">
<Label htmlFor="reg-passwordConfirm">Xác nhận mật khẩu</Label>
<Input
id="reg-passwordConfirm"
type="password"
value={form.passwordConfirm}
onChange={updateField('passwordConfirm')}
disabled={loading}
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Tối thiểu 6 tự; gồm chữ hoa, chữ thường, số tự đc biệt.
</p>
</div>
<div className="rounded-lg border border-border/80 bg-muted/20 p-4 space-y-2">
<p className="text-sm font-medium">Hồ nhân sự (bắt buộc)</p>
<p className="text-xs text-muted-foreground">
Đơn vị: chọn từ danh mục ĐHYD hoặc nhập tên tự do. Nếu học hàm/học vị «Khác», ghi
nội dung.
</p>
<StaffProfileFormFields
value={regStaff}
onChange={setRegStaff}
idPrefix={`registration-${variant}`}
/>
</div>
</CardContent>
<CardFooter className="flex flex-col gap-3">
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Gửi OTP
</Button>
<p className="text-sm text-center text-muted-foreground">
Đã tài khoản?{' '}
<Link to={loginPath} className="text-primary hover:underline font-medium">
Đăng nhập
</Link>
</p>
{variant === 'admin' ? (
<p className="text-sm text-center text-muted-foreground">
Đăng người nộp đơn?{' '}
<Link to="/register" className="text-primary hover:underline font-medium">
Trang đăng chung
</Link>
</p>
) : (
<p className="text-sm text-center text-muted-foreground">
Trang thông tin quản trị?{' '}
<Link to="/admin/register" className="text-primary hover:underline font-medium">
Đăng (ghi chú vai trò)
</Link>
</p>
)}
</CardFooter>
</form>
</Card>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { resetPassword } from './authOperations';
import { detailFromApiError } from '../api/client';
import { clearAccessToken } from '../utils/token';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Alert, AlertDescription } from '../components/ui/alert';
import { AlertCircle, KeyRound } from 'lucide-react';
import { Separator } from '../components/ui/separator';
import { toast } from '../components/ui/sonner';
/**
* Reset-password page — visual port of fe0/src/pages/ResetPasswordPage.tsx.
*
* Markup/className tree preserved verbatim; the `token` is read from the URL query
* exactly as fe0 does. The data call is rewired from fe0's
* `authService.confirmPasswordReset` (returning `{ success, message, error }`) to the
* shared `resetPassword(payload)` which resolves with the server message and throws on
* error — failures surface via `detailFromApiError`.
*/
export function ResetPasswordPage() {
const [searchParams] = useSearchParams();
const tokenFromUrl = useMemo(() => (searchParams.get('token') ?? '').trim(), [searchParams]);
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const t = tokenFromUrl;
if (!t) {
setError('Thiếu mã trong liên kết. Hãy mở lại đường dẫn từ email.');
return;
}
if (password !== passwordConfirm) {
setError('Mật khẩu xác nhận không khớp.');
return;
}
setIsLoading(true);
try {
const message = await resetPassword({
token: t,
newPassword: password,
newPasswordConfirm: passwordConfirm,
});
clearAccessToken();
toast.success(message || 'Đã đặt lại mật khẩu. Vui lòng đăng nhập lại.');
navigate('/login', { replace: true });
} catch (err) {
setError(detailFromApiError(err, 'Không thể đặt lại mật khẩu.'));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="w-full max-w-xl">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-foreground">Đt lại mật khẩu</h1>
<p className="text-muted-foreground mt-2">Nhập mật khẩu mới (đ đ phức tạp như khi đăng )</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5" />
Mật khẩu mới
</CardTitle>
<CardDescription>Tối thiểu 6 tự; chữ hoa, chữ thường, số tự đc biệt.</CardDescription>
</CardHeader>
<CardContent>
{!tokenFromUrl && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Liên kết không hợp lệ. Hãy dùng nút trong email hoặc yêu cầu gửi lại.
</AlertDescription>
</Alert>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-pass">Mật khẩu mới</Label>
<Input
id="new-pass"
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-pass2">Xác nhận mật khẩu</Label>
<Input
id="new-pass2"
type="password"
autoComplete="new-password"
value={passwordConfirm}
onChange={(e) => setPasswordConfirm(e.target.value)}
required
minLength={6}
/>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-center">
<Button type="submit" disabled={isLoading || !tokenFromUrl}>
{isLoading ? 'Đang lưu...' : 'Cập nhật mật khẩu'}
</Button>
</div>
</form>
</CardContent>
</Card>
<Separator className="my-6" />
<div className="text-center space-y-2">
<Link to="/login" className="text-sm text-muted-foreground hover:text-foreground block">
Đăng nhập
</Link>
</div>
</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
/**
* Shared applicant/admin auth operations — registration, OTP, password reset, and the
* reference catalogs — implemented against the same-origin backend via the shared
* {@link apiClient}.
*
* Ported (and slimmed) from fe0/src/lib/auth-service.ts. Differences from fe0:
* - Uses the shared `apiClient` (Bearer + auto-refresh interceptors) instead of bespoke
* `fetch` calls, so these compose with the rest of the kernel.
* - Throw-on-error rather than fe0's `{ success, error }` discriminated unions. Callers
* catch and render the message via `detailFromApiError`. `register` throws a richer
* {@link AuthRegisterError} so the form can show the HTTP-status heading + reason list.
*/
import { apiClient, type ApiError } from '../api/client';
import type { AcademicTitleOption, UnitOption } from '../profile/types';
export type OtpDeliveryChannel = 'smtp' | 'log_only' | 'none' | 'smtp_failed';
export interface RegisterPayload {
fullName: string;
email: string;
password: string;
passwordConfirm: string;
employeeId: string;
academicTitleCode: string;
academicTitleOther?: string | null;
unitId?: string | null;
unitNameFreetext?: string | null;
jobTitle: string;
}
/** Successful `register` response when the backend requires OTP email verification. */
export interface RegisterOtpResult {
message: string;
email: string;
emailVerificationRequired: true;
otpTtlSeconds?: number;
otpDeliveryChannel?: OtpDeliveryChannel;
}
/**
* Thrown by {@link register} on an HTTP error. Carries the status (for the UI heading)
* and the parsed reason list from FastAPI's `{ detail }` (string or validation array).
*/
export class AuthRegisterError extends Error {
readonly httpStatus: number;
readonly reasons: string[];
constructor(message: string, httpStatus: number, reasons: string[]) {
super(message);
this.name = 'AuthRegisterError';
this.httpStatus = httpStatus;
this.reasons = reasons.length > 0 ? reasons : [message];
}
}
function isApiError(err: unknown): err is ApiError {
return Boolean(err) && typeof err === 'object' && typeof (err as ApiError).status === 'number';
}
/** FastAPI detail → deduped reason list (handles both the string and `[{ msg }]` shapes). */
function reasonsFromDetail(detail: unknown, fallback: string): string[] {
if (typeof detail === 'string') {
const s = detail.trim();
return s ? [s] : [fallback];
}
if (Array.isArray(detail)) {
const reasons = detail
.map((e) => {
if (typeof e === 'string') return e.trim();
if (e && typeof e === 'object' && 'msg' in e) {
const m = (e as { msg?: unknown }).msg;
return typeof m === 'string' ? m.trim() : String(m);
}
return '';
})
.filter((s) => s.length > 0);
const dedup = [...new Set(reasons)];
return dedup.length ? dedup : [fallback];
}
return [fallback];
}
const AUTH = '/api/v1/auth';
/**
* POST /api/v1/auth/register. Resolves with the OTP-verification result on success;
* throws {@link AuthRegisterError} on an HTTP error. The server derives the role from the
* email policy and ignores any client-supplied role.
*/
export async function register(payload: RegisterPayload): Promise<RegisterOtpResult> {
try {
const raw = await apiClient.post<Record<string, unknown>>(`${AUTH}/register`, {
fullName: payload.fullName,
email: payload.email,
password: payload.password,
passwordConfirm: payload.passwordConfirm,
employeeId: payload.employeeId,
academicTitleCode: payload.academicTitleCode,
academicTitleOther: payload.academicTitleOther ?? undefined,
unitId: payload.unitId ?? undefined,
unitNameFreetext: payload.unitNameFreetext ?? undefined,
jobTitle: payload.jobTitle,
});
const otpTtlRaw = raw.otpTtlSeconds;
const otpTtlSeconds =
typeof otpTtlRaw === 'number' && Number.isFinite(otpTtlRaw) && otpTtlRaw > 0
? Math.floor(otpTtlRaw)
: undefined;
const ch = raw.otpDeliveryChannel;
const otpDeliveryChannel =
ch === 'smtp' || ch === 'log_only' || ch === 'none' || ch === 'smtp_failed' ? ch : undefined;
return {
message: typeof raw.message === 'string' ? raw.message : '',
email: typeof raw.email === 'string' ? raw.email : payload.email.trim().toLowerCase(),
emailVerificationRequired: true,
otpTtlSeconds,
otpDeliveryChannel,
};
} catch (err) {
if (isApiError(err)) {
const detail = (err.data as { detail?: unknown } | undefined)?.detail;
const fallback = err.message?.trim() || 'Đăng ký thất bại. Vui lòng thử lại.';
const reasons = reasonsFromDetail(detail, fallback);
throw new AuthRegisterError(reasons[0], err.status, reasons);
}
throw err;
}
}
/** POST /api/v1/auth/verify-otp → resolves with the server message; throws on error. */
export async function verifyOtp(payload: { email: string; otp: string }): Promise<string> {
const raw = await apiClient.post<{ message?: string }>(`${AUTH}/verify-otp`, {
email: payload.email.trim().toLowerCase(),
otp: payload.otp.trim(),
});
return typeof raw.message === 'string' ? raw.message : '';
}
/** POST /api/v1/auth/resend-otp → resolves with the server message; throws on error. */
export async function resendOtp(email: string): Promise<string> {
const raw = await apiClient.post<{ message?: string }>(`${AUTH}/resend-otp`, {
email: email.trim().toLowerCase(),
});
return typeof raw.message === 'string' ? raw.message : '';
}
/** POST /api/v1/auth/forgot-password → resolves with the server message; throws on error. */
export async function forgotPassword(email: string): Promise<string> {
const raw = await apiClient.post<{ message?: string }>(`${AUTH}/forgot-password`, {
email: email.trim().toLowerCase(),
});
return typeof raw.message === 'string' ? raw.message : '';
}
/** POST /api/v1/auth/reset-password → resolves with the server message; throws on error. */
export async function resetPassword(payload: {
token: string;
newPassword: string;
newPasswordConfirm: string;
}): Promise<string> {
const raw = await apiClient.post<{ message?: string }>(`${AUTH}/reset-password`, {
token: payload.token,
newPassword: payload.newPassword,
newPasswordConfirm: payload.newPasswordConfirm,
});
return typeof raw.message === 'string' ? raw.message : '';
}
/** GET /api/v1/auth/reference/academic-titles. */
export async function getAcademicTitles(): Promise<AcademicTitleOption[]> {
const raw = await apiClient.get<AcademicTitleOption[]>(`${AUTH}/reference/academic-titles`);
return Array.isArray(raw) ? raw : [];
}
/** GET /api/v1/auth/reference/units. */
export async function getUnits(): Promise<UnitOption[]> {
const raw = await apiClient.get<UnitOption[]>(`${AUTH}/reference/units`);
return Array.isArray(raw) ? raw : [];
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Client-side UX validation only; server is authoritative (UMP + UMC domains).
* (Ported verbatim from fe0/src/auth/institutionalEmail.ts.)
*/
export const INSTITUTIONAL_EMAIL_RE = /^[a-zA-Z0-9._%+-]+@(ump|umc)\.edu\.vn$/i;
export const INSTITUTIONAL_EMAIL_HINT =
'Vui lòng sử dụng email có đuôi @ump.edu.vn hoặc @umc.edu.vn';
export function validateInstitutionalEmail(email: string): boolean {
return INSTITUTIONAL_EMAIL_RE.test(email.trim().toLowerCase());
}
@@ -0,0 +1,53 @@
import { Input } from '../../components/ui/input';
import { REGISTRATION_OTP_LENGTH } from './constants';
/**
* Six single-digit OTP inputs — ported verbatim from
* fe0/src/auth/registration/OtpSixInputs.tsx (only the two `@/` imports were
* rewritten to shared-relative paths). Markup/classNames preserved.
*/
export interface OtpSixInputsProps {
otp: string[];
disabled?: boolean;
inputRefs: React.MutableRefObject<Array<HTMLInputElement | null>>;
onDigitChange: (index: number, value: string) => void;
onKeyDown: (index: number, e: React.KeyboardEvent<HTMLInputElement>) => void;
onPaste: (e: React.ClipboardEvent<HTMLDivElement>) => void;
}
export function OtpSixInputs({
otp,
disabled,
inputRefs,
onDigitChange,
onKeyDown,
onPaste,
}: OtpSixInputsProps) {
return (
<div className="flex gap-2 justify-center" onPaste={onPaste}>
{otp.map((digit, i) => (
<Input
key={i}
ref={(el) => {
inputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
autoComplete={i === 0 ? 'one-time-code' : 'off'}
maxLength={1}
value={digit}
onChange={(e) => onDigitChange(i, e.target.value)}
onKeyDown={(e) => onKeyDown(i, e)}
onFocus={(e) => e.currentTarget.select()}
disabled={disabled}
className="w-11 h-12 text-center text-lg font-semibold"
aria-label={`Chữ số thứ ${i + 1}`}
/>
))}
</div>
);
}
export function emptyOtpDigits(): string[] {
return Array(REGISTRATION_OTP_LENGTH).fill('');
}
@@ -0,0 +1,8 @@
/** Six-digit OTP sent after POST /auth/register (must match backend). */
export const REGISTRATION_OTP_LENGTH = 6;
/** OTP validity countdown on the registration screen (backend default REGISTER_OTP_TTL_MINUTES=1). */
export const REGISTRATION_OTP_VALID_SECONDS = 60;
/** Client-side cooldown before allowing resend (backend rate-limits independently). */
export const REGISTRATION_OTP_RESEND_COOLDOWN_SECONDS = 60;
@@ -0,0 +1,22 @@
/**
* Mirrors backend `_assert_password_policy` (auth_api.py) for immediate UX feedback.
* (Ported verbatim from fe0/src/auth/registration/passwordPolicy.ts.)
*/
export function registrationPasswordIssue(password: string): string | null {
if (password.length < 6) {
return 'Mật khẩu tối thiểu 6 ký tự.';
}
if (!/[a-z]/.test(password)) {
return 'Mật khẩu phải có ít nhất một chữ cái thường.';
}
if (!/[A-Z]/.test(password)) {
return 'Mật khẩu phải có ít nhất một chữ cái hoa.';
}
if (!/\d/.test(password)) {
return 'Mật khẩu phải có ít nhất một chữ số.';
}
if (!/[^A-Za-z0-9]/.test(password)) {
return 'Mật khẩu phải có ít nhất một ký tự đặc biệt (không chỉ chữ và số).';
}
return null;
}
+111
View File
@@ -0,0 +1,111 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "../../lib/utils";
import { buttonVariants } from "./button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "../../lib/utils";
import { buttonVariants } from "./button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+132
View File
@@ -0,0 +1,132 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "../../lib/utils";
import { Dialog, DialogContent } from "./dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+95
View File
@@ -0,0 +1,95 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+179
View File
@@ -0,0 +1,179 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "../../lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "../../lib/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { Circle } from "lucide-react";
import { cn } from "../../lib/utils";
const RadioGroup = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => {
return <RadioGroupPrimitive.Root className={cn("grid gap-2", className)} {...props} ref={ref} />;
});
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
const RadioGroupItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => {
return (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
});
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
export { RadioGroup, RadioGroupItem };
+37
View File
@@ -0,0 +1,37 @@
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "../../lib/utils";
const ResizablePanelGroup = ({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+38
View File
@@ -0,0 +1,38 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn("relative overflow-hidden", className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+143
View File
@@ -0,0 +1,143 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "../../lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "../../lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn("shrink-0 bg-border", orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+107
View File
@@ -0,0 +1,107 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title ref={ref} className={cn("text-lg font-semibold text-foreground", className)} {...props} />
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetOverlay,
SheetPortal,
SheetTitle,
SheetTrigger,
};
+7
View File
@@ -0,0 +1,7 @@
import { cn } from "../../lib/utils";
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("animate-pulse rounded-md bg-muted", className)} {...props} />;
}
export { Skeleton };
+32
View File
@@ -0,0 +1,32 @@
import { Toaster as Sonner, toast } from "sonner";
/**
* Toast host — port of fe0/src/components/ui/sonner.tsx.
*
* Simplified: fe0 read the active theme via `next-themes` (`useTheme`) to drive the
* toaster's light/dark mode. The app shells ship a single (light) theme and have no
* theme provider, so the `next-themes` dependency is dropped and `theme` defaults to
* "system". The toast classNames are preserved verbatim for visual parity.
*/
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="system"
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster, toast };
+41
View File
@@ -0,0 +1,41 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export interface SwitchProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
checked: boolean;
onCheckedChange?: (checked: boolean) => void;
}
/**
* A lightweight, dependency-free toggle switch — the shadcn look without @radix-ui/react-switch.
* Controlled: pass `checked` + `onCheckedChange`.
*/
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
({ className, checked, onCheckedChange, disabled, ...props }, ref) => (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary' : 'bg-input',
className,
)}
{...props}
>
<span
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform',
checked ? 'translate-x-5' : 'translate-x-0',
)}
/>
</button>
),
);
Switch.displayName = 'Switch';
export { Switch };
+72
View File
@@ -0,0 +1,72 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />,
);
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
),
);
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} {...props} />
),
);
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn("border-b transition-colors data-[state=selected]:bg-muted hover:bg-muted/50", className)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className,
)}
{...props}
/>
),
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)} {...props} />
),
);
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
),
);
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "../../lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };
+28
View File
@@ -0,0 +1,28 @@
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "../../lib/utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,517 @@
/**
* `@ump/shared/video-viewer` — a 2×2 viewer for 2D PNG image SEQUENCES.
*
* Q1 Ảnh gốc · Q2 Bản đồ độ sâu · Q3 Phủ phân vùng (original + segmentation overlay
* composited via mixBlendMode:'screen') · Q4 Tái tạo 3D (placeholder). A top toolbar
* adds brightness/contrast (CSS filters). A single frame index — driven by the controls-bar
* "video slider" — indexes all channels at once. Double-click a quad to expand it;
* the other three stack on the right.
*
* Sibling of `VideoQuadViewer`: same grid skeleton (one stable CSS-grid so panes
* never remount on expand) and the same StrictMode-safe object-URL discipline, but
* `<img>` frames + a frame index replace `<video>` + currentTime.
*
* Deliberately VTK-free and dependency-free beyond react: it never imports VTK,
* three.js, or the @ump/shared barrel, so this subpath stays light.
*/
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import type {
ImageSequenceViewerProps,
ImageSequenceSource,
QuadIndex,
ExpandedView,
} from './types';
import { expandedOrder } from './playbackModel';
import {
IMAGE_QUAD_LABELS,
clampFrame,
frameLabel,
naturalSortFiles,
} from './imageSequenceModel';
import { VideoQuad } from './VideoQuad';
// AnnotationOverlay + its types are VTK-free (React + types only), so reusing them
// here keeps the drawing tools DRY without pulling VTK into this light subpath.
import { AnnotationOverlay } from '../viewer/AnnotationOverlay';
import type { Annotation, AnnotationTool } from '../viewer/types';
// Inline class join — keep this package barrel-free (no `cn` from @ump/shared).
function join(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
const FPS_OPTIONS = [5, 10, 15, 30] as const;
const DEFAULT_FPS = 10;
const PLACEHOLDER_TEXT = 'text-xs text-white/50 px-2 text-center';
const ANNO_COLOR = '#22d3ee';
// Annotation tools — mirrors the 3D viewer's palette. 'none' = select (no drawing).
const TOOLS: { tool: AnnotationTool; label: string }[] = [
{ tool: 'none', label: 'Chọn' },
{ tool: 'bbox', label: 'Khung' },
{ tool: 'points', label: 'Điểm' },
{ tool: 'pen', label: 'Bút' },
{ tool: 'brush', label: 'Cọ' },
{ tool: 'polygon', label: 'Đa giác' },
];
const TOOL_LABEL: Record<AnnotationTool, string> = {
none: 'Chọn',
bbox: 'Khung',
points: 'Điểm',
pen: 'Bút',
brush: 'Cọ',
polygon: 'Đa giác',
};
/**
* Resolve a frame-URL array from an {urls?, files?} source. A pre-resolved `urls`
* array is returned as-is. For `files`, sort naturally then create one object URL
* per frame.
*
* The create + revoke MUST live in the same effect: under React 18 StrictMode the
* mount→cleanup→remount cycle would otherwise revoke the URLs right after mount
* (leaving dead `<img src>`s). Here cleanup revokes every created URL and the
* re-run recreates the whole array.
*/
function useSequenceUrls(source: ImageSequenceSource | undefined): string[] {
const urls = source?.urls;
const files = source?.files;
const [objectUrls, setObjectUrls] = useState<string[]>([]);
useEffect(() => {
if (!files || files.length === 0) {
setObjectUrls([]);
return;
}
const created = naturalSortFiles(files).map((f) => URL.createObjectURL(f));
setObjectUrls(created);
return () => {
for (const u of created) URL.revokeObjectURL(u);
};
}, [files]);
if (urls && urls.length > 0) return urls;
return objectUrls;
}
export function ImageSequenceViewer({
original,
depth,
segmentation,
fps: fpsProp,
className,
initialExpanded = null,
}: ImageSequenceViewerProps) {
const originalUrls = useSequenceUrls(original);
const depthUrls = useSequenceUrls(depth);
const segUrls = useSequenceUrls(segmentation);
const frameCount = Math.max(
originalUrls.length,
depthUrls.length,
segUrls.length,
);
const [frame, setFrame] = useState(0);
const [playing, setPlaying] = useState(false);
const [fps, setFps] = useState(fpsProp ?? DEFAULT_FPS);
const [expandedView, setExpandedView] = useState<ExpandedView>(initialExpanded ?? null);
// CT window width/level have no meaning for flat 2D rasters; brightness + contrast
// (CSS filters on the <img> panes) are the 2D-image equivalents.
const [brightness, setBrightness] = useState(1);
const [contrast, setContrast] = useState(1);
const imgFilter = `brightness(${brightness}) contrast(${contrast})`;
// Segmentation overlay controls (the explicit ask: adjust mask opacity) + the
// annotation tools, mirroring the 3D viewer's right sidebar.
const [maskVisible, setMaskVisible] = useState(true);
const [maskOpacity, setMaskOpacity] = useState(segmentation?.opacity ?? 0.6);
const [activeTool, setActiveTool] = useState<AnnotationTool>('none');
const [annotations, setAnnotations] = useState<Annotation[]>([]);
const toggleExpand = (i: QuadIndex) =>
setExpandedView((prev) => (prev === i ? null : i));
// Keep the current frame in range when the sequence length shrinks.
useEffect(() => {
setFrame((f) => clampFrame(f, frameCount));
}, [frameCount]);
// Honour a controlled `fps` prop (kept as state so the fps <select> can also drive it).
useEffect(() => {
if (fpsProp != null) setFps(fpsProp);
}, [fpsProp]);
// Surface a frame decode/load failure (broken PNG) on the original pane instead
// of a silent black quad. Reset whenever the frame or sequence changes.
const [imageError, setImageError] = useState(false);
useEffect(() => setImageError(false), [frame, originalUrls]);
// Play loop: advance the frame on an interval while playing. Drive the advance
// through the functional updater so `frame` is NOT an effect dependency — were it
// one, the interval would be torn down and recreated every tick (resetting the
// cadence). It re-runs only on play/pause, fps, or frameCount changes.
useEffect(() => {
if (!playing || frameCount <= 0) return;
const id = setInterval(() => {
setFrame((f) => (f + 1) % frameCount);
}, 1000 / fps);
return () => clearInterval(id);
}, [playing, fps, frameCount]);
const hasFrames = frameCount > 0;
const togglePlay = () => {
if (!hasFrames) return;
setPlaying((p) => !p);
};
const info = frameLabel(frame, frameCount);
const frameAnnotations = annotations.filter((a) => a.sliceIndex === frame);
// ---- Per-quad media render ------------------------------------------------
const renderQuadMedia = (q: QuadIndex) => {
switch (q) {
case 0: {
if (originalUrls.length === 0) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<span className={PLACEHOLDER_TEXT}>Chưa nh tải lên đ xem</span>
</div>
);
}
const idx = clampFrame(frame, originalUrls.length);
return (
<>
<img
src={originalUrls[idx]}
alt="Ảnh gốc"
className="w-full h-full object-contain bg-black"
style={{ filter: imgFilter }}
onError={() => setImageError(true)}
/>
{imageError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 p-3">
<span className={PLACEHOLDER_TEXT}>Không tải đưc nh.</span>
</div>
)}
<AnnotationOverlay
view="axial"
sliceIndex={frame}
tool={activeTool}
annotations={annotations}
onCommit={(a) => setAnnotations((prev) => [...prev, a])}
color={ANNO_COLOR}
onRequestExpand={() => toggleExpand(0)}
/>
</>
);
}
case 1: {
if (depthUrls.length === 0) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<span className={PLACEHOLDER_TEXT}>Không bản đ đ sâu</span>
</div>
);
}
const idx = clampFrame(frame, depthUrls.length);
return (
<img
src={depthUrls[idx]}
alt="Bản đồ độ sâu"
className="w-full h-full object-contain bg-black"
style={{ filter: `grayscale(1) ${imgFilter}` }}
/>
);
}
case 2: {
// Segmentation shown as an overlay over the original (the raw b/w mask pane
// was dropped — the mask appears only as this useful overlay).
const baseUrls = originalUrls.length > 0 ? originalUrls : segUrls;
if (baseUrls.length === 0) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<span className={PLACEHOLDER_TEXT}>Không dữ liệu phủ phân vùng</span>
</div>
);
}
const baseIdx = clampFrame(frame, baseUrls.length);
const hasSeg = segUrls.length > 0;
return (
<div className="relative w-full h-full">
<img
src={baseUrls[baseIdx]}
alt="Ảnh gốc"
className="w-full h-full object-contain bg-black"
style={{ filter: imgFilter }}
/>
{hasSeg ? (
<img
src={segUrls[clampFrame(frame, segUrls.length)]}
alt="Phủ phân vùng"
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
style={{ mixBlendMode: 'screen', opacity: maskVisible ? maskOpacity : 0 }}
/>
) : (
<div className="absolute bottom-2 right-2">
<span className={PLACEHOLDER_TEXT}>Không mặt nạ phân vùng</span>
</div>
)}
</div>
);
}
case 3:
// 3D reconstruction placeholder — populated later (e.g. a point cloud from depth).
return (
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03]">
<span className={PLACEHOLDER_TEXT}>Tái tạo 3D chưa dữ liệu</span>
</div>
);
default:
return null;
}
};
const renderQuad = (q: QuadIndex, cellClassName?: string) => (
<VideoQuad
key={q}
index={q}
label={IMAGE_QUAD_LABELS[q]}
info={hasFrames ? info : undefined}
expanded={expandedView === q}
onToggleExpand={toggleExpand}
className={cellClassName}
>
{renderQuadMedia(q)}
</VideoQuad>
);
// ---- Single CSS-grid tree -------------------------------------------------
// Layout switches via grid-template-areas so the four cells are NEVER remounted
// on expand (keeps each `<img>` and its load state stable). One grid, four stable
// keyed cells in fixed order; expand only re-targets their gridArea.
const areaFor = (q: QuadIndex): string => {
if (expandedView === null) return `q${q}`;
const order = expandedOrder(expandedView); // [main, t0, t1, t2]
if (q === order[0]) return 'main';
return `t${order.indexOf(q) - 1}`;
};
const gridStyle: CSSProperties =
expandedView === null
? {
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr 1fr',
gridTemplateAreas: '"q0 q1" "q2 q3"',
}
: {
gridTemplateColumns: '3fr 1fr',
gridTemplateRows: '1fr 1fr 1fr',
gridTemplateAreas: '"main t0" "main t1" "main t2"',
};
const grid = (
<div className="grid gap-1 w-full h-full" style={gridStyle}>
{([0, 1, 2, 3] as QuadIndex[]).map((q) => (
<div key={q} className="relative min-h-0 min-w-0" style={{ gridArea: areaFor(q) }}>
{renderQuad(q, 'w-full h-full')}
</div>
))}
</div>
);
return (
<div className={join('flex flex-col w-full h-full bg-black', className)}>
{/* Top toolbar — brightness/contrast (the 2D equivalent of CT window width/level). */}
<div className="shrink-0 flex flex-wrap items-center gap-x-6 gap-y-2 px-3 py-2 border-b border-white/10 text-white/80">
<label className="flex items-center gap-2 text-xs">
<span className="whitespace-nowrap">Đ sáng</span>
<input
type="range"
min={0.2}
max={2}
step={0.05}
value={brightness}
onChange={(e) => setBrightness(Number(e.target.value))}
className="w-28"
aria-label="Độ sáng"
/>
<span className="w-9 tabular-nums">{brightness.toFixed(2)}</span>
</label>
<label className="flex items-center gap-2 text-xs">
<span className="whitespace-nowrap">Tương phản</span>
<input
type="range"
min={0.2}
max={2}
step={0.05}
value={contrast}
onChange={(e) => setContrast(Number(e.target.value))}
className="w-28"
aria-label="Tương phản"
/>
<span className="w-9 tabular-nums">{contrast.toFixed(2)}</span>
</label>
<button
type="button"
onClick={() => {
setBrightness(1);
setContrast(1);
}}
className="text-xs text-white/50 underline-offset-2 hover:underline"
>
Đt lại
</button>
</div>
<div className="flex flex-1 min-h-0">
<div className="flex-1 min-w-0 bg-black">{grid}</div>
{/* Right sidebar — mirrors the 3D viewer: overlay control + annotation tools + list. */}
<aside className="w-72 shrink-0 overflow-y-auto border-l border-border bg-background text-foreground">
{/* Lớp phủ — segmentation overlay show/hide + opacity (the explicit ask). */}
<div className="border-b border-border p-4">
<h3 className="font-serif text-base font-semibold">Lớp phủ</h3>
<p className="text-xs text-muted-foreground">Điều chỉnh mặt nạ phân vùng.</p>
<div className="mt-3 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm">
<span
className="h-3 w-3 rounded-full"
style={{ background: segmentation?.color ?? '#34d399' }}
/>
Mặt nạ phân vùng
</span>
<button
type="button"
onClick={() => setMaskVisible((v) => !v)}
className="rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted"
title={maskVisible ? 'Ẩn lớp phủ' : 'Hiện lớp phủ'}
>
{maskVisible ? 'Ẩn' : 'Hiện'}
</button>
</div>
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Đ mờ</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={maskOpacity}
onChange={(e) => setMaskOpacity(Number(e.target.value))}
disabled={!maskVisible}
className="flex-1 disabled:opacity-40"
aria-label="Độ mờ mặt nạ"
/>
<span className="w-9 text-right text-xs tabular-nums">
{Math.round(maskOpacity * 100)}%
</span>
</div>
</div>
</div>
{/* Công cụ chú thích — drawing tools (draw on the Ảnh gốc pane). */}
<div className="border-b border-border p-4">
<h3 className="font-serif text-base font-semibold">Công cụ chú thích</h3>
<p className="text-xs text-muted-foreground">Vẽ trên nh gốc.</p>
<div className="mt-3 grid grid-cols-3 gap-2">
{TOOLS.map((t) => (
<button
key={t.tool}
type="button"
onClick={() => setActiveTool(t.tool)}
className={join(
'rounded-lg border px-2 py-3 text-xs font-medium transition',
activeTool === t.tool
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-foreground hover:bg-muted',
)}
>
{t.label}
</button>
))}
</div>
</div>
{/* Chú thích — list for the current frame. */}
<div className="p-4">
<h3 className="font-serif text-base font-semibold">
Chú thích ({frameAnnotations.length})
</h3>
{frameAnnotations.length === 0 ? (
<p className="mt-4 text-center text-xs text-muted-foreground">
Chọn một công cụ rồi vẽ trên nh gốc.
<br />
(Đa giác: bấm từng đnh, nhấn đúp đ đóng.)
</p>
) : (
<ul className="mt-3 space-y-1">
{frameAnnotations.map((a) => (
<li
key={a.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<span className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: a.color }} />
{TOOL_LABEL[a.tool]}
</span>
<button
type="button"
onClick={() => setAnnotations((prev) => prev.filter((x) => x.id !== a.id))}
className="text-destructive hover:underline"
>
Xoá
</button>
</li>
))}
</ul>
)}
</div>
</aside>
</div>
{/* Controls bar — the "video slider". */}
<div className="shrink-0 flex items-center gap-3 px-3 py-2 border-t border-white/10 text-white/80">
<button
type="button"
onClick={togglePlay}
disabled={!hasFrames}
className="shrink-0 px-2 py-1 text-xs rounded border border-white/20 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label={playing ? 'Tạm dừng' : 'Phát'}
title={playing ? 'Tạm dừng' : 'Phát'}
>
{playing ? '❚❚' : '▶'}
</button>
<input
type="range"
min={0}
max={Math.max(0, frameCount - 1)}
step={1}
value={frame}
onChange={(e) => setFrame(clampFrame(Number(e.target.value), frameCount))}
disabled={!hasFrames}
className="flex-1 min-w-0 disabled:opacity-40"
aria-label="Thanh khung hình"
/>
<span className="shrink-0 text-[11px] font-mono tabular-nums">{info}</span>
<select
value={fps}
onChange={(e) => setFps(Number(e.target.value))}
disabled={!hasFrames}
className="shrink-0 text-xs rounded border border-white/20 bg-black/40 text-white/80 px-1 py-1 disabled:opacity-40"
aria-label="Tốc độ khung hình"
>
{FPS_OPTIONS.map((f) => (
<option key={f} value={f} className="text-black">
{f} fps
</option>
))}
</select>
</div>
</div>
);
}
export default ImageSequenceViewer;
@@ -0,0 +1,57 @@
/**
* Presentational wrapper for one quad pane: frame border, top-left label chip,
* optional bottom-left info chip (timecode), and double-click-to-expand. Holds no
* playback logic — the media element is passed in as `children`.
*
* VTK-free.
*/
import type { QuadIndex } from './types';
// Inline class join — this package stays dependency-free beyond react, so it does
// NOT import `cn` from the @ump/shared barrel.
function join(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
export interface VideoQuadProps {
index: QuadIndex;
label: string;
info?: string;
expanded: boolean;
onToggleExpand: (i: QuadIndex) => void;
children: React.ReactNode;
className?: string;
}
export function VideoQuad({
index,
label,
info,
expanded,
onToggleExpand,
children,
className,
}: VideoQuadProps) {
return (
<div
className={join(
'relative overflow-hidden border-2 border-border rounded-md box-border bg-black',
expanded && 'ring-1 ring-primary/40',
className,
)}
onDoubleClick={() => onToggleExpand(index)}
>
{children}
<div className="absolute top-2 left-2 text-[11px] uppercase tracking-wide text-white/90 pointer-events-none drop-shadow z-10">
{label}
</div>
{info && (
<div className="absolute bottom-2 left-2 text-[11px] font-mono text-white/90 pointer-events-none drop-shadow z-10">
{info}
</div>
)}
</div>
);
}
@@ -0,0 +1,285 @@
/**
* `@ump/shared/video-viewer` — a 2×2 video dataset quad-viewer.
*
* Q1 video gốc (master clock) · Q2 bản đồ độ sâu · Q3 mặt nạ phân vùng (base video
* + segmentation overlay composited via mixBlendMode:'screen') · Q4 tái tạo 3D
* (placeholder). Double-click a quad to expand it; the other three stack on the
* right. A controls bar below the grid drives synced playback across the
* time-based panes.
*
* Deliberately VTK-free and dependency-free beyond react: it never imports VTK,
* three.js, or the @ump/shared barrel, so this subpath stays light.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import type {
VideoQuadViewerProps,
QuadIndex,
ExpandedView,
VideoSource,
DepthSource,
SegmentationSource,
} from './types';
import { QUAD_LABELS, formatTimecode, expandedOrder } from './playbackModel';
import { usePlaybackSync } from './usePlaybackSync';
import { VideoQuad } from './VideoQuad';
// Inline class join — keep this package barrel-free.
function join(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}
/**
* Resolve a playable URL from a {url?, file?} source. When a File is given, create
* an object URL and revoke it when the file identity changes or on unmount. A plain
* `url` is returned as-is.
*
* The create + revoke MUST live in the same effect: under React 18 StrictMode the
* mount→cleanup→remount cycle would otherwise revoke the URL right after mount
* (leaving a dead `<video src>`). Here cleanup revokes and the re-run recreates.
*/
function useSourceUrl(
source: VideoSource | DepthSource | SegmentationSource | undefined,
): string | undefined {
const file = source?.file;
const url = source?.url;
const [objectUrl, setObjectUrl] = useState<string | undefined>(undefined);
useEffect(() => {
if (!file) {
setObjectUrl(undefined);
return;
}
const created = URL.createObjectURL(file);
setObjectUrl(created);
return () => URL.revokeObjectURL(created);
}, [file]);
return objectUrl ?? url;
}
const PLACEHOLDER_TEXT = 'text-xs text-white/50 px-2 text-center';
export function VideoQuadViewer({
video,
depth,
segmentation,
recon,
className,
initialExpanded = null,
}: VideoQuadViewerProps) {
const videoUrl = useSourceUrl(video);
const depthUrl = useSourceUrl(depth);
const segUrl = useSourceUrl(segmentation);
const hasSegVideo = !!segUrl;
// Refs to the time-based <video> elements.
const videoRef = useRef<HTMLVideoElement>(null); // Q1 master
const depthRef = useRef<HTMLVideoElement>(null); // Q2
const segBaseRef = useRef<HTMLVideoElement>(null); // Q3 base (= original video again)
const segOverlayRef = useRef<HTMLVideoElement>(null); // Q3 overlay (segmentation video)
// Drive synced playback. Q1 is the master clock (index 0). Include the overlay
// ref only when a segmentation video is present. The hook is null-safe so the
// array always has stable length.
const syncRefs = useMemo(
() => [videoRef, depthRef, segBaseRef, segOverlayRef],
[],
);
const { currentTime, duration, playing, rate, togglePlay, seek, setRate } =
usePlaybackSync(syncRefs, 0, videoUrl);
const [expandedView, setExpandedView] = useState<ExpandedView>(initialExpanded ?? null);
const toggleExpand = (i: QuadIndex) =>
setExpandedView((prev) => (prev === i ? null : i));
// Surface a media decode failure (e.g. an unsupported codec such as MPEG-4
// Part 2, which Chrome's <video> can't play) instead of a silent black quad.
// Reset whenever the source changes.
const [videoError, setVideoError] = useState(false);
useEffect(() => setVideoError(false), [videoUrl]);
const timecode = `${formatTimecode(currentTime)} / ${formatTimecode(duration)}`;
// ---- Per-quad media render ------------------------------------------------
const renderQuadMedia = (q: QuadIndex) => {
switch (q) {
case 0:
if (!videoUrl) {
return (
<div className="absolute inset-0 flex items-center justify-center">
<span className={PLACEHOLDER_TEXT}>Chưa video tải lên đ xem</span>
</div>
);
}
return (
<>
<video
ref={videoRef}
src={videoUrl}
className="w-full h-full object-contain bg-black"
playsInline
muted
onError={() => setVideoError(true)}
/>
{videoError && (
<div className="absolute inset-0 flex items-center justify-center bg-black/70 p-3">
<span className={PLACEHOLDER_TEXT}>
Không phát đưc video trình duyệt không hỗ trợ codec này. Hãy chuyển sang
MP4 (H.264).
</span>
</div>
)}
</>
);
case 1:
return depthUrl ? (
<video
ref={depthRef}
src={depthUrl}
className="w-full h-full object-contain bg-black grayscale"
playsInline
muted
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className={PLACEHOLDER_TEXT}>Không bản đ đ sâu</span>
</div>
);
case 2:
return (
<div className="relative w-full h-full">
<video
ref={segBaseRef}
src={videoUrl}
className="w-full h-full object-contain bg-black"
playsInline
muted
/>
{hasSegVideo ? (
<video
ref={segOverlayRef}
src={segUrl}
className="absolute inset-0 w-full h-full object-contain pointer-events-none"
style={{ mixBlendMode: 'screen', opacity: segmentation?.opacity ?? 0.6 }}
playsInline
muted
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<span className={PLACEHOLDER_TEXT}>Không mặt nạ phân vùng</span>
</div>
)}
</div>
);
case 3:
return (
<div className="absolute inset-0 flex items-center justify-center bg-white/[0.03]">
<span className={PLACEHOLDER_TEXT}>
{recon?.placeholderText ?? 'Tái tạo 3D — chưa có dữ liệu'}
</span>
</div>
);
default:
return null;
}
};
const quadInfo = (q: QuadIndex): string | undefined =>
// Timecode chip only on the time-based panes (0,1,2).
q !== 3 ? timecode : undefined;
const renderQuad = (q: QuadIndex, cellClassName?: string) => (
<VideoQuad
key={q}
index={q}
label={QUAD_LABELS[q]}
info={quadInfo(q)}
expanded={expandedView === q}
onToggleExpand={toggleExpand}
className={cellClassName}
>
{renderQuadMedia(q)}
</VideoQuad>
);
// ---- Single CSS-grid tree -------------------------------------------------
// Layout switches via grid-template-areas so the four <video> nodes are NEVER
// remounted on expand. Remounting would drop usePlaybackSync's master-clock
// listeners and reset every video to 0 — so the tree structure (one grid, four
// stable keyed cells in fixed order) must stay identical across expand toggles.
const areaFor = (q: QuadIndex): string => {
if (expandedView === null) return `q${q}`;
const order = expandedOrder(expandedView); // [main, t0, t1, t2]
if (q === order[0]) return 'main';
return `t${order.indexOf(q) - 1}`;
};
const gridStyle: CSSProperties =
expandedView === null
? {
gridTemplateColumns: '1fr 1fr',
gridTemplateRows: '1fr 1fr',
gridTemplateAreas: '"q0 q1" "q2 q3"',
}
: {
gridTemplateColumns: '3fr 1fr',
gridTemplateRows: '1fr 1fr 1fr',
gridTemplateAreas: '"main t0" "main t1" "main t2"',
};
const grid = (
<div className="grid gap-1 w-full h-full" style={gridStyle}>
{([0, 1, 2, 3] as QuadIndex[]).map((q) => (
<div key={q} className="relative min-h-0 min-w-0" style={{ gridArea: areaFor(q) }}>
{renderQuad(q, 'w-full h-full')}
</div>
))}
</div>
);
return (
<div className={join('flex flex-col w-full h-full', className)}>
<div className="flex-1 min-h-0">{grid}</div>
{/* Controls bar — plain HTML + Tailwind, no barrel imports. */}
<div className="shrink-0 flex items-center gap-3 px-2 py-2 border-t border-border">
<button
type="button"
onClick={togglePlay}
className="shrink-0 px-2 py-1 text-xs rounded border border-border hover:bg-accent"
aria-label={playing ? 'Tạm dừng' : 'Phát'}
title={playing ? 'Tạm dừng' : 'Phát'}
>
{playing ? '❚❚' : '▶'}
</button>
<input
type="range"
min={0}
max={duration || 0}
step={0.01}
value={currentTime}
onChange={(e) => seek(Number(e.target.value))}
className="flex-1 min-w-0"
aria-label="Thanh thời gian"
/>
<span className="shrink-0 text-[11px] font-mono text-muted-foreground tabular-nums">
{timecode}
</span>
<select
value={rate}
onChange={(e) => setRate(Number(e.target.value))}
className="shrink-0 text-xs rounded border border-border bg-background px-1 py-1"
aria-label="Tốc độ phát"
>
<option value={0.5}>0.5x</option>
<option value={1}>1x</option>
<option value={1.5}>1.5x</option>
<option value={2}>2x</option>
</select>
</div>
</div>
);
}
export default VideoQuadViewer;
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import {
clampFrame,
naturalSortFiles,
naturalSortBy,
IMAGE_QUAD_LABELS,
frameLabel,
} from './imageSequenceModel';
// A minimal File stand-in — we only ever read `.name`. Avoids relying on the DOM
// File constructor under the `node` vitest env.
function mkFile(name: string): File {
return { name } as File;
}
describe('clampFrame', () => {
it('returns an in-range index unchanged', () => {
expect(clampFrame(0, 10)).toBe(0);
expect(clampFrame(4, 10)).toBe(4);
expect(clampFrame(9, 10)).toBe(9);
});
it('clamps an over-range index down to count - 1', () => {
expect(clampFrame(15, 10)).toBe(9);
expect(clampFrame(10, 10)).toBe(9);
});
it('clamps a negative index up to 0', () => {
expect(clampFrame(-3, 10)).toBe(0);
});
it('returns 0 for a NaN / non-finite index', () => {
expect(clampFrame(NaN, 10)).toBe(0);
// Infinity is non-finite → 0 per the contract (not the max index).
expect(clampFrame(Infinity, 10)).toBe(0);
});
it('returns 0 for an empty sequence (count 0)', () => {
expect(clampFrame(0, 0)).toBe(0);
expect(clampFrame(3, 0)).toBe(0);
});
it('floors a fractional index', () => {
expect(clampFrame(2.9, 10)).toBe(2);
});
});
describe('naturalSortFiles', () => {
it('orders frame names numerically (frame2 < frame10)', () => {
const sorted = naturalSortFiles([
mkFile('f10.png'),
mkFile('f2.png'),
mkFile('f1.png'),
]);
expect(sorted.map((f) => f.name)).toEqual(['f1.png', 'f2.png', 'f10.png']);
});
it('returns a new array and does not mutate the input', () => {
const input = [mkFile('b.png'), mkFile('a.png')];
const sorted = naturalSortFiles(input);
expect(sorted).not.toBe(input);
expect(input.map((f) => f.name)).toEqual(['b.png', 'a.png']);
});
});
describe('naturalSortBy', () => {
it('sorts by a string key with numeric awareness', () => {
expect(naturalSortBy(['x10', 'x2', 'x1'], (s) => s)).toEqual(['x1', 'x2', 'x10']);
});
});
describe('IMAGE_QUAD_LABELS', () => {
it('carries the Vietnamese pane labels with diacritics', () => {
expect(IMAGE_QUAD_LABELS[0]).toBe('Ảnh gốc');
expect(IMAGE_QUAD_LABELS[1]).toBe('Bản đồ độ sâu');
expect(IMAGE_QUAD_LABELS[2]).toBe('Phủ phân vùng');
expect(IMAGE_QUAD_LABELS[3]).toBe('Tái tạo 3D');
});
});
describe('frameLabel', () => {
it('returns "khung 0/0" for an empty sequence', () => {
expect(frameLabel(0, 0)).toBe('khung 0/0');
});
it('formats a 1-based counter', () => {
expect(frameLabel(4, 10)).toBe('khung 5/10');
expect(frameLabel(0, 10)).toBe('khung 1/10');
});
it('clamps the index inside the counter', () => {
expect(frameLabel(99, 10)).toBe('khung 10/10');
expect(frameLabel(-5, 10)).toBe('khung 1/10');
});
});
@@ -0,0 +1,57 @@
/**
* Pure, DOM-free helpers for the image-sequence quad-viewer. Fully unit-testable —
* no React, no `<img>`, no VTK. The `ImageSequenceViewer` component composes these.
*
* The sequence viewer mirrors the video viewer but is driven by a frame index over
* an array of 2D PNG frames (original / depth / segmentation) instead of a
* `<video>` currentTime. These helpers cover clamping the frame index, natural
* (numeric) sorting of frame files, and the Vietnamese pane labels / counters.
*/
import type { QuadIndex } from './types';
/**
* Clamp a frame index to the valid range [0, max(0, count - 1)].
* Guards NaN / negative inputs and an empty (count <= 0) sequence → 0.
*/
export function clampFrame(i: number, count: number): number {
if (!Number.isFinite(count) || count <= 0) return 0;
const max = count - 1;
if (!Number.isFinite(i) || i < 0) return 0;
return i > max ? max : Math.floor(i);
}
/** Vietnamese labels for the four image quad panes. Diacritics preserved. */
export const IMAGE_QUAD_LABELS: Record<QuadIndex, string> = {
0: 'Ảnh gốc',
1: 'Bản đồ độ sâu',
2: 'Phủ phân vùng',
3: 'Tái tạo 3D',
};
/**
* Sort items by a string key using a natural (numeric-aware) comparison so frame
* names order as humans expect: `frame2` < `frame10`. Returns a new array; the
* input is left untouched.
*/
export function naturalSortBy<T>(items: T[], key: (t: T) => string): T[] {
return [...items].sort((a, b) =>
key(a).localeCompare(key(b), undefined, { numeric: true, sensitivity: 'base' }),
);
}
/**
* Sort frame files by `file.name` with a natural (numeric-aware) comparison, so
* `['f10.png','f2.png','f1.png']` → `['f1.png','f2.png','f10.png']`. Returns a new
* array; the input array is not mutated.
*/
export function naturalSortFiles(files: File[]): File[] {
return naturalSortBy(files, (f) => f.name);
}
/**
* Human-readable 1-based frame counter, e.g. `khung 5/10`. An empty sequence
* (count 0) → `khung 0/0`. Diacritics preserved.
*/
export function frameLabel(i: number, count: number): string {
return count ? `khung ${clampFrame(i, count) + 1}/${count}` : 'khung 0/0';
}
@@ -0,0 +1,11 @@
/**
* `@ump/shared/video-viewer` — a VTK-free 2×2 video dataset quad-viewer.
*
* Unlike the `@ump/shared/viewer` subpath (which pulls @kitware/vtk.js), this
* package depends on nothing beyond react, so it never bloats a page's bundle.
*/
export { VideoQuadViewer, default } from './VideoQuadViewer';
export { ImageSequenceViewer } from './ImageSequenceViewer';
export * from './types';
export * from './playbackModel';
export * from './imageSequenceModel';
@@ -0,0 +1,95 @@
import { describe, it, expect } from 'vitest';
import {
clampTime,
formatTimecode,
isTimeBased,
expandedOrder,
QUAD_LABELS,
} from './playbackModel';
describe('clampTime', () => {
it('clamps within [0, duration]', () => {
expect(clampTime(5, 10)).toBe(5);
expect(clampTime(0, 10)).toBe(0);
expect(clampTime(10, 10)).toBe(10);
});
it('clamps an over-duration time down to duration', () => {
expect(clampTime(15, 10)).toBe(10);
});
it('clamps a negative time up to 0', () => {
expect(clampTime(-3, 10)).toBe(0);
});
it('returns 0 for NaN / non-finite time', () => {
expect(clampTime(NaN, 10)).toBe(0);
expect(clampTime(Infinity, 10)).toBe(0);
});
it('returns 0 when duration is 0, NaN, or non-finite', () => {
expect(clampTime(5, 0)).toBe(0);
expect(clampTime(5, NaN)).toBe(0);
expect(clampTime(5, -1)).toBe(0);
});
});
describe('formatTimecode', () => {
it('formats 0 as "0:00"', () => {
expect(formatTimecode(0)).toBe('0:00');
});
it('zero-pads seconds under ten', () => {
expect(formatTimecode(5)).toBe('0:05');
});
it('formats minutes and seconds (75 → "1:15")', () => {
expect(formatTimecode(75)).toBe('1:15');
});
it('floors fractional seconds', () => {
expect(formatTimecode(75.9)).toBe('1:15');
});
it('guards NaN / negative → "0:00"', () => {
expect(formatTimecode(NaN)).toBe('0:00');
expect(formatTimecode(-5)).toBe('0:00');
});
});
describe('isTimeBased', () => {
it('is true for the video / depth / segmentation quads (0,1,2)', () => {
expect(isTimeBased(0)).toBe(true);
expect(isTimeBased(1)).toBe(true);
expect(isTimeBased(2)).toBe(true);
});
it('is false for the 3D recon quad (3)', () => {
expect(isTimeBased(3)).toBe(false);
});
});
describe('expandedOrder', () => {
it('returns the natural 2×2 order when nothing is expanded', () => {
expect(expandedOrder(null)).toEqual([0, 1, 2, 3]);
});
it('puts the expanded quad first, then the rest ascending', () => {
expect(expandedOrder(2)).toEqual([2, 0, 1, 3]);
});
it('handles each expanded index', () => {
expect(expandedOrder(0)).toEqual([0, 1, 2, 3]);
expect(expandedOrder(3)).toEqual([3, 0, 1, 2]);
});
});
describe('QUAD_LABELS', () => {
it('carries the Vietnamese pane labels with diacritics', () => {
expect(QUAD_LABELS[0]).toBe('Video gốc');
expect(QUAD_LABELS[1]).toBe('Bản đồ độ sâu');
expect(QUAD_LABELS[2]).toBe('Mặt nạ phân vùng');
expect(QUAD_LABELS[3]).toBe('Tái tạo 3D');
});
});
@@ -0,0 +1,55 @@
/**
* Pure, DOM-free playback helpers for the video quad-viewer. Fully unit-testable —
* no React, no `<video>`, no VTK. The hook (`usePlaybackSync`) and the components
* compose these.
*/
import type { QuadIndex, ExpandedView } from './types';
/**
* Clamp a time to the playable range [0, duration].
* Guards NaN / negative / non-finite inputs and a 0 (or unknown) duration → 0.
*/
export function clampTime(t: number, duration: number): number {
if (!Number.isFinite(t) || t < 0) return 0;
if (!Number.isFinite(duration) || duration <= 0) return 0;
return t > duration ? duration : t;
}
/**
* Format seconds as "m:ss" (e.g. 75 → "1:15"). Guards NaN / negative / non-finite → "0:00".
*/
export function formatTimecode(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = total % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
/** Vietnamese labels for the four quad panes. Diacritics preserved. */
export const QUAD_LABELS: Record<QuadIndex, string> = {
0: 'Video gốc',
1: 'Bản đồ độ sâu',
2: 'Mặt nạ phân vùng',
3: 'Tái tạo 3D',
};
/**
* Whether a quad is time-driven (synced to the master video clock).
* True for 0/1/2 (video, depth, segmentation); false for 3 (the 3D recon
* placeholder is not a time-based stream).
*/
export function isTimeBased(q: QuadIndex): boolean {
return q === 0 || q === 1 || q === 2;
}
/**
* Quad render order. When a pane is expanded, return [expanded, ...the other three
* in ascending order] — main first, then the three thumbnails. When null, the
* natural 2×2 order [0,1,2,3].
*/
export function expandedOrder(expanded: ExpandedView): QuadIndex[] {
const all: QuadIndex[] = [0, 1, 2, 3];
if (expanded === null) return all;
return [expanded, ...all.filter((i) => i !== expanded)];
}
@@ -0,0 +1,65 @@
/**
* Public contracts for the `@ump/shared/video-viewer` package — a VTK-free 2×2
* video dataset quad-viewer. Kept deliberately free of any VTK / three.js types
* so this subpath never pulls the heavy rendering chunk.
*/
export interface VideoSource {
url?: string;
file?: File;
fps?: number;
label?: string;
}
export interface DepthSource {
url?: string;
file?: File;
label?: string;
}
export interface SegmentationSource {
url?: string;
file?: File;
color?: string;
opacity?: number;
label?: string;
}
export interface ReconSource {
ready?: boolean;
placeholderText?: string;
}
export type QuadIndex = 0 | 1 | 2 | 3;
export type ExpandedView = QuadIndex | null;
export interface VideoQuadViewerProps {
video?: VideoSource;
depth?: DepthSource;
segmentation?: SegmentationSource;
recon?: ReconSource;
className?: string;
initialExpanded?: ExpandedView;
}
/**
* One channel of a 2D image SEQUENCE (PNG frames). Either pre-resolved `urls` or
* raw `files` (sorted naturally + turned into object URLs by the viewer). `opacity`
* applies to the segmentation overlay in the fused (Phủ phân vùng) pane.
*/
export interface ImageSequenceSource {
urls?: string[];
files?: File[];
color?: string;
opacity?: number;
label?: string;
}
export interface ImageSequenceViewerProps {
original?: ImageSequenceSource;
depth?: ImageSequenceSource;
segmentation?: ImageSequenceSource;
fps?: number;
className?: string;
initialExpanded?: ExpandedView;
}
@@ -0,0 +1,141 @@
/**
* Drives synced playback across the time-based `<video>` elements of the quad
* viewer. The master video (videoRefs[masterIndex]) is the clock; every other
* non-null video is nudged to follow it when it drifts. Fully null-safe — refs
* may be null and videos may be absent.
*
* VTK-free: this hook touches only the DOM `<video>` API.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { clampTime } from './playbackModel';
/** Seconds of drift tolerated before a follower is re-synced to the master clock. */
const DRIFT_TOLERANCE = 0.15;
export interface PlaybackSync {
currentTime: number;
duration: number;
playing: boolean;
rate: number;
play: () => void;
pause: () => void;
togglePlay: () => void;
seek: (t: number) => void;
setRate: (r: number) => void;
}
export function usePlaybackSync(
videoRefs: React.RefObject<HTMLVideoElement>[],
masterIndex = 0,
/**
* Optional value that, when it changes, forces the master-clock listeners to
* re-subscribe — e.g. pass the master video's URL so listeners attach once the
* master `<video>` appears (or its source changes) after the initial mount.
*/
resubscribeKey?: unknown,
): PlaybackSync {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [playing, setPlaying] = useState(false);
const [rate, setRateState] = useState(1);
// Keep a live handle to the refs array without re-subscribing the master
// listeners every render (the array identity changes each render).
const refsRef = useRef(videoRefs);
refsRef.current = videoRefs;
const durationRef = useRef(0);
durationRef.current = duration;
const forEachVideo = useCallback(
(fn: (v: HTMLVideoElement) => void) => {
for (const ref of refsRef.current) {
const v = ref?.current;
if (v) fn(v);
}
},
[],
);
const master = videoRefs[masterIndex];
// Subscribe to the master video's clock. `timeupdate` advances state and nudges
// any follower that has drifted; `loadedmetadata` captures the duration.
useEffect(() => {
const m = master?.current;
if (!m) return;
const onTimeUpdate = () => {
const t = m.currentTime;
setCurrentTime(t);
// Re-sync followers without triggering their own timeupdate feedback loops:
// only assign when drift exceeds the tolerance.
for (const ref of refsRef.current) {
const v = ref?.current;
if (!v || v === m) continue;
if (Math.abs(v.currentTime - t) > DRIFT_TOLERANCE) {
v.currentTime = t;
}
}
};
const onLoadedMetadata = () => {
setDuration(Number.isFinite(m.duration) ? m.duration : 0);
};
const onEnded = () => setPlaying(false);
m.addEventListener('timeupdate', onTimeUpdate);
m.addEventListener('loadedmetadata', onLoadedMetadata);
m.addEventListener('ended', onEnded);
// The metadata event may have already fired before this effect attached.
if (Number.isFinite(m.duration) && m.duration > 0) {
setDuration(m.duration);
}
return () => {
m.removeEventListener('timeupdate', onTimeUpdate);
m.removeEventListener('loadedmetadata', onLoadedMetadata);
m.removeEventListener('ended', onEnded);
};
}, [master, resubscribeKey]);
const play = useCallback(() => {
forEachVideo((v) => {
// play() returns a promise that can reject (autoplay policy) — swallow it.
void v.play?.()?.catch?.(() => undefined);
});
setPlaying(true);
}, [forEachVideo]);
const pause = useCallback(() => {
forEachVideo((v) => v.pause?.());
setPlaying(false);
}, [forEachVideo]);
const togglePlay = useCallback(() => {
if (playing) pause();
else play();
}, [playing, play, pause]);
const seek = useCallback(
(t: number) => {
const clamped = clampTime(t, durationRef.current);
forEachVideo((v) => {
v.currentTime = clamped;
});
setCurrentTime(clamped);
},
[forEachVideo],
);
const setRate = useCallback(
(r: number) => {
forEachVideo((v) => {
v.playbackRate = r;
});
setRateState(r);
},
[forEachVideo],
);
return { currentTime, duration, playing, rate, play, pause, togglePlay, seek, setRate };
}
@@ -0,0 +1,254 @@
import { useEffect, useReducer, useRef, useState } from 'react';
import type { Annotation, AnnotationPoint, AnnotationTool } from './types';
interface AnnotationOverlayProps {
view: 'axial' | 'coronal' | 'sagittal';
/** Current slice index of this pane — annotations are tagged + filtered by it. */
sliceIndex: number;
tool: AnnotationTool;
annotations: Annotation[];
onCommit: (annotation: Annotation) => void;
color?: string;
/** Double-click (when not finishing a polygon) → expand/restore this pane. */
onRequestExpand?: () => void;
/** Forward wheel to the underlying viewport so slice-scroll / zoom keep working. */
onForwardWheel?: (e: { deltaY: number; ctrlKey: boolean; metaKey: boolean }) => void;
}
const PEN_WIDTH = 2;
const BRUSH_WIDTH = 16;
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const newId = () =>
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `anno_${Math.round(performance.now())}_${Math.floor(Math.random() * 1e6)}`;
/**
* Per-pane drawing surface for the ImageHub annotation tools. Sits on top of one
* 2D viewport (absolute, inset-0). Pointer-transparent while the tool is "none"
* (so VTK keeps slice-scroll / zoom / rotate); when a tool is active it captures
* events with NATIVE listeners that `stopPropagation()`, so a draw drag never
* reaches the VTK interactor bound on the parent canvas (which would otherwise
* rotate / window-level the 2D views). Geometry is normalized [0..1] to the pane.
*/
export function AnnotationOverlay({
view,
sliceIndex,
tool,
annotations,
onCommit,
color = '#22d3ee',
onRequestExpand,
onForwardWheel,
}: AnnotationOverlayProps) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
const draftRef = useRef<AnnotationPoint[] | null>(null);
const drawingRef = useRef(false);
const [, bump] = useReducer((x: number) => x + 1, 0);
// Latest tool + callbacks in refs so the native listeners (added once per
// view/slice) always see current values without re-binding mid-drag.
const toolRef = useRef(tool);
toolRef.current = tool;
const cbRef = useRef({ onCommit, onRequestExpand, onForwardWheel, color });
cbRef.current = { onCommit, onRequestExpand, onForwardWheel, color };
useEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const r = el.getBoundingClientRect();
setSize({ w: r.width, h: r.height });
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
// Cancel any in-progress draft when the tool changes or the slice scrolls away.
useEffect(() => {
draftRef.current = null;
drawingRef.current = false;
bump();
}, [tool, sliceIndex, view]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const toNorm = (e: PointerEvent): AnnotationPoint => {
const r = el.getBoundingClientRect();
return { x: clamp01((e.clientX - r.left) / r.width), y: clamp01((e.clientY - r.top) / r.height) };
};
const commit = (t: Exclude<AnnotationTool, 'none'>, points: AnnotationPoint[], strokeWidth?: number) =>
cbRef.current.onCommit({ id: newId(), view, sliceIndex, tool: t, points, color: cbRef.current.color, strokeWidth });
const onDown = (e: PointerEvent) => {
const t = toolRef.current;
if (t === 'none') return;
e.stopPropagation();
e.preventDefault();
try {
el.setPointerCapture(e.pointerId);
} catch {
/* best-effort */
}
const p = toNorm(e);
if (t === 'points') {
commit('points', [p]);
return;
}
if (t === 'polygon') {
draftRef.current = draftRef.current ? [...draftRef.current, p] : [p];
bump();
return;
}
drawingRef.current = true;
draftRef.current = t === 'bbox' ? [p, p] : [p];
bump();
};
const onMove = (e: PointerEvent) => {
if (!drawingRef.current || !draftRef.current) return;
e.stopPropagation();
const t = toolRef.current;
const p = toNorm(e);
const d = draftRef.current;
draftRef.current = t === 'bbox' ? [d[0], p] : [...d, p];
bump();
};
const onUp = (e: PointerEvent) => {
if (!drawingRef.current) return;
e.stopPropagation();
drawingRef.current = false;
const t = toolRef.current;
const d = draftRef.current;
if (d) {
if (t === 'bbox') {
const [a, b] = d;
if (Math.abs(a.x - b.x) > 0.005 && Math.abs(a.y - b.y) > 0.005) {
commit('bbox', [
{ x: Math.min(a.x, b.x), y: Math.min(a.y, b.y) },
{ x: Math.max(a.x, b.x), y: Math.max(a.y, b.y) },
]);
}
} else if (t === 'pen' && d.length > 1) commit('pen', d, PEN_WIDTH);
else if (t === 'brush' && d.length > 1) commit('brush', d, BRUSH_WIDTH);
}
draftRef.current = null;
bump();
};
const onDbl = (e: MouseEvent) => {
const t = toolRef.current;
if (t === 'none') return;
e.stopPropagation();
const d = draftRef.current;
if (t === 'polygon' && d && d.length >= 3) {
commit('polygon', d);
draftRef.current = null;
bump();
} else {
cbRef.current.onRequestExpand?.();
}
};
const onWheel = (e: WheelEvent) => {
if (toolRef.current === 'none') return;
e.stopPropagation();
cbRef.current.onForwardWheel?.({ deltaY: e.deltaY, ctrlKey: e.ctrlKey, metaKey: e.metaKey });
};
el.addEventListener('pointerdown', onDown);
el.addEventListener('pointermove', onMove);
el.addEventListener('pointerup', onUp);
el.addEventListener('pointerleave', onUp);
el.addEventListener('dblclick', onDbl);
el.addEventListener('wheel', onWheel, { passive: false });
return () => {
el.removeEventListener('pointerdown', onDown);
el.removeEventListener('pointermove', onMove);
el.removeEventListener('pointerup', onUp);
el.removeEventListener('pointerleave', onUp);
el.removeEventListener('dblclick', onDbl);
el.removeEventListener('wheel', onWheel);
};
}, [view, sliceIndex]);
const { w, h } = size;
const draft = draftRef.current;
const px = (p: AnnotationPoint) => `${p.x * w},${p.y * h}`;
const onThisSlice = annotations.filter((a) => a.view === view && a.sliceIndex === sliceIndex);
const renderShape = (a: Annotation, key: string, live = false) => {
const stroke = a.color || color;
const dash = live ? '4 3' : undefined;
if (a.tool === 'bbox' && a.points.length === 2) {
const [tl, br] = a.points;
return (
<rect
key={key}
x={Math.min(tl.x, br.x) * w}
y={Math.min(tl.y, br.y) * h}
width={Math.abs(br.x - tl.x) * w}
height={Math.abs(br.y - tl.y) * h}
fill={`${stroke}22`}
stroke={stroke}
strokeWidth={2}
strokeDasharray={dash}
/>
);
}
if (a.tool === 'points') {
return (
<g key={key}>
{a.points.map((p, i) => (
<circle key={i} cx={p.x * w} cy={p.y * h} r={5} fill={stroke} stroke="#000" strokeWidth={1} />
))}
</g>
);
}
if (a.tool === 'pen' || a.tool === 'brush') {
return (
<polyline
key={key}
points={a.points.map(px).join(' ')}
fill="none"
stroke={stroke}
strokeWidth={a.strokeWidth ?? PEN_WIDTH}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={dash}
opacity={a.tool === 'brush' ? 0.55 : 1}
/>
);
}
if (a.tool === 'polygon') {
const Tag = live ? 'polyline' : 'polygon';
return (
<Tag key={key} points={a.points.map(px).join(' ')} fill={live ? 'none' : `${stroke}22`} stroke={stroke} strokeWidth={2} strokeDasharray={dash} />
);
}
return null;
};
const draftAnno: Annotation | null =
draft && tool !== 'none' && tool !== 'points'
? { id: 'draft', view, sliceIndex, tool, points: draft, color, strokeWidth: tool === 'brush' ? BRUSH_WIDTH : PEN_WIDTH }
: null;
return (
<div
ref={ref}
className="absolute inset-0"
style={{
pointerEvents: tool === 'none' ? 'none' : 'auto',
cursor: tool === 'none' ? 'default' : 'crosshair',
touchAction: 'none',
}}
>
<svg width={w} height={h} className="absolute inset-0" style={{ pointerEvents: 'none' }}>
{onThisSlice.map((a) => renderShape(a, a.id))}
{draftAnno && renderShape(draftAnno, 'draft', true)}
</svg>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,821 @@
import { useEffect, useRef, useCallback, useState } from "react";
import "@kitware/vtk.js/Rendering/Profiles/Volume";
import "@kitware/vtk.js/Rendering/Profiles/Geometry";
import "@kitware/vtk.js/Rendering/Misc/RenderingAPIs";
import vtkBoundingBox from "@kitware/vtk.js/Common/DataModel/BoundingBox";
import vtkColorTransferFunction from "@kitware/vtk.js/Rendering/Core/ColorTransferFunction";
import vtkImageResliceMapper from "@kitware/vtk.js/Rendering/Core/ImageResliceMapper";
import vtkImageSlice from "@kitware/vtk.js/Rendering/Core/ImageSlice";
import vtkInteractorStyleImage from "@kitware/vtk.js/Interaction/Style/InteractorStyleImage";
import vtkInteractorStyleTrackballCamera from "@kitware/vtk.js/Interaction/Style/InteractorStyleTrackballCamera";
import vtkPiecewiseFunction from "@kitware/vtk.js/Common/DataModel/PiecewiseFunction";
import vtkPlane from "@kitware/vtk.js/Common/DataModel/Plane";
import vtkRenderWindow from "@kitware/vtk.js/Rendering/Core/RenderWindow";
import vtkRenderWindowInteractor from "@kitware/vtk.js/Rendering/Core/RenderWindowInteractor";
import vtkRenderer from "@kitware/vtk.js/Rendering/Core/Renderer";
import vtkVolume from "@kitware/vtk.js/Rendering/Core/Volume";
import vtkVolumeMapper from "@kitware/vtk.js/Rendering/Core/VolumeMapper";
import vtkOpenGLRenderWindow from "@kitware/vtk.js/Rendering/OpenGL/RenderWindow";
import type { ViewOrientation, Active2DView } from "./ViewRotationControls";
interface QuadViewRendererProps {
dicomData: any;
windowWidth: number;
windowLevel: number;
opacity: number;
isLoading?: boolean;
onRotate3D?: (orientation: ViewOrientation) => void;
active2DView?: Active2DView;
currentOrientation?: ViewOrientation;
}
interface ViewportContainer {
element: HTMLDivElement;
id: number;
label: string;
}
type ExpandedView = null | 0 | 1 | 2 | 3;
export const QuadViewRenderer = ({
dicomData,
windowWidth,
windowLevel,
opacity,
isLoading = false,
onRotate3D,
active2DView,
currentOrientation,
}: QuadViewRendererProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const [expandedView, setExpandedView] = useState<ExpandedView>(null);
const isMouseDownRef = useRef(false);
const activeViewportRef = useRef<HTMLElement | null>(null);
// Rotation angle state for each view
const rotationAnglesRef = useRef<{ [key: string]: number }>({
axial: 0,
coronal: 0,
sagittal: 0,
});
const contextRef = useRef<{
renderWindow: any;
renderWindowView: any;
interactor: any;
renderers: any[];
containers: ViewportContainer[];
imageSliceActors: any[];
slicePlanes: any[];
volumeActor: any;
ctf: any;
pf: any;
iStyle: any;
tStyle: any;
} | null>(null);
const loadedDataRef = useRef<any>(null);
// --- Helper Functions ---
const resizeViewportContainer = useCallback((
_view: HTMLElement,
ren: any,
element: HTMLElement
) => {
const vp = ren.getViewport();
const border = 2;
// Position the frame as PERCENTAGES of the container. The WebGL canvas fills the
// container (style width:100%), so a normalized VTK viewport [vp0,vp1,vp2,vp3] maps
// to the same %-rect and lines up with the rendered content. Computing px from
// getBoundingClientRect() was fragile: the viewer opens in a dialog that animates
// with transform: scale(.95→1), and ResizeObserver does NOT fire on transform
// changes — so a rect captured mid-zoom froze the frame ~5% small/offset while the
// canvas stretched to fill, leaving the slice spilling past the top/right borders.
// Percentages track the canvas under any transform or resize. (box-sizing:border-box
// is set on the element, so the 2px frame is drawn inside the viewport rect.)
element.style.position = "absolute";
element.style.left = `${vp[0] * 100}%`;
element.style.bottom = `${vp[1] * 100}%`;
element.style.width = `${(vp[2] - vp[0]) * 100}%`;
element.style.height = `${(vp[3] - vp[1]) * 100}%`;
element.style.border = `solid ${border}px hsl(var(--border))`;
element.style.borderRadius = "6px";
}, []);
const bindInteractor = useCallback((interactor: any, el: HTMLElement | null) => {
if (!contextRef.current || !containerRef.current) return;
const { iStyle, tStyle } = contextRef.current;
const full = containerRef.current;
// Switch interaction STYLE per active pane (3D → trackball, 2D → image), but
// always bind events to the full canvas container — never a per-pane overlay —
// so VTK's findPokedRenderer resolves the cursor to the correct viewport in
// full-canvas coordinates (sub-region binding made 3D drags drive the 2D panes).
if (el) {
interactor.setInteractorStyle(el.dataset.viewId === "3" ? tStyle : iStyle);
}
if (interactor.getContainer() !== full) {
if (interactor.getContainer()) {
interactor.unbindEvents();
}
interactor.bindEvents(full);
}
}, []);
const getViewports = useCallback((expanded: ExpandedView): [number, number, number, number][] => {
if (expanded === null) {
return [
[0.01, 0.51, 0.49, 0.99], // top-left (Axial)
[0.51, 0.51, 0.99, 0.99], // top-right (Coronal)
[0.01, 0.01, 0.49, 0.49], // bottom-left (Sagittal)
[0.51, 0.01, 0.99, 0.49], // bottom-right (3D)
];
} else {
const mainViewport: [number, number, number, number] = [0.01, 0.01, 0.74, 0.99];
const sideViewports: [number, number, number, number][] = [
[0.75, 0.67, 0.99, 0.99],
[0.75, 0.34, 0.99, 0.66],
[0.75, 0.01, 0.99, 0.33],
];
const result: [number, number, number, number][] = [];
let sideIndex = 0;
for (let i = 0; i < 4; i++) {
if (i === expanded) {
result.push(mainViewport);
} else {
result.push(sideViewports[sideIndex]);
sideIndex++;
}
}
return result;
}
}, []);
const resize = useCallback(() => {
if (!containerRef.current || !contextRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const { renderWindowView, renderers, containers, renderWindow } = contextRef.current;
renderWindowView.setSize(rect.width, rect.height);
containers.forEach((c) => {
resizeViewportContainer(containerRef.current!, renderers[c.id], c.element);
});
renderWindow.render();
}, [resizeViewportContainer]);
// --- Logic 1: 3D Rotation Functionality ---
const rotate3DView = useCallback((orientation: ViewOrientation) => {
if (!contextRef.current || !dicomData?.imageData) return;
const { renderers, renderWindow } = contextRef.current;
const bounds = dicomData.imageData.getBounds();
const center = vtkBoundingBox.getCenter(bounds);
const diagonal = vtkBoundingBox.getDiagonalLength(bounds);
const distance = (diagonal ?? 0) * 1.5;
const cam3d = renderers[3].getActiveCamera();
switch (orientation) {
case "anterior":
cam3d.setPosition(center[0], center[1] - distance, center[2]);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, 0, 1);
break;
case "posterior":
cam3d.setPosition(center[0], center[1] + distance, center[2]);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, 0, 1);
break;
case "left-lateral":
cam3d.setPosition(center[0] - distance, center[1], center[2]);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, 0, 1);
break;
case "right-lateral":
cam3d.setPosition(center[0] + distance, center[1], center[2]);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, 0, 1);
break;
case "superior":
cam3d.setPosition(center[0], center[1], center[2] + distance);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, 1, 0);
break;
case "inferior":
cam3d.setPosition(center[0], center[1], center[2] - distance);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, -1, 0);
break;
default:
cam3d.setPosition(center[0], center[1] - distance, center[2]);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, 0, 1);
}
renderWindow.render();
onRotate3D?.(orientation);
}, [dicomData, onRotate3D]);
// --- Logic 2: Robust 2D Rotation Logic (Rodrigues' Formula) ---
const rotate2DView = useCallback((view: Active2DView, direction: "cw" | "ccw") => {
if (!contextRef.current || !view || !dicomData?.imageData) return;
const { renderers, renderWindow } = contextRef.current;
const bounds = dicomData.imageData.getBounds();
const center = vtkBoundingBox.getCenter(bounds);
// Rotation step in degrees
const rotationStep = direction === "cw" ? 90 : -90;
// Update rotation angle
rotationAnglesRef.current[view] = (rotationAnglesRef.current[view] + rotationStep) % 360;
const angle = rotationAnglesRef.current[view] * (Math.PI / 180);
let cam: any;
let viewDirection: [number, number, number];
let baseViewUp: [number, number, number];
// Get the appropriate renderer and base view up vector
if (view === "axial") {
cam = renderers[0].getActiveCamera();
viewDirection = [0, 0, 1]; // Looking along Z axis
baseViewUp = [0, -1, 0];
} else if (view === "coronal") {
cam = renderers[1].getActiveCamera();
viewDirection = [0, 1, 0]; // Looking along Y axis
baseViewUp = [0, 0, 1];
} else if (view === "sagittal") {
cam = renderers[2].getActiveCamera();
viewDirection = [1, 0, 0]; // Looking along X axis
baseViewUp = [0, 0, 1];
} else {
return;
}
// Get current view up vector from camera
const currentViewUp = cam.getViewUp();
// Calculate rotation axis (the view direction)
const axis = viewDirection;
// Rotate the current view up vector around the view direction axis
// Using Rodrigues' rotation formula
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const dot = currentViewUp[0] * axis[0] + currentViewUp[1] * axis[1] + currentViewUp[2] * axis[2];
// Project view up onto the plane perpendicular to the axis
const projX = currentViewUp[0] - dot * axis[0];
const projY = currentViewUp[1] - dot * axis[1];
const projZ = currentViewUp[2] - dot * axis[2];
// Normalize the projection
const projLen = Math.sqrt(projX * projX + projY * projY + projZ * projZ);
if (projLen < 1e-6) {
// If projection is too small, use base view up
const rotatedViewUp: [number, number, number] = [
baseViewUp[0] * cos - (axis[1] * baseViewUp[2] - axis[2] * baseViewUp[1]) * sin,
baseViewUp[1] * cos - (axis[2] * baseViewUp[0] - axis[0] * baseViewUp[2]) * sin,
baseViewUp[2] * cos - (axis[0] * baseViewUp[1] - axis[1] * baseViewUp[0]) * sin
];
cam.setFocalPoint(...center);
cam.setViewUp(...rotatedViewUp);
} else {
// Normalize projection
const ux = projX / projLen;
const uy = projY / projLen;
const uz = projZ / projLen;
// Calculate perpendicular vector (cross product of axis and projection)
const perpX = axis[1] * uz - axis[2] * uy;
const perpY = axis[2] * ux - axis[0] * uz;
const perpZ = axis[0] * uy - axis[1] * ux;
// Rotate using Rodrigues' formula
const rotatedViewUp: [number, number, number] = [
ux * cos + perpX * sin + dot * axis[0],
uy * cos + perpY * sin + dot * axis[1],
uz * cos + perpZ * sin + dot * axis[2]
];
cam.setFocalPoint(...center);
cam.setViewUp(...rotatedViewUp);
}
cam.orthogonalizeViewUp();
renderWindow.render();
}, [dicomData]);
// --- Logic 3: Handle currentOrientation prop changes ---
useEffect(() => {
if (!contextRef.current || !currentOrientation || !active2DView) return;
// This handles 2D rotations triggered by the toolbar/external state
if (currentOrientation === "rotate-cw") {
rotate2DView(active2DView, "cw");
} else if (currentOrientation === "rotate-ccw") {
rotate2DView(active2DView, "ccw");
}
}, [currentOrientation, active2DView, rotate2DView]);
// --- Logic 4: Standard Medical View Orientations (2D Initialization) ---
useEffect(() => {
if (!contextRef.current || !dicomData?.imageData) return;
const { renderers, renderWindow } = contextRef.current;
const bounds = dicomData.imageData.getBounds();
const center = vtkBoundingBox.getCenter(bounds);
// Axial: Looking from head to toe, patient's right on left (Radiological)
const axialCam = renderers[0].getActiveCamera();
axialCam.setPosition(center[0], center[1], center[2] - 1);
axialCam.setFocalPoint(center[0], center[1], center[2]);
axialCam.setViewUp(0, -1, 0); // Inverted Y for radiological convention
// Coronal: Looking from front to back, patient's right on left
const coronalCam = renderers[1].getActiveCamera();
coronalCam.setPosition(center[0], center[1] - 1, center[2]);
coronalCam.setFocalPoint(center[0], center[1], center[2]);
coronalCam.setViewUp(0, 0, 1);
// Sagittal: Looking from left to right, anterior at top
const sagittalCam = renderers[2].getActiveCamera();
sagittalCam.setPosition(center[0] - 1, center[1], center[2]);
sagittalCam.setFocalPoint(center[0], center[1], center[2]);
sagittalCam.setViewUp(0, 0, 1);
renderWindow.render();
}, [dicomData]);
// --- Logic 5: Handle External Rotation Requests via Ref (Imperative) ---
useEffect(() => {
if (!onRotate3D || !contextRef.current) return;
const handleRotate = (orientation: ViewOrientation) => {
// Integrated Logic from Code 1: 2D Rotations
if (orientation === "rotate-cw" && active2DView) {
rotate2DView(active2DView, "cw");
} else if (orientation === "rotate-ccw" && active2DView) {
rotate2DView(active2DView, "ccw");
} else if (orientation === "axial" || orientation === "coronal" || orientation === "sagittal") {
// Reset rotation angle for that view if explicitly selected
rotationAnglesRef.current[orientation] = 0;
} else {
// Handle 3D rotations
rotate3DView(orientation);
}
};
// Store handler for external access
(contextRef.current as any).handleRotate = handleRotate;
(contextRef.current as any).rotate3D = rotate3DView;
}, [onRotate3D, rotate3DView, rotate2DView, active2DView]);
// --- Logic 6: Reset rotation when view changes ---
useEffect(() => {
if (active2DView) {
rotationAnglesRef.current[active2DView] = 0;
}
}, [active2DView]);
// --- Logic 7: Viewport Expansion Handling ---
useEffect(() => {
if (!contextRef.current) return;
const { renderers, containers, renderWindow, renderWindowView } = contextRef.current;
const viewports = getViewports(expandedView);
renderers.forEach((ren, i) => {
ren.setViewport(viewports[i][0], viewports[i][1], viewports[i][2], viewports[i][3]);
});
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
renderWindowView.setSize(rect.width, rect.height);
containers.forEach((c) => {
resizeViewportContainer(containerRef.current!, renderers[c.id], c.element);
});
}
// Re-fit every view to its new viewport so the slice / volume FILLS the resized pane.
// Without this an expanded pane keeps the small framing it had as a 2×2 quadrant
// (content stuck tiny in a corner). resetCamera preserves each camera's direction +
// view-up — so orientation and any 3D rotation are kept — and only re-centers + rescales.
renderers.forEach((ren) => ren.resetCamera());
renderWindow.render();
}, [expandedView, getViewports, resizeViewportContainer]);
// --- Logic 8: VTK Initialization ---
useEffect(() => {
if (!containerRef.current) return;
const renderWindow = vtkRenderWindow.newInstance();
const renderWindowView = vtkOpenGLRenderWindow.newInstance();
const rect = containerRef.current.getBoundingClientRect();
renderWindowView.setSize(rect.width, rect.height);
renderWindow.addView(renderWindowView);
renderWindowView.setContainer(containerRef.current);
const iStyle = vtkInteractorStyleImage.newInstance();
const tStyle = vtkInteractorStyleTrackballCamera.newInstance();
const interactor = vtkRenderWindowInteractor.newInstance();
interactor.setView(renderWindowView);
interactor.initialize();
interactor.setInteractorStyle(tStyle);
const renderers: any[] = [];
const containers: ViewportContainer[] = [];
const labels = ["AXIAL", "CORONAL", "SAGITTAL", "3D"];
// Create 4 viewports in a 2x2 grid
const viewports: [number, number, number, number][] = [
[0.01, 0.51, 0.49, 0.99], // top-left
[0.51, 0.51, 0.99, 0.99], // top-right
[0.01, 0.01, 0.49, 0.49], // bottom-left
[0.51, 0.01, 0.99, 0.49], // bottom-right (3D)
];
// Pin / unpin all VTK interaction to a single pane for the lifetime of a drag.
// VTK re-resolves the "poked" renderer on EVERY mouse-move (findPokedRenderer),
// and it skips renderers whose getInteractive() is false — so marking the other
// panes non-interactive while a drag is in flight keeps the gesture on the pane
// it started in (e.g. a 3D-pane rotate never leaks into axial/coronal/sagittal).
const confineInteractionTo = (activeId: number) => {
renderers.forEach((ren, idx) => ren.setInteractive(idx === activeId));
};
const releaseInteraction = () => {
renderers.forEach((ren) => ren.setInteractive(true));
};
for (let i = 0; i < 4; i++) {
// Black background for 2D views, dark gray for 3D
const bgColor: [number, number, number] = i === 3 ? [0.1, 0.1, 0.15] : [0, 0, 0];
const ren = vtkRenderer.newInstance({ background: bgColor });
ren.setViewport(viewports[i][0], viewports[i][1], viewports[i][2], viewports[i][3]);
const container = document.createElement("div");
container.dataset.viewId = String(i);
container.style.position = "absolute";
container.style.boxSizing = "border-box";
container.style.overflow = "hidden";
container.style.borderRadius = "6px";
containerRef.current.appendChild(container);
// Add label
const label = document.createElement("div");
label.textContent = labels[i];
label.style.position = "absolute";
label.style.top = "8px";
label.style.left = "8px";
label.style.color = "rgba(255, 255, 255, 0.7)";
label.style.fontSize = "11px";
label.style.fontWeight = "600";
label.style.letterSpacing = "0.05em";
label.style.textTransform = "uppercase";
label.style.zIndex = "10";
label.style.pointerEvents = "none";
label.style.textShadow = "0 1px 2px rgba(0,0,0,0.8)";
container.appendChild(label);
// Handle mouse down - lock interactor to this viewport
container.addEventListener("mousedown", (e) => {
isMouseDownRef.current = true;
activeViewportRef.current = container;
confineInteractionTo(Number(container.dataset.viewId));
bindInteractor(interactor, container);
// Toggle expand on double-click — detected HERE via e.detail===2 (the 2nd press of
// a double-click) rather than a "dblclick" listener, because VTK's interactor takes
// pointer capture on press, so the native click/dblclick land on the parent canvas
// container, NOT this per-pane div. mousedown still fires on the div, so this is the
// only place a per-pane double-click is observable.
if (e.detail === 2) {
const viewId = Number(container.dataset.viewId);
setExpandedView((prev) => (prev === viewId ? null : (viewId as ExpandedView)));
}
});
// Handle mouse up - unlock interactor
container.addEventListener("mouseup", () => {
isMouseDownRef.current = false;
activeViewportRef.current = null;
releaseInteraction();
});
// Handle mouse leave - only unbind if not dragging
container.addEventListener("mouseleave", () => {
if (!isMouseDownRef.current) {
bindInteractor(interactor, null);
}
});
// Handle pointer enter - only bind if not dragging and no active viewport
container.addEventListener("pointerenter", () => {
if (!isMouseDownRef.current && !activeViewportRef.current) {
bindInteractor(interactor, container);
}
});
// Handle pointer leave - only unbind if not dragging
container.addEventListener("pointerleave", () => {
if (!isMouseDownRef.current) {
bindInteractor(interactor, null);
}
});
renderWindow.addRenderer(ren);
renderers.push(ren);
containers.push({ element: container, id: i, label: labels[i] });
resizeViewportContainer(containerRef.current, ren, container);
}
// Handle global mouse up to catch releases outside viewports
const handleGlobalMouseUp = () => {
isMouseDownRef.current = false;
activeViewportRef.current = null;
releaseInteraction();
};
window.addEventListener("mouseup", handleGlobalMouseUp);
// Create slice mappers and planes
const slicePlanes: any[] = [];
const imageSliceActors: any[] = [];
// Axial plane (Z normal)
const axialPlane = vtkPlane.newInstance();
axialPlane.setNormal(0, 0, 1);
slicePlanes.push(axialPlane);
// Coronal plane (Y normal)
const coronalPlane = vtkPlane.newInstance();
coronalPlane.setNormal(0, 1, 0);
slicePlanes.push(coronalPlane);
// Sagittal plane (X normal)
const sagittalPlane = vtkPlane.newInstance();
sagittalPlane.setNormal(1, 0, 0);
slicePlanes.push(sagittalPlane);
// Create 2D slice actors (grayscale; window/level driven)
for (let i = 0; i < 3; i++) {
const mapper = vtkImageResliceMapper.newInstance();
mapper.setSlicePlane(slicePlanes[i]);
const actor = vtkImageSlice.newInstance();
actor.setMapper(mapper);
const sliceCtf = vtkColorTransferFunction.newInstance();
// Placeholder points; updated by window/level effect
sliceCtf.addRGBPoint(0, 0, 0, 0);
sliceCtf.addRGBPoint(1, 1, 1, 1);
actor.getProperty().setRGBTransferFunction(0, sliceCtf);
actor.getProperty().setColorWindow(windowWidth);
actor.getProperty().setColorLevel(windowLevel);
renderers[i].addActor(actor);
const cam = renderers[i].getActiveCamera();
cam.setParallelProjection(true);
if (i === 0) {
// Axial
cam.setPosition(0, 0, -1);
cam.setViewUp(0, -1, 0);
} else if (i === 1) {
// Coronal
cam.setPosition(0, -1, 0);
cam.setViewUp(0, 0, 1);
} else {
// Sagittal
cam.setPosition(-1, 0, 0);
cam.setViewUp(0, 0, 1);
}
imageSliceActors.push({ actor, mapper, ctf: sliceCtf });
}
// Volume actor for 3D view with color transfer function
const ctf = vtkColorTransferFunction.newInstance();
ctf.addRGBPoint(-1000, 0, 0, 0);
ctf.addRGBPoint(-100, 0.4, 0.2, 0.1);
ctf.addRGBPoint(0, 0.8, 0.6, 0.5);
ctf.addRGBPoint(100, 0.9, 0.8, 0.7);
ctf.addRGBPoint(400, 1, 1, 0.9);
ctf.addRGBPoint(1000, 1, 1, 1);
const pf = vtkPiecewiseFunction.newInstance();
pf.addPoint(-1000, 0.0);
pf.addPoint(-100, 0.0);
pf.addPoint(0, 0.1);
pf.addPoint(100, 0.3);
pf.addPoint(400, 0.6);
pf.addPoint(1000, 1.0);
const volumeActor = vtkVolume.newInstance();
const volumeMapper = vtkVolumeMapper.newInstance({ sampleDistance: 1.0 });
volumeActor.setMapper(volumeMapper);
volumeActor.getProperty().setRGBTransferFunction(0, ctf);
volumeActor.getProperty().setScalarOpacity(0, pf);
volumeActor.getProperty().setScalarOpacityUnitDistance(0, 3.0);
volumeActor.getProperty().setInterpolationTypeToLinear();
volumeActor.getProperty().setUseGradientOpacity(0, true);
volumeActor.getProperty().setGradientOpacityMinimumValue(0, 2);
volumeActor.getProperty().setGradientOpacityMinimumOpacity(0, 0.0);
volumeActor.getProperty().setGradientOpacityMaximumValue(0, 20);
volumeActor.getProperty().setGradientOpacityMaximumOpacity(0, 1.0);
volumeActor.getProperty().setShade(true);
volumeActor.getProperty().setAmbient(0.2);
volumeActor.getProperty().setDiffuse(0.7);
volumeActor.getProperty().setSpecular(0.3);
volumeActor.getProperty().setSpecularPower(8.0);
contextRef.current = {
renderWindow,
renderWindowView,
interactor,
renderers,
containers,
imageSliceActors,
slicePlanes,
volumeActor: { actor: volumeActor, mapper: volumeMapper },
ctf,
pf,
iStyle,
tStyle,
};
// Handle resize
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
window.removeEventListener("mouseup", handleGlobalMouseUp);
if (contextRef.current) {
containers.forEach((c) => c.element.remove());
imageSliceActors.forEach(({ actor, mapper, ctf: sliceCtf }) => {
actor.delete();
mapper.delete();
sliceCtf?.delete();
});
slicePlanes.forEach((p) => p.delete());
volumeActor.delete();
volumeMapper.delete();
ctf.delete();
pf.delete();
renderers.forEach((r) => r.delete());
interactor.delete();
renderWindowView.delete();
renderWindow.delete();
contextRef.current = null;
}
};
}, [bindInteractor, resize, resizeViewportContainer]);
// --- Logic 9: Window/Level Updates ---
useEffect(() => {
if (!contextRef.current) return;
const { imageSliceActors, volumeActor, ctf, pf, renderWindow } = contextRef.current;
const low = windowLevel - windowWidth / 2;
const high = windowLevel + windowWidth / 2;
// 2D: strict grayscale with black background
imageSliceActors.forEach(({ actor, ctf: sliceCtf }) => {
actor.getProperty().setColorWindow(windowWidth);
actor.getProperty().setColorLevel(windowLevel);
// Anything below low stays black; anything above high stays white
sliceCtf.removeAllPoints();
sliceCtf.addRGBPoint(low - 1, 0, 0, 0);
sliceCtf.addRGBPoint(low, 0, 0, 0);
sliceCtf.addRGBPoint(high, 1, 1, 1);
sliceCtf.addRGBPoint(high + 1, 1, 1, 1);
actor.getProperty().setRGBTransferFunction(0, sliceCtf);
});
// 3D: window/level driven (keeps opacity)
ctf.removeAllPoints();
ctf.addRGBPoint(low - 200, 0, 0, 0);
ctf.addRGBPoint(low, 0.4, 0.2, 0.1);
ctf.addRGBPoint(low + (high - low) * 0.3, 0.8, 0.6, 0.5);
ctf.addRGBPoint(low + (high - low) * 0.5, 0.9, 0.8, 0.7);
ctf.addRGBPoint(high, 1, 1, 0.9);
ctf.addRGBPoint(high + 200, 1, 1, 1);
pf.removeAllPoints();
pf.addPoint(low - 200, 0);
pf.addPoint(low, 0);
pf.addPoint(low + (high - low) * 0.2, opacity * 0.2);
pf.addPoint(low + (high - low) * 0.5, opacity * 0.5);
pf.addPoint(high, opacity);
volumeActor.actor.getProperty().setRGBTransferFunction(0, ctf);
volumeActor.actor.getProperty().setScalarOpacity(0, pf);
renderWindow.render();
}, [windowWidth, windowLevel, opacity]);
// --- Logic 10: Data Loading ---
useEffect(() => {
if (!dicomData?.imageData || !contextRef.current) return;
if (loadedDataRef.current === dicomData.imageData) return;
loadedDataRef.current = dicomData.imageData;
const { imageSliceActors, slicePlanes, volumeActor, renderers, renderWindow, ctf, pf } = contextRef.current;
const im = dicomData.imageData;
const bounds = im.getBounds();
// Set slice plane origins at center of volume
const centerX = (bounds[0] + bounds[1]) / 2;
const centerY = (bounds[2] + bounds[3]) / 2;
const centerZ = (bounds[4] + bounds[5]) / 2;
slicePlanes[0].setOrigin(centerX, centerY, centerZ); // Axial
slicePlanes[1].setOrigin(centerX, centerY, centerZ); // Coronal
slicePlanes[2].setOrigin(centerX, centerY, centerZ); // Sagittal
// Set input data for 2D slices
imageSliceActors.forEach(({ mapper }) => {
mapper.setInputData(im);
});
// Set input data for 3D volume
volumeActor.mapper.setInputData(im);
// Add volume to 3D renderer
renderers[3].removeAllActors();
renderers[3].removeAllVolumes();
renderers[3].addVolume(volumeActor.actor);
// Configure volume properties
const dataArray = im.getPointData().getScalars();
const dataRange = dataArray.getRange();
volumeActor.actor.getProperty().setScalarOpacityUnitDistance(
0,
(vtkBoundingBox.getDiagonalLength(bounds) ?? 0) / Math.max(...im.getDimensions())
);
volumeActor.actor.getProperty().setGradientOpacityMinimumValue(0, 0);
volumeActor.actor.getProperty().setGradientOpacityMaximumValue(0, (dataRange[1] - dataRange[0]) * 0.05);
volumeActor.actor.getProperty().setRGBTransferFunction(0, ctf);
volumeActor.actor.getProperty().setScalarOpacity(0, pf);
// Set 3D camera
const center = vtkBoundingBox.getCenter(bounds);
const cam3d = renderers[3].getActiveCamera();
cam3d.setPosition(center[0], center[1] - 500, center[2]);
cam3d.setFocalPoint(...center);
cam3d.setViewUp(0, 0, 1);
// Reset cameras
renderers.forEach((r) => r.resetCamera());
renderWindow.render();
}, [dicomData]);
return (
<div className="relative w-full h-full" ref={containerRef}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-muted-foreground">Loading DICOM data...</span>
</div>
</div>
)}
{!dicomData && !isLoading && (
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className="text-center p-8">
<div className="text-6xl mb-4 opacity-30">🩻</div>
<p className="text-muted-foreground">Upload DICOM files to view</p>
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,190 @@
import { useMemo } from "react";
import { QuadViewRenderer } from "./QuadViewRenderer";
import { NiftiQuadViewRenderer } from "./NiftiQuadViewRenderer";
import { useDicomData } from "./useDicomData";
import { useNiftiData } from "./useNiftiData";
// Updated import to include Active2DView
import type { ViewOrientation, Active2DView } from "./ViewRotationControls";
import type { BoundingBox, SegmentationMask } from "./types";
import type { OrganMaskData } from "./types";
import type { Annotation, AnnotationTool } from "./types";
export type FileFormat = "dicom" | "nifti" | null;
export interface UnifiedQuadViewRendererProps {
files: File[];
windowWidth: number;
windowLevel: number;
opacity: number;
isLoading?: boolean;
onRotate3D?: (orientation: ViewOrientation) => void;
active2DView?: Active2DView;
currentOrientation?: ViewOrientation;
// Segmentation props
segmentationEnabled?: boolean;
boundingBox?: BoundingBox | null;
onBoundingBoxChange?: (box: BoundingBox | null) => void;
currentSliceIndex?: number;
onSliceIndexChange?: (index: number) => void;
segmentationMask?: SegmentationMask | null;
// Organ mask overlays
organMasks?: OrganMaskData[];
// Client-side annotation tools (bbox / points / pen / brush / polygon)
annotationTool?: AnnotationTool;
annotations?: Annotation[];
onAnnotationsChange?: (annotations: Annotation[]) => void;
}
/**
* Detects file format based on file extensions
*/
const detectFileFormat = (files: File[]): FileFormat => {
if (files.length === 0) return null;
// Check if any file is NIfTI
const hasNifti = files.some(file => {
const name = file.name.toLowerCase();
return name.endsWith(".nii") || name.endsWith(".nii.gz");
});
if (hasNifti) {
// If there's a mix, prioritize NIfTI if it's a single file
if (files.length === 1) return "nifti";
// If multiple files and one is NIfTI, still treat as NIfTI (use first NIfTI file)
return "nifti";
}
// Check if files are DICOM
const hasDicom = files.some(file => {
const name = file.name.toLowerCase();
return name.endsWith(".dcm") || name.endsWith(".dicom");
});
if (hasDicom) return "dicom";
return null;
};
/**
* Unified Quad View Renderer that automatically switches between
* DICOM and NIfTI renderers based on uploaded file format
*/
export const UnifiedQuadViewRenderer = ({
files,
windowWidth,
windowLevel,
opacity,
isLoading: externalLoading = false,
onRotate3D,
active2DView,
currentOrientation,
// Segmentation props
segmentationEnabled = false,
boundingBox,
onBoundingBoxChange,
currentSliceIndex = 0,
onSliceIndexChange,
segmentationMask,
// Organ mask overlays
organMasks = [],
annotationTool = "none",
annotations = [],
onAnnotationsChange,
}: UnifiedQuadViewRendererProps) => {
// Detect file format
const fileFormat = useMemo(() => detectFileFormat(files), [files]);
// Get the first NIfTI file if format is NIfTI
// Use a more specific key to ensure changes are detected
const niftiFile = useMemo(() => {
if (fileFormat === "nifti") {
const niftiFile = files.find(file => {
const name = file.name.toLowerCase();
return name.endsWith(".nii") || name.endsWith(".nii.gz");
});
return niftiFile || null;
}
return null;
}, [files, fileFormat]);
// Get DICOM files if format is DICOM - filter out any NIfTI files that might be mixed in
const dicomFileObjects = useMemo(() => {
if (fileFormat === "dicom") {
return files.filter(file => {
const name = file.name.toLowerCase();
return name.endsWith(".dcm") || name.endsWith(".dicom");
});
}
return [];
}, [files, fileFormat]);
// Use appropriate hooks based on file format
const { dicomData, isLoading: isDicomLoading } = useDicomData(dicomFileObjects);
const { niftiData, isLoading: isNiftiLoading, error: niftiError } = useNiftiData(niftiFile);
const isLoading = externalLoading || (fileFormat === "dicom" ? isDicomLoading : isNiftiLoading);
// Render appropriate component based on file format
if (fileFormat === "nifti") {
return (
<>
<NiftiQuadViewRenderer
niftiData={niftiData}
windowWidth={windowWidth}
windowLevel={windowLevel}
opacity={opacity}
isLoading={isLoading}
onRotate3D={onRotate3D}
active2DView={active2DView}
currentOrientation={currentOrientation}
segmentationEnabled={segmentationEnabled}
boundingBox={boundingBox}
onBoundingBoxChange={onBoundingBoxChange}
currentSliceIndex={currentSliceIndex}
onSliceIndexChange={onSliceIndexChange}
segmentationMask={segmentationMask}
organMasks={organMasks}
annotationTool={annotationTool}
annotations={annotations}
onAnnotationsChange={onAnnotationsChange}
/>
{niftiError && (
<div className="absolute inset-0 flex items-center justify-center z-30 bg-background/90 backdrop-blur-sm">
<div className="text-center p-8 bg-destructive/10 border border-destructive rounded-lg">
<p className="text-destructive font-semibold">Error loading NIfTI file</p>
<p className="text-sm text-muted-foreground mt-2">{niftiError}</p>
</div>
</div>
)}
</>
);
}
if (fileFormat === "dicom") {
return (
<QuadViewRenderer
dicomData={dicomData}
windowWidth={windowWidth}
windowLevel={windowLevel}
opacity={opacity}
isLoading={isLoading}
onRotate3D={onRotate3D}
active2DView={active2DView}
currentOrientation={currentOrientation} // Pass it
/>
);
}
// No files or unknown format
return (
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className="text-center p-8">
<div className="text-6xl mb-4 opacity-30">🩻</div>
<p className="text-muted-foreground">Upload DICOM or NIfTI files to view</p>
<p className="text-xs text-muted-foreground mt-2">
Supported formats: .dcm, .dicom, .nii, .nii.gz
</p>
</div>
</div>
);
};
@@ -0,0 +1,129 @@
import { Button } from "../ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "../ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { RotateCw, RotateCcw, Navigation2 } from "lucide-react";
export type ViewOrientation =
| "axial"
| "coronal"
| "sagittal"
| "rotate-cw"
| "rotate-ccw"
| "anterior"
| "posterior"
| "left-lateral"
| "right-lateral"
| "superior"
| "inferior";
export type Active2DView = "axial" | "coronal" | "sagittal" | null;
interface ViewRotationControlsProps {
onRotate: (orientation: ViewOrientation) => void;
onSelectView?: (view: Active2DView) => void;
currentOrientation?: ViewOrientation;
activeView?: Active2DView;
}
const twoDViews: {
id: Active2DView;
label: string;
description: string;
}[] = [
{ id: "axial", label: "Axial", description: "Head to toe view" },
{ id: "coronal", label: "Coronal", description: "Front to back view" },
{ id: "sagittal", label: "Sagittal", description: "Left to right view" },
];
export const ViewRotationControls = ({
onRotate,
onSelectView,
currentOrientation,
activeView,
}: ViewRotationControlsProps) => {
return (
<div className="flex items-center gap-2">
{/* 2D View Selection Dropdown */}
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Navigation2 className="w-4 h-4" />
<span className="hidden sm:inline">
{activeView
? twoDViews.find(v => v.id === activeView)?.label
: "Select View"}
</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Select 2D View to Rotate</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>2D Views</DropdownMenuLabel>
{twoDViews.map((view) => (
<DropdownMenuItem
key={view.id}
onClick={() => {
onSelectView?.(view.id);
onRotate(view.id as ViewOrientation);
}}
className={activeView === view.id ? "bg-accent" : ""}
>
<div className="flex flex-col">
<span className="font-medium">{view.label}</span>
<span className="text-xs text-muted-foreground">
{view.description}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Clockwise Rotation Buttons */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => onRotate("rotate-ccw")}
disabled={!activeView}
className="gap-2"
>
<RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">CCW</span>
</Button>
</TooltipTrigger>
<TooltipContent>Rotate Counter-Clockwise</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => onRotate("rotate-cw")}
disabled={!activeView}
className="gap-2"
>
<RotateCw className="w-4 h-4" />
<span className="hidden sm:inline">CW</span>
</Button>
</TooltipTrigger>
<TooltipContent>Rotate Clockwise</TooltipContent>
</Tooltip>
</div>
);
};
+14
View File
@@ -0,0 +1,14 @@
/**
* Heavy viewer entry — pulls in @kitware/vtk.js + nifti-reader-js.
*
* Exposed as the `@ump/shared/viewer` subpath (NOT the main @ump/shared barrel)
* so VTK is code-split into an async chunk and never bloats a page's initial
* bundle. Import it through `React.lazy(() => import('@ump/shared/viewer'))`.
*/
export { UnifiedQuadViewRenderer } from './UnifiedQuadViewRenderer';
export type { UnifiedQuadViewRendererProps, FileFormat } from './UnifiedQuadViewRenderer';
export type { Annotation, AnnotationTool, AnnotationPoint } from './types';
// Organ-mask overlays: a non-hook NIfTI->vtkImageData loader + the OrganMaskData contract,
// so a host can build `organMasks` for the renderer outside React state.
export { loadNiftiImageData, parseNiftiBuffer, labelValues, extractBinaryLabel } from './niftiLoader';
export type { OrganMaskData, OrganName } from './types';
@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { distinctLabelValues, binaryLabelMask, isMultiLabel } from './labelMask';
describe('distinctLabelValues', () => {
it('returns sorted distinct non-zero integers, ignoring 0', () => {
expect(distinctLabelValues([0, 5, 1, 5, 0, 3, 1])).toEqual([1, 3, 5]);
});
it('ignores zero-only / empty volumes', () => {
expect(distinctLabelValues([0, 0, 0])).toEqual([]);
expect(distinctLabelValues([])).toEqual([]);
});
it('skips non-integer (continuous) values — those are images, not labels', () => {
expect(distinctLabelValues([0, 1.5, 2, 2.0, 3.7])).toEqual([2]);
});
it('works on a typed array (the real scalar buffer type)', () => {
expect(distinctLabelValues(new Uint8Array([0, 2, 2, 117, 1]))).toEqual([1, 2, 117]);
});
});
describe('binaryLabelMask', () => {
it('marks 1 only where the scalar equals the value', () => {
expect(Array.from(binaryLabelMask([0, 5, 1, 5, 3], 5))).toEqual([0, 1, 0, 1, 0]);
});
it('returns an all-zero mask for an absent value', () => {
expect(Array.from(binaryLabelMask([1, 2, 3], 9))).toEqual([0, 0, 0]);
});
it('preserves length and is Uint8', () => {
const m = binaryLabelMask([1, 2, 1], 1);
expect(m).toBeInstanceOf(Uint8Array);
expect(m.length).toBe(3);
});
});
describe('isMultiLabel', () => {
it('is true with ≥2 distinct organ labels, false otherwise', () => {
expect(isMultiLabel([0, 1, 2])).toBe(true);
expect(isMultiLabel([0, 1, 1, 0])).toBe(false);
expect(isMultiLabel([0, 0])).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* Multi-label segmentation helpers (pure — no VTK, no DOM, unit-testable).
*
* A single labelsTr/<case>.nii.gz can encode many organs as integer voxel values
* (e.g. TotalSegmentator: 1..117). These split such a volume's scalar array into the
* distinct label values present, and into a per-value binary (0/1) mask the renderer
* can extract as one coloured iso-surface. The VTK wrappers live in `niftiLoader`.
*/
/** Distinct non-zero integer label values present in a segmentation's scalar array, ascending. */
export function distinctLabelValues(scalars: ArrayLike<number>): number[] {
const seen = new Set<number>();
for (let i = 0; i < scalars.length; i++) {
const v = scalars[i];
if (v > 0 && Number.isInteger(v)) seen.add(v);
}
return [...seen].sort((a, b) => a - b);
}
/** A binary (0/1) Uint8Array mask: 1 where the scalar equals `value`, else 0. */
export function binaryLabelMask(scalars: ArrayLike<number>, value: number): Uint8Array {
const out = new Uint8Array(scalars.length);
for (let i = 0; i < scalars.length; i++) {
if (scalars[i] === value) out[i] = 1;
}
return out;
}
/** True when a segmentation holds more than one distinct organ label (so it is worth splitting). */
export function isMultiLabel(scalars: ArrayLike<number>): boolean {
return distinctLabelValues(scalars).length > 1;
}
@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
import {
orientationFromAffine,
isCanonicalOrientation,
applyCanonicalOrientation,
} from "./niftiLoader";
// Real affines measured from the two dev-stack files (via nibabel) — see the KiTS
// orientation investigation. ct.nii.gz (TotalSegmentator) is RAS; KIT23_00002_0000.nii.gz
// (RibFrac dataset) is ('I','P','L').
const RAS_AFFINE = [
[1.5, 0, 0, 0],
[0, 1.5, 0, 0],
[0, 0, 1.5, 0],
[0, 0, 0, 1],
];
const KIT23_IPL_AFFINE = [
[0, 0, -0.9395, 0],
[0, -0.9395, 0, 0],
[-1.0, 0, 0, 0],
[0, 0, 0, 1],
];
describe("orientationFromAffine", () => {
it("treats a clean RAS (diagonal, positive) affine as canonical — no reorder/flip", () => {
const o = orientationFromAffine(RAS_AFFINE)!;
expect(o.perm).toEqual([0, 1, 2]);
expect(o.flips).toEqual([false, false, false]);
expect(isCanonicalOrientation(o)).toBe(true);
});
it("maps the KiTS ('I','P','L') affine to a full axis reversal + flip-all", () => {
const o = orientationFromAffine(KIT23_IPL_AFFINE)!;
expect(o.perm).toEqual([2, 1, 0]);
expect(o.flips).toEqual([true, true, true]);
expect(isCanonicalOrientation(o)).toBe(false);
});
it("handles a pure permutation (axes swapped, no flip)", () => {
// array axis0→S, axis1→A, axis2→R, all positive
const o = orientationFromAffine([
[0, 0, 1, 0],
[0, 1, 0, 0],
[1, 0, 0, 0],
[0, 0, 0, 1],
])!;
expect(o.perm).toEqual([2, 1, 0]);
expect(o.flips).toEqual([false, false, false]);
});
it("handles a pure flip (in-order axes, one reversed)", () => {
const o = orientationFromAffine([
[1, 0, 0, 0],
[0, -1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1],
])!;
expect(o.perm).toEqual([0, 1, 2]);
expect(o.flips).toEqual([false, true, false]);
});
it("returns null for a missing / too-short / singular affine (safe fallback)", () => {
expect(orientationFromAffine(null)).toBeNull();
expect(orientationFromAffine([])).toBeNull();
expect(orientationFromAffine([[1, 0], [0, 1]])).toBeNull(); // < 3 rows
// singular: a zero column means an array axis maps nowhere
expect(
orientationFromAffine([
[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 0, 0],
[0, 0, 0, 1],
]),
).toBeNull();
});
});
describe("applyCanonicalOrientation", () => {
// A 2x3x4 volume (axis0 fastest-varying) filled with its own flat index, so a remapped
// voxel's value IS its original flat index — easy to assert the mapping.
const dims: [number, number, number] = [2, 3, 4];
const spacing: [number, number, number] = [1, 2, 3];
const data = new Float32Array(2 * 3 * 4);
for (let i = 0; i < data.length; i++) data[i] = i;
it("reverses + flips a KiTS-style volume to canonical, swapping dims and spacing", () => {
const o = { perm: [2, 1, 0] as [number, number, number], flips: [true, true, true] as [boolean, boolean, boolean] };
const r = applyCanonicalOrientation(data, dims, spacing, o);
expect(r.dims).toEqual([4, 3, 2]); // dims[perm]
expect(r.spacing).toEqual([3, 2, 1]); // spacing[perm]
expect(r.data.length).toBe(24);
// Full reversal: canonical (0,0,0) ← source far corner; canonical last ← source (0,0,0).
expect(r.data[0]).toBe(23);
expect(r.data[23]).toBe(0);
// Stepping the fastest output axis maps back along the (flipped) source axis 2.
expect(r.data[1]).toBe(17);
});
it("is a value-preserving no-op for the canonical orientation", () => {
const o = { perm: [0, 1, 2] as [number, number, number], flips: [false, false, false] as [boolean, boolean, boolean] };
const r = applyCanonicalOrientation(data, dims, spacing, o);
expect(r.dims).toEqual([2, 3, 4]);
expect(r.spacing).toEqual([1, 2, 3]);
expect(Array.from(r.data)).toEqual(Array.from(data));
});
});
+285
View File
@@ -0,0 +1,285 @@
import * as nifti from "nifti-reader-js";
import vtkImageData from "@kitware/vtk.js/Common/DataModel/ImageData";
import vtkDataArray from "@kitware/vtk.js/Common/Core/DataArray";
import type { NiftiData } from "./types";
import { binaryLabelMask, distinctLabelValues } from "./labelMask";
/**
* Anatomical reorientation to closest-canonical RAS+ — a JS port of nibabel's
* `as_closest_canonical`. The quad-view renderer assumes the voxel array is already in
* canonical order (axis0 = L→R, axis1 = P→A, axis2 = I→S); TotalSegmentator output is
* resampled to RAS so it renders correctly, but raw clinical data keeps its acquisition
* orientation (e.g. KiTS/RibFrac files are `('I','P','L')`) and would otherwise render with
* permuted/flipped axial/coronal/sagittal planes. We read the NIfTI affine and reorder + flip
* the array to RAS+ so EVERY file matches the renderer's assumption.
*/
export type CanonicalOrientation = {
/** perm[k] = the source array axis that becomes canonical axis k (0=R/X, 1=A/Y, 2=S/Z). */
perm: [number, number, number];
/** flips[k] = whether canonical axis k is reversed so +index points toward R / A / S. */
flips: [boolean, boolean, boolean];
};
/**
* Derive the RAS+ reorientation from a 4x4 NIfTI affine (`affine[worldRow][voxelCol]`, the
* convention nifti-reader-js builds from sform/qform). Greedy axis assignment, mirroring
* nibabel's `io_orientation`. Returns null when the affine is missing/singular so the caller
* safely falls back to the raw array order.
*/
export function orientationFromAffine(
affine: number[][] | undefined | null,
): CanonicalOrientation | null {
if (!affine || affine.length < 3) return null;
const m: number[][] = [];
for (let w = 0; w < 3; w++) {
const row = affine[w];
if (!row || row.length < 3) return null;
m.push([row[0], row[1], row[2]]);
}
const worldForInput = [-1, -1, -1];
const signForInput = [1, 1, 1];
const usedInput = [false, false, false];
const usedWorld = [false, false, false];
for (let pass = 0; pass < 3; pass++) {
let best = -1;
let bi = -1;
let bw = -1;
for (let w = 0; w < 3; w++) {
if (usedWorld[w]) continue;
for (let i = 0; i < 3; i++) {
if (usedInput[i]) continue;
const v = Math.abs(m[w][i]);
if (v > best) {
best = v;
bw = w;
bi = i;
}
}
}
if (bi < 0 || best === 0) return null; // singular / degenerate orientation
worldForInput[bi] = bw;
signForInput[bi] = m[bw][bi] < 0 ? -1 : 1;
usedInput[bi] = true;
usedWorld[bw] = true;
}
const perm: [number, number, number] = [0, 0, 0];
const flips: [boolean, boolean, boolean] = [false, false, false];
for (let i = 0; i < 3; i++) {
const w = worldForInput[i];
perm[w] = i;
flips[w] = signForInput[i] < 0;
}
return { perm, flips };
}
/** True when the orientation is already RAS+ (no reorder, no flip) — the canonical fast path. */
export function isCanonicalOrientation(o: CanonicalOrientation): boolean {
return (
o.perm[0] === 0 &&
o.perm[1] === 1 &&
o.perm[2] === 2 &&
!o.flips[0] &&
!o.flips[1] &&
!o.flips[2]
);
}
/**
* Reorder + flip a flat voxel array (axis0 fastest-varying) into RAS+ canonical order,
* returning the canonical data + dims + spacing. O(N) with incremental strided indexing
* (one read/write per voxel); only invoked for non-canonical files.
*/
export function applyCanonicalOrientation(
data: Float32Array,
dims: [number, number, number],
spacing: [number, number, number],
o: CanonicalOrientation,
): { data: Float32Array; dims: [number, number, number]; spacing: [number, number, number] } {
const inDims = dims;
const inStride = [1, inDims[0], inDims[0] * inDims[1]];
const outDims: [number, number, number] = [dims[o.perm[0]], dims[o.perm[1]], dims[o.perm[2]]];
const outSpacing: [number, number, number] = [
spacing[o.perm[0]],
spacing[o.perm[1]],
spacing[o.perm[2]],
];
const [m0, m1, m2] = outDims;
// Per output axis k: the input-flat step for o_k+1, and the base offset (o_k=0).
const step = [0, 0, 0];
const start = [0, 0, 0];
for (let k = 0; k < 3; k++) {
const a = o.perm[k];
step[k] = (o.flips[k] ? -1 : 1) * inStride[a];
start[k] = o.flips[k] ? (inDims[a] - 1) * inStride[a] : 0;
}
const base = start[0] + start[1] + start[2];
const out = new Float32Array(data.length);
let outFlat = 0;
let b2 = base;
for (let o2 = 0; o2 < m2; o2++) {
let b1 = b2;
for (let o1 = 0; o1 < m1; o1++) {
let inIdx = b1;
for (let o0 = 0; o0 < m0; o0++) {
out[outFlat++] = data[inIdx];
inIdx += step[0];
}
b1 += step[1];
}
b2 += step[2];
}
return { data: out, dims: outDims, spacing: outSpacing };
}
/**
* Parse a (possibly gzipped) NIfTI ArrayBuffer into a VTK ImageData + header/geometry.
*
* Pure + non-hook so it can be shared by BOTH the `useNiftiData` hook (the main image)
* and the organ-mask overlay loader (which loads N extra volumes outside React state).
* Throws on a non-NIfTI buffer. Mirrors the original in-hook conversion exactly
* (datatype switch + scl_slope/scl_inter scaling + dims/spacing).
*/
export function parseNiftiBuffer(input: ArrayBuffer): NiftiData {
let arrayBuffer: ArrayBuffer = input;
if (nifti.isCompressed(arrayBuffer)) {
arrayBuffer = nifti.decompress(arrayBuffer) as ArrayBuffer;
}
if (!nifti.isNIFTI(arrayBuffer)) {
throw new Error("Not a valid NIfTI file");
}
const header = nifti.readHeader(arrayBuffer);
if (!header) {
throw new Error("Could not read NIfTI header");
}
const imageBuffer = nifti.readImage(header, arrayBuffer);
if (!imageBuffer) {
throw new Error("Could not read NIfTI image data");
}
const dims = header.dims;
const nx = dims[1];
const ny = dims[2];
const nz = dims[3];
const pixDims = header.pixDims;
const sx = Math.abs(pixDims[1]) || 1;
const sy = Math.abs(pixDims[2]) || 1;
const sz = Math.abs(pixDims[3]) || 1;
let typedData: Float32Array | Int16Array | Uint16Array | Uint8Array | Int8Array;
switch (header.datatypeCode) {
case nifti.NIFTI1.TYPE_UINT8:
typedData = new Uint8Array(imageBuffer);
break;
case nifti.NIFTI1.TYPE_INT8:
typedData = new Int8Array(imageBuffer);
break;
case nifti.NIFTI1.TYPE_UINT16:
typedData = new Uint16Array(imageBuffer);
break;
case nifti.NIFTI1.TYPE_INT16:
typedData = new Int16Array(imageBuffer);
break;
case nifti.NIFTI1.TYPE_FLOAT32:
typedData = new Float32Array(imageBuffer);
break;
case nifti.NIFTI1.TYPE_FLOAT64: {
const float64 = new Float64Array(imageBuffer);
typedData = new Float32Array(float64);
break;
}
case nifti.NIFTI1.TYPE_INT32: {
const int32 = new Int32Array(imageBuffer);
typedData = new Float32Array(int32);
break;
}
default:
typedData = new Float32Array(imageBuffer);
}
const slope = header.scl_slope || 1;
const intercept = header.scl_inter || 0;
let scaledData: Float32Array;
if (slope !== 1 || intercept !== 0) {
scaledData = new Float32Array(typedData.length);
for (let i = 0; i < typedData.length; i++) {
scaledData[i] = typedData[i] * slope + intercept;
}
} else {
scaledData = typedData instanceof Float32Array ? typedData : new Float32Array(typedData);
}
// Reorient to closest-canonical RAS+ so a file in any acquisition orientation (e.g.
// KiTS/RibFrac = ('I','P','L')) renders with the axial/coronal/sagittal mapping the
// renderer assumes. Canonical files (TotalSegmentator = RAS) hit the no-op fast path.
let outData: Float32Array = scaledData;
let outDims: [number, number, number] = [nx, ny, nz];
let outSpacing: [number, number, number] = [sx, sy, sz];
const orientation = orientationFromAffine(header.affine);
if (orientation && !isCanonicalOrientation(orientation)) {
const reoriented = applyCanonicalOrientation(scaledData, [nx, ny, nz], [sx, sy, sz], orientation);
outData = reoriented.data;
outDims = reoriented.dims;
outSpacing = reoriented.spacing;
}
const imageData = vtkImageData.newInstance();
imageData.setDimensions(outDims);
imageData.setSpacing(outSpacing);
const dataArray = vtkDataArray.newInstance({
name: "Scalars",
numberOfComponents: 1,
values: outData,
});
imageData.getPointData().setScalars(dataArray);
return {
header: {
dims,
pixDims,
datatype: header.datatypeCode,
littleEndian: header.littleEndian,
voxOffset: header.vox_offset,
affine: header.affine || [],
description: header.description || "",
},
imageData,
rawData: arrayBuffer,
dimensions: outDims,
spacing: outSpacing,
};
}
/**
* Load a NIfTI File into a VTK ImageData — for organ-mask overlays (`OrganMaskData.imageData`).
* The mask must share the parent image's voxel grid (dims + spacing) to overlay correctly;
* no resampling/affine alignment is applied here.
*/
export async function loadNiftiImageData(file: File): Promise<NiftiData["imageData"]> {
const buffer = await file.arrayBuffer();
return parseNiftiBuffer(buffer).imageData;
}
/** Distinct non-zero integer label values present in a loaded segmentation volume. */
export function labelValues(imageData: NiftiData["imageData"]): number[] {
return distinctLabelValues(imageData.getPointData().getScalars().getData());
}
/**
* Extract ONE organ from a multi-label volume as a binary (0/1) vtkImageData that shares the
* source grid (dims / spacing / origin) — so the renderer's contour-at-0.5 + value-1 slice colour
* pick out exactly that organ. Lets a single labelsTr/<case>.nii.gz drive N per-organ overlays.
*/
export function extractBinaryLabel(imageData: NiftiData["imageData"], value: number): NiftiData["imageData"] {
const scalars = imageData.getPointData().getScalars().getData();
const out = vtkImageData.newInstance();
out.setDimensions(imageData.getDimensions());
out.setSpacing(imageData.getSpacing());
out.setOrigin(imageData.getOrigin());
out.getPointData().setScalars(
vtkDataArray.newInstance({ name: "Scalars", numberOfComponents: 1, values: binaryLabelMask(scalars, value) }),
);
return out;
}
+92
View File
@@ -0,0 +1,92 @@
export interface NiftiHeader {
dims: number[];
pixDims: number[];
datatype: number;
littleEndian: boolean;
voxOffset: number;
affine: number[][];
description: string;
}
export interface NiftiData {
header: NiftiHeader;
imageData: any; // vtkImageData
rawData: ArrayBuffer;
dimensions: [number, number, number];
spacing: [number, number, number];
}
export interface NiftiViewerProps {
file: File | null;
windowWidth?: number;
windowLevel?: number;
opacity?: number;
}
// ---------------------------------------------------------------------------
// Types extracted from the segmentation / organ-mask modules that we did NOT
// port (SegmentationTool, OrganSelector, useOrganMasks). The NIfTI renderer
// references these only as `import type`, and the viewer is wired with those
// features disabled (segmentation needs an /api/medsam2/predict backend; organ
// overlays need a mask source — neither exists in ImageHub yet). Kept here as
// standalone aliases so the render path typechecks without the dropped modules.
// ---------------------------------------------------------------------------
export interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
sliceIndex: number;
viewType: "axial" | "coronal" | "sagittal";
}
export interface SegmentationMask {
viewType: "axial" | "coronal" | "sagittal";
sliceIndex: number;
// Normalized coordinates (0-1) for the mask polygon points
polygonPoints?: Array<{ x: number; y: number }>;
// Or a base64-encoded mask image
maskImageData?: string;
// Bounding box of the mask (normalized 0-1)
bounds: { x: number; y: number; width: number; height: number };
// Confidence score
confidence?: number;
}
// Originally a union of organ names from OrganSelector; we only consume this
// type (never construct organ masks), so a string alias is sufficient.
export type OrganName = string;
export interface OrganMaskData {
/** Stable unique id (e.g. the mask file id). The renderer keys overlays by this so two
* organs that share a display label don't collapse into one. Falls back to organName. */
id?: string;
organName: OrganName;
imageData: any; // vtkImageData
color: [number, number, number];
}
// ---------------------------------------------------------------------------
// Client-side annotation model (toolbar tools: bbox / points / pen / brush /
// polygon). Geometry is stored in NORMALIZED [0..1] coordinates within a single
// 2D pane, tagged with that pane's view + slice index so an annotation only
// shows on the slice it was drawn on. `bbox` uses [topLeft, bottomRight];
// the freehand/multi-vertex tools store an ordered list of vertices.
// ---------------------------------------------------------------------------
export type AnnotationTool = 'none' | 'bbox' | 'points' | 'pen' | 'brush' | 'polygon';
export interface AnnotationPoint {
x: number;
y: number;
}
export interface Annotation {
id: string;
view: 'axial' | 'coronal' | 'sagittal';
sliceIndex: number;
tool: Exclude<AnnotationTool, 'none'>;
points: AnnotationPoint[];
color: string;
strokeWidth?: number;
label?: string;
}
@@ -0,0 +1,219 @@
import { useState, useCallback, useRef, useEffect } from "react";
import vtkImageData from "@kitware/vtk.js/Common/DataModel/ImageData";
import vtkDataArray from "@kitware/vtk.js/Common/Core/DataArray";
interface ParsedSlice {
data: Uint8Array | Int16Array;
width: number;
height: number;
bitsAllocated: 8 | 16;
}
interface DicomData {
imageData: any;
slices: ParsedSlice[];
width: number;
height: number;
depth: number;
}
// Simple DICOM parser for raw pixel data
const parseDicomBuffer = (buffer: Uint8Array): ParsedSlice | null => {
try {
const hasDicmPrefix =
buffer.length > 132 &&
buffer[128] === 68 &&
buffer[129] === 73 &&
buffer[130] === 67 &&
buffer[131] === 77;
let offset = hasDicmPrefix ? 132 : 0;
let width = 512;
let height = 512;
let bitsAllocated: 8 | 16 = 16;
let pixelDataOffset = -1;
let pixelDataLength = 0;
while (offset < buffer.length - 8) {
const group = buffer[offset] | (buffer[offset + 1] << 8);
const element = buffer[offset + 2] | (buffer[offset + 3] << 8);
offset += 4;
let length: number;
const vr = String.fromCharCode(buffer[offset], buffer[offset + 1]);
if (["OB", "OW", "OF", "SQ", "UC", "UN", "UR", "UT"].includes(vr)) {
offset += 4;
length =
buffer[offset] |
(buffer[offset + 1] << 8) |
(buffer[offset + 2] << 16) |
(buffer[offset + 3] << 24);
offset += 4;
} else if (vr.match(/[A-Z]{2}/)) {
offset += 2;
length = buffer[offset] | (buffer[offset + 1] << 8);
offset += 2;
} else {
length =
buffer[offset - 4] |
(buffer[offset - 3] << 8) |
(buffer[offset - 2] << 16) |
(buffer[offset - 1] << 24);
}
if (group === 0x0028 && element === 0x0010) {
height = buffer[offset] | (buffer[offset + 1] << 8);
} else if (group === 0x0028 && element === 0x0011) {
width = buffer[offset] | (buffer[offset + 1] << 8);
} else if (group === 0x0028 && element === 0x0100) {
const v = buffer[offset] | (buffer[offset + 1] << 8);
bitsAllocated = v === 8 ? 8 : 16;
} else if (group === 0x7fe0 && element === 0x0010) {
pixelDataOffset = offset;
pixelDataLength = length;
break;
}
if (length === 0xffffffff) break;
offset += length;
}
if (pixelDataOffset === -1) {
const expectedSize = width * height * (bitsAllocated / 8);
if (buffer.length >= expectedSize) {
pixelDataOffset = buffer.length - expectedSize;
pixelDataLength = expectedSize;
} else {
return null;
}
}
if (bitsAllocated === 16) {
// Interpret as SIGNED Int16 (typical CT storage), keep raw values for window/level
const pixelBuffer = buffer.slice(pixelDataOffset, pixelDataOffset + pixelDataLength);
const out = new Int16Array(width * height);
for (let i = 0; i < width * height; i++) {
const low = pixelBuffer[i * 2] ?? 0;
const high = pixelBuffer[i * 2 + 1] ?? 0;
let value = low | (high << 8);
if (value & 0x8000) value = value - 0x10000; // signed
out[i] = value;
}
return { data: out, width, height, bitsAllocated: 16 };
}
// 8-bit fallback
const pixelBuffer = buffer.slice(
pixelDataOffset,
pixelDataOffset + Math.min(pixelDataLength, width * height)
);
const out = new Uint8Array(width * height);
out.set(pixelBuffer.subarray(0, out.length));
return { data: out, width, height, bitsAllocated: 8 };
} catch (error) {
console.error("Error parsing DICOM:", error);
return null;
}
};
export const useDicomData = (dicomFiles: File[]) => {
const [dicomData, setDicomData] = useState<DicomData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastFilesRef = useRef<string>("");
const loadDicomFiles = useCallback(async (files: File[]) => {
if (files.length === 0) {
setDicomData(null);
return;
}
// Create a unique key from file names and sizes to detect changes
const filesKey = files
.map((f) => `${f.name}-${f.size}-${f.lastModified}`)
.sort()
.join("|");
// Skip if we already loaded these files
if (filesKey === lastFilesRef.current && dicomData) {
return;
}
lastFilesRef.current = filesKey;
setIsLoading(true);
setError(null);
try {
const sortedFiles = [...files].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true })
);
const slices: ParsedSlice[] = [];
for (const file of sortedFiles) {
const arrayBuffer = await file.arrayBuffer();
const parsed = parseDicomBuffer(new Uint8Array(arrayBuffer));
if (parsed) {
slices.push(parsed);
}
}
if (slices.length === 0) {
setError("No valid DICOM data found");
setIsLoading(false);
return;
}
const width = slices[0].width;
const height = slices[0].height;
const depth = slices.length;
const is16Bit = slices[0].bitsAllocated === 16;
const voxelCount = width * height * depth;
const volumeData = is16Bit ? new Int16Array(voxelCount) : new Uint8Array(voxelCount);
for (let z = 0; z < depth; z++) {
const slice = slices[z];
const sliceOffset = z * width * height;
if (is16Bit) {
(volumeData as Int16Array).set(slice.data as Int16Array, sliceOffset);
} else {
(volumeData as Uint8Array).set(slice.data as Uint8Array, sliceOffset);
}
}
const imageData = vtkImageData.newInstance();
imageData.setDimensions(width, height, depth);
imageData.setSpacing([1.0, 1.0, 1.0]);
const scalars = vtkDataArray.newInstance({
name: "Scalars",
numberOfComponents: 1,
values: volumeData as any,
});
imageData.getPointData().setScalars(scalars);
setDicomData({
imageData,
slices,
width,
height,
depth,
});
} catch (err) {
console.error("Error loading DICOM files:", err);
setError("Error loading DICOM files");
} finally {
setIsLoading(false);
}
}, [dicomData]);
useEffect(() => {
loadDicomFiles(dicomFiles);
}, [dicomFiles, loadDicomFiles]);
return { dicomData, isLoading, error };
};
@@ -0,0 +1,49 @@
import { useState, useEffect, useRef } from "react";
import type { NiftiData } from "./types";
import { parseNiftiBuffer } from "./niftiLoader";
export function useNiftiData(file: File | null) {
const [niftiData, setNiftiData] = useState<NiftiData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const lastFileRef = useRef<string>("");
useEffect(() => {
if (!file) {
setNiftiData(null);
lastFileRef.current = "";
return;
}
// Create a unique identifier for the file to detect changes
// Use name, size, and lastModified to ensure we detect different files
const fileKey = `${file.name}-${file.size}-${file.lastModified}`;
// Skip if we already loaded this exact file
if (fileKey === lastFileRef.current && niftiData) {
return;
}
lastFileRef.current = fileKey;
const loadNifti = async () => {
setIsLoading(true);
setError(null);
try {
const arrayBuffer = await file.arrayBuffer();
setNiftiData(parseNiftiBuffer(arrayBuffer));
} catch (err) {
console.error("Error loading NIfTI:", err);
setError(err instanceof Error ? err.message : "Failed to load NIfTI file");
setNiftiData(null);
} finally {
setIsLoading(false);
}
};
loadNifti();
}, [file, niftiData]);
return { niftiData, isLoading, error };
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Environment configuration
* Centralized environment variable management with type safety.
* (Ported verbatim from fe0/src/shared/config/env.ts.)
*/
interface EnvConfig {
API_URL: string;
WS_URL?: string;
ENVIRONMENT: 'development' | 'staging' | 'production';
ENABLE_DEV_TOOLS: boolean;
APP_NAME: string;
APP_VERSION: string;
}
const getEnvVar = (key: string, defaultValue?: string): string => {
const value = import.meta.env[key];
if (!value && defaultValue === undefined) {
if (import.meta.env.DEV) {
console.warn(`Missing environment variable: ${key}. Using default: undefined`);
}
}
return value || defaultValue || '';
};
const getBooleanEnvVar = (key: string, defaultValue = false): boolean => {
const value = getEnvVar(key, String(defaultValue));
return value === 'true' || value === '1';
};
/**
* Base URL for API calls. In dev, default is "" (same origin) so requests hit `/api/...` and the
* Vite dev proxy forwards to the backend — avoids CORS and fixes Docker (browser → :PORT, proxy → be0:4402).
* Set `VITE_API_URL` explicitly (e.g. `http://localhost:4402`) if you want the browser to call the API host directly.
*/
function resolveApiUrl(): string {
const raw = import.meta.env.VITE_API_URL as string | undefined;
if (raw != null && String(raw).trim() !== '') {
return String(raw).replace(/\/$/, '');
}
// Same-origin: Vite dev proxy (development) or nginx → be0 (production).
return '';
}
export const env: EnvConfig = {
API_URL: resolveApiUrl(),
WS_URL: getEnvVar('VITE_WS_URL', ''),
ENVIRONMENT: getEnvVar('VITE_ENV', 'development') as EnvConfig['ENVIRONMENT'],
ENABLE_DEV_TOOLS: getBooleanEnvVar('VITE_ENABLE_DEV_TOOLS', false),
APP_NAME: getEnvVar('VITE_APP_NAME', 'UMP Initiative'),
APP_VERSION: getEnvVar('VITE_APP_VERSION', '1.0.0'),
};
export const isDevelopment = env.ENVIRONMENT === 'development';
export const isProduction = env.ENVIRONMENT === 'production';
export const isStaging = env.ENVIRONMENT === 'staging';
+45
View File
@@ -0,0 +1,45 @@
/** Đơn vị trực thuộc ĐHYD — shared by admin/council filters and applicant “Nơi công tác” dropdown. */
export type DepartmentOption = { value: string; label: string };
export const DEPARTMENT_OPTIONS: DepartmentOption[] = [
{ value: 'doan_thanh_nien_hoi_sinh_vien', label: 'Đoàn Thanh niên - Hội Sinh viên' },
{ value: 'phong_hanh_chinh_tong_hop', label: 'Phòng Hành chính Tổng hợp' },
{ value: 'phong_hop_tac_quoc_te', label: 'Phòng Hợp tác Quốc tế' },
{ value: 'phong_cong_tac_sinh_vien', label: 'Phòng Công tác Sinh viên' },
{ value: 'phong_khoa_hoc_cong_nghe', label: 'Phòng Khoa học Công nghệ' },
{ value: 'phong_thanh_tra_phap_che', label: 'Phòng Thanh tra - Pháp chế' },
{ value: 'phong_to_chuc_can_bo', label: 'Phòng Tổ chức Cán bộ' },
{ value: 'phong_dao_tao_dai_hoc', label: 'Phòng Đào tạo Đại học' },
{ value: 'phong_dao_tao_sau_dai_hoc', label: 'Phòng Đào tạo Sau đại học' },
{ value: 'phong_quan_tri_giao_tai', label: 'Phòng Quản trị Giáo tài' },
{ value: 'phong_dam_bao_clgd_kt', label: 'Phòng Đảm bảo CLGD & KT' },
{ value: 'phong_ke_hoach_tai_chinh', label: 'Phòng Kế hoạch Tài chính' },
{ value: 'truong_y', label: 'Trường Y' },
{ value: 'truong_duoc', label: 'Trường Dược' },
{ value: 'truong_dieu_duong_ky_thuat_y_hoc', label: 'Trường Điều dưỡng Kỹ thuật Y học' },
{ value: 'khoa_rang_ham_mat', label: 'Khoa Răng Hàm Mặt' },
{ value: 'khoa_y_te_cong_cong', label: 'Khoa Y tế Công cộng' },
{ value: 'khoa_y_hoc_co_truyen', label: 'Khoa Y học Cổ truyền' },
{ value: 'khoa_khoa_hoc_co_ban', label: 'Khoa Khoa học Cơ bản' },
{ value: 'umc_cs1', label: 'UMC - CS1' },
{ value: 'umc_cs2', label: 'UMC CS2' },
{ value: 'umc_cs3', label: 'UMC CS3' },
{ value: 'phong_kham_chuyen_khoa_rhm', label: 'Phòng khám chuyên khoa RHM' },
{ value: 'tt_kiem_chuan_chat_luong_xn_yh', label: 'TT Kiểm chuẩn Chất lượng XN YH' },
{ value: 'tt_phau_thuat_thuc_nghiem', label: 'TT Phẫu thuật Thực nghiệm' },
{ value: 'tt_dao_tao_nhan_luc_y_te_theo_ncxh', label: 'TT Đào tạo Nhân lực Y tế theo NCXH' },
{ value: 'tt_cong_nghe_thong_tin', label: 'TT Công nghệ Thông tin' },
{ value: 'tt_khoa_hoc_cong_nghe_ump', label: 'TT Khoa học Công nghệ UMP' },
{ value: 'tt_giao_duc_y_hoc', label: 'TT Giáo dục Y học' },
{ value: 'tt_y_sinh_hoc_phan_tu', label: 'TT Y sinh học Phân tử' },
{ value: 'thu_vien', label: 'Thư viện' },
{ value: 'ky_tuc_xa', label: 'Ký túc xá' },
{ value: 'tap_chi_y_hoc_tphcm', label: 'Tạp chí Y học TP.HCM (MedPharmRes)' },
];
const LABEL_SET = new Set(DEPARTMENT_OPTIONS.map((o) => o.label));
export function isKnownDepartmentWorkplace(workplace: string): boolean {
return LABEL_SET.has(workplace);
}
+79
View File
@@ -0,0 +1,79 @@
/** Public surface of @ump/shared — the kernel both frontends import. */
export * from './config/env';
export * from './utils/token';
export * from './lib/permissions';
export * from './lib/utils';
export * from './api/client';
export * from './auth/AuthProvider';
export * from './auth/institutionalEmail';
export * from './auth/authOperations';
export * from './auth/LoginRegisterCard';
export * from './auth/ForgotPasswordPage';
export * from './auth/ResetPasswordPage';
export * from './auth/RegistrationWithOtp';
// Initiative draft data layer (slice 1 + slice 3 widened: types + form-state + persistence + helpers).
export * from './initiative/DraftContext';
export * from './initiative/applicationDrafts';
export * from './initiative/applicantDraftSessionUtils';
export * from './initiative/reviewTabs';
export * from './initiative/types';
export * from './initiative/initiativeFormTypes';
export * from './initiative/reportContentSummaryMerge';
export * from './initiative/reportApplicationFieldMirror';
export * from './initiative/reportAuthorEvaluationMerge';
export * from './initiative/applicationAuthorsContributionTable';
export * from './initiative/contributionRepresentativeAuthorSync';
export * from './initiative/applicantPrefill';
export * from './initiative/bieuMauTemplate';
export { getFile, putFile } from './initiative/serializers';
export * from './initiative/contributionDraftTypes';
export * from './initiative/mergeContributionWorkUnits';
export * from './initiative/mapDraftToOfficialBieuMau';
export * from './initiative/buildInitiativeDraftFromReviewTabs';
export * from './initiative/ApplicationFormOfficialPdfActions';
export { default as DocxToPdfViewer } from './initiative/DocxToPdfViewer';
export * from './lib/applicantRegistrationSession';
export * from './lib/vnDateFormat';
export * from './lib/evidenceUploadLimits';
export * from './lib/applicantHonestyPrerequisites';
export * from './lib/networkTimeout';
export * from './lib/applicationFormDocxApi';
export * from './lib/officialFormPdfFileName';
export * from './lib/reviewDocumentApi';
export * from './lib/templateApi';
export * from './lib/researchApi';
export * from './lib/imagehubApi';
export * from './lib/imagehubViewer';
export * from './data/departmentOptions';
// Shared shadcn/ui primitives.
export * from './components/ui/button';
export * from './components/ui/input';
export * from './components/ui/label';
export * from './components/ui/card';
export * from './components/ui/alert';
export * from './components/ui/separator';
export * from './components/ui/select';
export * from './components/ui/sonner';
export * from './components/ui/tabs';
export * from './components/ui/resizable';
export * from './components/ui/textarea';
export * from './components/ui/table';
export * from './components/ui/badge';
export * from './components/ui/checkbox';
export * from './components/ui/dropdown-menu';
export * from './components/ui/switch';
export * from './components/ui/radio-group';
export * from './components/ui/popover';
export * from './components/ui/calendar';
export * from './components/ui/dialog';
export * from './components/ui/alert-dialog';
export * from './components/ui/command';
export * from './components/ui/sheet';
export * from './components/ui/scroll-area';
export * from './components/ui/skeleton';
export * from './components/ui/tooltip';
// Profile (staff verification badge — admin user management reuses it).
export { ProfileVerificationBadge } from './profile/ProfileVerificationBadge';
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '../components/ui/button';
import type { InitiativeDraft } from './types';
import type { ContributionDraftShape } from './contributionDraftTypes';
import {
createOfficialFormPdfFromDrafts,
downloadPdfBlob,
} from './applicationFormOfficialPdf';
import {
EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS,
type OfficialFormPreviewFields,
} from './officialFormPreviewFieldEdits';
import { OfficialFormPdfPreviewDialog } from './OfficialFormPdfPreviewDialog';
export type ApplicationFormOfficialPdfActionsProps = {
draft: InitiativeDraft;
contribution: ContributionDraftShape;
caseId?: string;
disabled?: boolean;
className?: string;
};
/**
* Preview (modal) + direct download for the official application-form PDF built in the browser.
*/
export function ApplicationFormOfficialPdfActions({
draft,
contribution,
caseId,
disabled = false,
className,
}: ApplicationFormOfficialPdfActionsProps) {
const [previewOpen, setPreviewOpen] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [pdfFileName, setPdfFileName] = useState('');
const [previewFields, setPreviewFields] = useState<OfficialFormPreviewFields>(EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS);
const [pdfSourceNote, setPdfSourceNote] = useState<string | null>(null);
const [downloadLoading, setDownloadLoading] = useState(false);
const handlePreviewOpen = () => {
setPdfBlob(null);
setPdfFileName('');
setPreviewFields(EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS);
setPdfSourceNote(null);
setPreviewOpen(true);
};
const handlePreview = async () => {
if (previewLoading || disabled) return;
setPreviewLoading(true);
handlePreviewOpen();
try {
const { pdfBlob: blob, fileName, source, initialPreviewFields } = await createOfficialFormPdfFromDrafts(
draft,
contribution,
);
setPdfBlob(blob);
setPdfFileName(fileName);
setPreviewFields(initialPreviewFields);
setPdfSourceNote(
source === 'client'
? 'Bản xem trước: PDF tạo trong trình duyệt (docx-preview + canvas). Chỉnh sửa trường dữ liệu trong hộp thoại chỉ áp dụng cục bộ, không lưu lên máy chủ.'
: 'Bản xem trước: PDF từ máy chủ (LibreOffice), cùng luồng với tệp official-form lưu ở MinIO khi nộp hồ sơ. Chỉnh sửa trường dữ liệu chỉ áp dụng cục bộ.',
);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Không xuất được PDF mẫu hồ sơ.';
toast.error(msg);
setPreviewOpen(false);
} finally {
setPreviewLoading(false);
}
};
const handleDownload = async () => {
if (downloadLoading || disabled) return;
setDownloadLoading(true);
try {
const { pdfBlob: blob, fileName, source } = await createOfficialFormPdfFromDrafts(
draft,
contribution,
);
downloadPdfBlob(blob, fileName);
toast.success(
source === 'server'
? 'Đã tải PDF mẫu hồ sơ (LibreOffice, đồng bộ với máy chủ / MinIO).'
: 'Đã tải PDF mẫu hồ sơ (trình duyệt — dự phòng khi máy chủ chưa cấu hình LibreOffice).',
);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Không xuất được PDF mẫu hồ sơ.';
toast.error(msg);
} finally {
setDownloadLoading(false);
}
};
const busy = previewLoading || downloadLoading;
return (
<>
<div className={className ?? 'flex flex-wrap gap-2'}>
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => void handlePreview()}
disabled={disabled || busy}
>
{previewLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin inline" aria-hidden />
Đang tạo PDF
</>
) : (
'Xem trước PDF'
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() => void handleDownload()}
disabled={disabled || busy}
>
{downloadLoading ? (
<>
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin inline" aria-hidden />
Đang tải PDF
</>
) : (
'Xuất PDF (mẫu Word)'
)}
</Button>
</div>
<OfficialFormPdfPreviewDialog
open={previewOpen}
onOpenChange={(open) => {
setPreviewOpen(open);
if (!open) {
setPdfBlob(null);
setPdfFileName('');
setPreviewFields(EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS);
setPdfSourceNote(null);
}
}}
pdfBlob={pdfBlob}
fileName={pdfFileName}
initialPreviewFields={previewFields}
loading={previewLoading}
sourceNote={pdfSourceNote ?? undefined}
caseId={caseId}
/>
</>
);
}
+446
View File
@@ -0,0 +1,446 @@
/**
* Paginated DOCX preview (docx-preview) + per-page raster PDF (html2canvas + jsPDF).
* See docs/PDF_converter.md.
*/
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
type DragEvent,
} from 'react';
import {
convertDocxToPdfBlob,
type ConvertDocxToPdfLayoutInsights,
createOffScreenDocxCaptureHost,
DOCX_PDF_PREVIEW_SHELL_MAX_WIDTH_PX,
} from './convertDocxToPdfBlob';
import { convertUploadedDocxToOfficialPdf } from '../lib/applicationFormDocxApi';
export type ConverterStatus =
| 'idle'
| 'rendering'
| 'capturing'
| 'ready'
| 'error';
export interface DocxToPdfViewerProps {
file?: File | null;
hideFilePicker?: boolean;
hidePreview?: boolean;
onPdfReady?: (pdfBlob: Blob, sourceFile: File) => void;
onStatusChange?: (status: ConverterStatus) => void;
renderScale?: number;
imageQuality?: number;
losslessImages?: boolean;
/**
* Try server-side LibreOffice conversion (`/api/v1/docx/convert-pdf`) for
* Word-faithful output, then fall back to browser conversion on failure.
*/
preferOfficialServerPdf?: boolean;
className?: string;
style?: CSSProperties;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
export default function DocxToPdfViewer({
file: externalFile,
hideFilePicker = false,
hidePreview = false,
onPdfReady,
onStatusChange,
renderScale = 2,
imageQuality = 0.95,
losslessImages = false,
preferOfficialServerPdf = true,
className,
style,
}: DocxToPdfViewerProps) {
const [file, setFile] = useState<File | null>(externalFile ?? null);
const [status, setStatusInternal] = useState<ConverterStatus>('idle');
const [pageProgress, setPageProgress] = useState({ current: 0, total: 0 });
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [serverFixJustifiedSoftBreaks, setServerFixJustifiedSoftBreaks] = useState(true);
const [serverFixTableRowHeights, setServerFixTableRowHeights] = useState(false);
const [pdfSource, setPdfSource] = useState<'server' | 'browser' | null>(null);
const [layoutInsights, setLayoutInsights] = useState<ConvertDocxToPdfLayoutInsights | null>(null);
/** Only the latest {@link convert} run may commit blob/status — avoids stale PDF after a newer docx is on screen. */
const convertGenerationRef = useRef(0);
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [pdfReady, setPdfReady] = useState(false);
const [pdfPreviewUrl, setPdfPreviewUrl] = useState<string | null>(null);
const setStatus = useCallback(
(next: ConverterStatus) => {
setStatusInternal(next);
onStatusChange?.(next);
},
[onStatusChange],
);
useEffect(() => {
if (externalFile !== undefined) setFile(externalFile);
}, [externalFile]);
useEffect(() => {
if (!pdfBlob) {
setPdfPreviewUrl(null);
return;
}
const nextUrl = URL.createObjectURL(pdfBlob);
setPdfPreviewUrl(nextUrl);
return () => URL.revokeObjectURL(nextUrl);
}, [pdfBlob]);
const convert = useCallback(
async (source: File) => {
const tempHost = createOffScreenDocxCaptureHost();
const container: HTMLElement = tempHost;
const gen = ++convertGenerationRef.current;
setError(null);
setPdfBlob(null);
setPdfReady(false);
setPdfSource(null);
setLayoutInsights(null);
setPageProgress({ current: 0, total: 0 });
try {
const serverPdfPromise = preferOfficialServerPdf
? convertUploadedDocxToOfficialPdf(source, {
relaxJustifiedSoftBreaks: serverFixJustifiedSoftBreaks,
stripTableRowHeights: serverFixTableRowHeights,
})
.then((buffer) => new Blob([buffer], { type: 'application/pdf' }))
.catch(() => null)
: Promise.resolve<Blob | null>(null);
const browserBlob = await convertDocxToPdfBlob(source, container, {
renderScale,
imageQuality,
losslessImages,
onPhaseRendering: () => {
if (gen !== convertGenerationRef.current) return;
setStatus('rendering');
},
onPhaseCapturing: (pageCount) => {
if (gen !== convertGenerationRef.current) return;
setStatus('capturing');
setPageProgress({ current: 0, total: pageCount });
},
onCaptureProgress: (current, total) => {
if (gen !== convertGenerationRef.current) return;
setPageProgress({ current, total });
},
onLayoutAnalysed: (insights) => {
if (gen !== convertGenerationRef.current) return;
setLayoutInsights(insights);
},
});
const serverBlob = await serverPdfPromise;
const blob = serverBlob ?? browserBlob;
if (gen !== convertGenerationRef.current) return;
setPdfBlob(blob);
setPdfSource(serverBlob ? 'server' : 'browser');
setPdfReady(true);
setStatus('ready');
onPdfReady?.(blob, source);
} catch (e) {
if (gen !== convertGenerationRef.current) return;
const msg = e instanceof Error ? e.message : String(e);
setError(msg);
setStatus('error');
} finally {
if (tempHost?.parentNode) {
tempHost.parentNode.removeChild(tempHost);
}
}
},
[
renderScale,
imageQuality,
losslessImages,
preferOfficialServerPdf,
serverFixJustifiedSoftBreaks,
serverFixTableRowHeights,
onPdfReady,
setStatus,
],
);
useEffect(() => {
if (file) void convert(file);
}, [file, convert]);
const handlePick = (e: ChangeEvent<HTMLInputElement>) => {
const picked = e.target.files?.[0];
if (picked) setFile(picked);
};
const handleDrop = (e: DragEvent<HTMLLabelElement>) => {
e.preventDefault();
setIsDragging(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped && /\.docx$/i.test(dropped.name)) setFile(dropped);
};
const handleExportPdf = useCallback(() => {
if (!pdfBlob) return;
const baseName =
file?.name.replace(/\.[^.]+$/, '')?.trim() || 'document';
const safeBaseName = baseName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
const pdfName = `${safeBaseName || 'document'}.pdf`;
const url = URL.createObjectURL(pdfBlob);
const link = document.createElement('a');
link.href = url;
link.download = pdfName;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(() => URL.revokeObjectURL(url), 4000);
}, [pdfBlob, file]);
const sourceLabel =
pdfSource === 'server'
? 'Official PDF (server LibreOffice)'
: pdfSource === 'browser'
? 'Fallback PDF (browser capture)'
: null;
const exportPdfButtonStyle: CSSProperties = {
padding: '0.5rem 1rem',
borderRadius: 6,
border: '1px solid #1e293b',
background: '#1e293b',
color: 'white',
cursor: 'pointer',
fontSize: 14,
};
const statusLabel = useMemo(() => {
switch (status) {
case 'rendering':
return 'Rendering document…';
case 'capturing':
return pageProgress.total > 0
? `Capturing page ${pageProgress.current} of ${pageProgress.total}`
: 'Capturing pages…';
case 'ready':
return 'Ready.';
case 'error':
return `Error: ${error}`;
default:
return 'Waiting for a .docx file.';
}
}, [status, pageProgress, error]);
return (
<div className={className} style={style}>
{!hideFilePicker && (
<label
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
style={{
display: 'block',
padding: '1.25rem',
border: `2px dashed ${isDragging ? '#3b82f6' : '#cbd5e1'}`,
borderRadius: 8,
background: isDragging ? '#eff6ff' : '#f8fafc',
cursor: 'pointer',
textAlign: 'center',
}}
>
<input
type="file"
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
onChange={handlePick}
style={{ display: 'none' }}
/>
<div style={{ fontWeight: 500 }}>
{file ? file.name : 'Drop a .docx file here, or click to browse'}
</div>
{file && (
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
{formatBytes(file.size)}
</div>
)}
</label>
)}
{status !== 'idle' && (
<div
role="status"
aria-live="polite"
style={{
marginTop: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
fontSize: 14,
color: status === 'error' ? '#b91c1c' : '#475569',
}}
>
<span>{statusLabel}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{sourceLabel ? (
<span
style={{
fontSize: 12,
color: '#334155',
background: '#e2e8f0',
border: '1px solid #cbd5e1',
borderRadius: 999,
padding: '2px 8px',
whiteSpace: 'nowrap',
}}
>
{sourceLabel}
</span>
) : null}
{pdfReady && hidePreview ? (
<button
type="button"
onClick={handleExportPdf}
style={exportPdfButtonStyle}
aria-label="Download converted PDF"
>
Download PDF
</button>
) : null}
</div>
</div>
)}
{!hidePreview ? (
<>
{pdfReady && pdfPreviewUrl ? (
<div
style={{
marginTop: 16,
maxWidth: DOCX_PDF_PREVIEW_SHELL_MAX_WIDTH_PX,
marginLeft: 'auto',
marginRight: 'auto',
borderRadius: 6,
overflow: 'hidden',
border: '1px solid #cbd5e1',
background: '#fff',
}}
>
<div
style={{
padding: '10px 12px',
fontSize: 13,
color: '#334155',
background: '#f8fafc',
borderBottom: '1px solid #cbd5e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
flexWrap: 'wrap',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<span>PDF preview (final output)</span>
<span style={{ color: '#64748b' }}>
{preferOfficialServerPdf
? 'Configured to prefer server conversion'
: 'Browser conversion mode'}
</span>
</div>
{preferOfficialServerPdf ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={serverFixJustifiedSoftBreaks}
onChange={(e) => setServerFixJustifiedSoftBreaks(e.target.checked)}
/>
Fix stretched text lines
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={serverFixTableRowHeights}
onChange={(e) => setServerFixTableRowHeights(e.target.checked)}
/>
Relax fixed table row heights
</label>
</div>
) : null}
<button
type="button"
onClick={handleExportPdf}
style={exportPdfButtonStyle}
aria-label="Export converted PDF"
>
Export PDF
</button>
</div>
{pdfSource === 'browser' && layoutInsights?.hasFloatingShapeCandidates ? (
<div
role="note"
style={{
padding: '10px 12px',
fontSize: 12,
lineHeight: 1.5,
color: '#1f2937',
background: '#fef9c3',
borderBottom: '1px solid #fde68a',
}}
>
This document appears to contain floating shapes (signature lines / checkboxes).
Please spot-check alignment in the DOCX preview before using browser PDF as final.
For pixel-critical output, prefer Official PDF (server LibreOffice).
</div>
) : null}
{pdfSource === 'browser' && layoutInsights?.appliedTimesFallbackOverride ? (
<div
role="note"
style={{
padding: '10px 12px',
fontSize: 12,
lineHeight: 1.5,
color: '#334155',
background: '#eff6ff',
borderBottom: '1px solid #bfdbfe',
}}
>
Times New Roman was unavailable in this environment, so a scoped fallback font
override was applied. Verify checkbox glyphs still render correctly.
</div>
) : null}
<iframe
title="Generated PDF preview"
src={pdfPreviewUrl}
style={{ width: '100%', height: '70vh', border: 0 }}
/>
</div>
) : null}
</>
) : null}
</div>
);
}
+377
View File
@@ -0,0 +1,377 @@
import {
createContext,
useContext,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { toast } from 'sonner';
import { detailFromApiError } from '../api/client';
import { MAX_EVIDENCE_PDF_BYTES } from '../lib/evidenceUploadLimits';
import {
deleteApplicationEvidence,
getApplicationEvidence,
uploadApplicationEvidence,
} from './applicationEvidenceApi';
import { snapshotStore, fileStore } from './draftStorage';
import { putFile, getFile } from './serializers';
import {
LS_KEY,
emptyInitiativeDraft,
type InitiativeDraft,
type StoredApplicationForm,
} from './types';
import type { ApplicationFormState } from './initiativeFormTypes';
import type { InitiativeFormState } from './initiativeFormTypes';
function newDraftId(): string {
try {
return `DRAFT-${crypto.randomUUID()}`;
} catch {
return `DRAFT-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
}
export interface DraftContextValue {
draft: InitiativeDraft;
/** Initiative `case_code` (Postgres) when the applicant dashboard has created/loaded a draft — use for MinIO evidence APIs. */
caseId?: string;
updateApplication: (patch: Partial<ApplicationFormState>) => Promise<void>;
updateReport: (patch: Partial<InitiativeFormState>) => void;
hydrateApplication: () => Promise<ApplicationFormState>;
reset: () => Promise<void>;
reload: () => void;
}
export const DraftCtx = createContext<DraftContextValue | null>(null);
const FILE_FIELDS = new Set([
'textbookEvidenceFile',
'researchEvidenceFile',
'technicalEvidenceFile',
]);
function isFileField(k: string) {
return FILE_FIELDS.has(k);
}
function fileFieldToServerEvidenceKind(
field: string,
): import('./types').ServerEvidenceKind | null {
if (field === 'researchEvidenceFile') return 'research';
if (field === 'textbookEvidenceFile') return 'textbook';
if (field === 'technicalEvidenceFile') return 'technical';
return null;
}
function mergeStoredApplication(
base: StoredApplicationForm,
partial: Record<string, unknown> | undefined | null,
): StoredApplicationForm {
if (!partial || typeof partial !== 'object') return base;
const next = { ...base };
for (const [k, v] of Object.entries(partial)) {
if (k === 'textbookEvidenceFile' || k === 'researchEvidenceFile' || k === 'technicalEvidenceFile') {
continue;
}
(next as Record<string, unknown>)[k] = v;
}
return next as StoredApplicationForm;
}
function mergeReport(
base: InitiativeFormState,
partial: Record<string, unknown> | undefined | null,
): InitiativeFormState {
if (!partial || typeof partial !== 'object') return base;
const eff = partial.effectiveness;
return {
...base,
...partial,
effectiveness:
eff && typeof eff === 'object'
? { ...base.effectiveness, ...(eff as InitiativeFormState['effectiveness']) }
: base.effectiveness,
trialUnits: Array.isArray(partial.trialUnits) ? (partial.trialUnits as InitiativeFormState['trialUnits']) : base.trialUnits,
};
}
export function DraftProvider({
children,
caseId,
seedApplication,
seedReport,
preferLocalSnapshot = true,
}: {
children: ReactNode;
caseId?: string;
seedApplication?: Record<string, unknown> | null;
seedReport?: Record<string, unknown> | null;
preferLocalSnapshot?: boolean;
}) {
const [draft, setDraft] = useState<InitiativeDraft>(() => {
if (!preferLocalSnapshot) return emptyInitiativeDraft(newDraftId());
return snapshotStore.load<InitiativeDraft>(LS_KEY) ?? emptyInitiativeDraft(newDraftId());
});
const mergedCaseRef = useRef<string | null>(null);
const draftRef = useRef(draft);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
useEffect(() => {
mergedCaseRef.current = null;
}, [caseId]);
useEffect(() => {
if (!caseId || mergedCaseRef.current === caseId) return;
const hasSomething =
(seedApplication && Object.keys(seedApplication as object).length > 0) ||
(seedReport && Object.keys(seedReport as object).length > 0);
if (!hasSomething) return;
mergedCaseRef.current = caseId;
const base = emptyInitiativeDraft(newDraftId());
setDraft(() => ({
...base,
updatedAt: new Date().toISOString(),
application: mergeStoredApplication(base.application, seedApplication ?? undefined),
report: mergeReport(base.report, seedReport ?? undefined),
}));
}, [caseId, seedApplication, seedReport]);
/**
* After opening a server-backed draft, attach metadata for 2.1 / 2.2 evidence from MinIO so
* the form can re-download PDFs when IndexedDB is empty.
*/
useEffect(() => {
if (!caseId || preferLocalSnapshot) return;
const cid = caseId.trim();
if (!cid) return;
let cancelled = false;
const t = window.setTimeout(() => {
void (async () => {
try {
const ev = await getApplicationEvidence(cid);
if (cancelled) return;
setDraft((d) => {
const app = { ...d.application };
let changed = false;
if (ev.research && !app.researchEvidenceFile) {
app.researchEvidenceFile = {
key: `${d.draftId}:researchEvidenceFile`,
name: ev.research.originalName || 'minh-chung-2-1.pdf',
size: ev.research.byteSize || 0,
type: 'application/pdf',
lastModified: Date.parse(ev.research.uploadedAt || '') || Date.now(),
serverStorageKey: ev.research.storageKey,
serverUploadedAt: ev.research.uploadedAt || undefined,
serverKind: 'research',
};
changed = true;
}
if (ev.textbook && !app.textbookEvidenceFile) {
app.textbookEvidenceFile = {
key: `${d.draftId}:textbookEvidenceFile`,
name: ev.textbook.originalName || 'minh-chung-2-2.pdf',
size: ev.textbook.byteSize || 0,
type: 'application/pdf',
lastModified: Date.parse(ev.textbook.uploadedAt || '') || Date.now(),
serverStorageKey: ev.textbook.storageKey,
serverUploadedAt: ev.textbook.uploadedAt || undefined,
serverKind: 'textbook',
};
changed = true;
}
if (ev.technical && !app.technicalEvidenceFile) {
app.technicalEvidenceFile = {
key: `${d.draftId}:technicalEvidenceFile`,
name: ev.technical.originalName || 'minh-chung-ky-thuat.pdf',
size: ev.technical.byteSize || 0,
type: ev.technical.mimeType || 'application/pdf',
lastModified: Date.parse(ev.technical.uploadedAt || '') || Date.now(),
serverStorageKey: ev.technical.storageKey,
serverUploadedAt: ev.technical.uploadedAt || undefined,
serverKind: 'technical',
};
changed = true;
}
if (!changed) return d;
return { ...d, updatedAt: new Date().toISOString(), application: app };
});
} catch {
/* offline / 401 / MinIO not configured */
}
})();
}, 0);
return () => {
cancelled = true;
window.clearTimeout(t);
};
}, [caseId, preferLocalSnapshot, draft.draftId]);
const saveTimer = useRef<number | null>(null);
useEffect(() => {
if (saveTimer.current) window.clearTimeout(saveTimer.current);
saveTimer.current = window.setTimeout(() => {
snapshotStore.save(LS_KEY, { ...draft, updatedAt: new Date().toISOString() });
}, 400);
return () => {
if (saveTimer.current) window.clearTimeout(saveTimer.current);
};
}, [draft]);
const updateApplication = useCallback<DraftContextValue['updateApplication']>(
async (patch) => {
const next: Partial<StoredApplicationForm> = {};
const cid = caseId?.trim() || '';
for (const [k, v] of Object.entries(patch)) {
if (isFileField(k)) {
if (v instanceof File || v === null) {
const field = k as keyof StoredApplicationForm;
const prevHandle = draftRef.current.application[field] as
| import('./types').FileHandle
| null
| undefined;
const evKind = fileFieldToServerEvidenceKind(k);
if (v instanceof File && v.size > MAX_EVIDENCE_PDF_BYTES) {
toast.error(
`Tệp minh chứng vượt quá 50 MB (${(v.size / 1024 / 1024).toFixed(1)} MB). Vui lòng chọn tệp nhỏ hơn.`,
);
continue;
}
if (v === null && cid && evKind && prevHandle?.serverStorageKey) {
try {
await deleteApplicationEvidence(cid, evKind);
} catch (e) {
console.error('Delete evidence on server failed', e);
toast.error(
detailFromApiError(
e,
'Không xóa được minh chứng trên máy chủ (đã gỡ bản cục bộ).',
),
);
}
}
let handle = await putFile(draftRef.current.draftId, k, v as File | null);
if (handle && v instanceof File && evKind) {
if (!cid) {
toast.message(
'Chưa có mã hồ sơ từ máy chủ — hãy chờ lưu bản nháp tự động (vài giây) rồi chọn lại tệp minh chứng để đồng bộ MinIO.',
);
} else {
try {
const res = await uploadApplicationEvidence(cid, evKind, v);
handle = {
...handle,
serverStorageKey: res.storageKey,
serverUploadedAt: res.uploadedAt,
serverKind: evKind,
};
const label = res.originalName?.trim() || v.name;
toast.success(
`Đã lưu minh chứng lên máy chủ: ${label} (${evKind === 'research' ? '2.1' : evKind === 'textbook' ? '2.2' : 'kỹ thuật'}).`,
);
} catch (e) {
console.error('Evidence upload failed', e);
toast.error(
detailFromApiError(
e,
'Không tải được minh chứng lên máy chủ. Tệp vẫn lưu cục bộ trên trình duyệt.',
),
);
}
}
}
(next as Record<string, unknown>)[k] = handle;
}
} else {
(next as Record<string, unknown>)[k] = v;
}
}
setDraft((d) => ({
...d,
updatedAt: new Date().toISOString(),
application: { ...d.application, ...next },
}));
},
[caseId],
);
const updateReport = useCallback<DraftContextValue['updateReport']>((patch) => {
setDraft((d) => {
const { effectiveness: effIn, ...rest } = patch;
return {
...d,
updatedAt: new Date().toISOString(),
report: {
...d.report,
...rest,
effectiveness: effIn ? { ...d.report.effectiveness, ...effIn } : d.report.effectiveness,
},
};
});
}, []);
const hydrateApplication = useCallback<DraftContextValue['hydrateApplication']>(async () => {
const a = draftRef.current.application;
const cid = caseId?.trim() || null;
const [t, r, tech] = await Promise.all([
getFile(a.textbookEvidenceFile, { caseId: cid, serverKind: 'textbook' }),
getFile(a.researchEvidenceFile, { caseId: cid, serverKind: 'research' }),
getFile(a.technicalEvidenceFile, { caseId: cid, serverKind: 'technical' }),
]);
return {
...a,
textbookEvidenceFile: t,
researchEvidenceFile: r,
technicalEvidenceFile: tech,
};
}, [caseId]);
const reset = useCallback<DraftContextValue['reset']>(async () => {
await fileStore.clearPrefix(`${draft.draftId}:`);
snapshotStore.clear(LS_KEY);
const nextId = newDraftId();
setDraft(emptyInitiativeDraft(nextId));
}, [draft.draftId]);
const reload = useCallback<DraftContextValue['reload']>(() => {
setDraft(snapshotStore.load<InitiativeDraft>(LS_KEY) ?? emptyInitiativeDraft(newDraftId()));
}, []);
const value = useMemo(
() => ({
draft,
caseId,
updateApplication,
updateReport,
hydrateApplication,
reset,
reload,
}),
[draft, caseId, updateApplication, updateReport, hydrateApplication, reset, reload],
);
return <DraftCtx.Provider value={value}>{children}</DraftCtx.Provider>;
}
/** Draft context accessor — throws outside a `<DraftProvider>`. */
export function useDraft(): DraftContextValue {
const ctx = useContext(DraftCtx);
if (!ctx) throw new Error('useDraft must be used inside <DraftProvider>');
return ctx;
}
/** Draft context accessor — returns `null` outside a `<DraftProvider>` (no throw). */
export function useOptionalDraft(): DraftContextValue | null {
return useContext(DraftCtx);
}
@@ -0,0 +1,341 @@
import { useEffect, useRef, useState } from 'react';
import { ExternalLink, Loader2, PenLine, RotateCcw } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../components/ui/dialog';
import { Button } from '../components/ui/button';
import { downloadPdfBlob } from './applicationFormOfficialPdf';
import { PdfTextLayoutEditorDialog } from './PdfTextLayoutEditorDialog';
import {
EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS,
hasOfficialFormPreviewFieldChanges,
OFFICIAL_FORM_PREVIEW_FIELD_DEFS,
sanitizeOfficialFormPreviewFields,
toOfficialFormPreviewOverlayEdits,
type OfficialFormPreviewFieldKey,
type OfficialFormPreviewFields,
} from './officialFormPreviewFieldEdits';
import { applyPdfTextLayoutEdits, type PdfTextLayoutEdit } from './pdfLayoutEditor';
import { cn } from '../lib/utils';
import { toast } from 'sonner';
import { detailFromApiError } from '../api/client';
import { getOfficialFormLayout, saveOfficialFormLayout } from '../lib/officialFormLayoutApi';
export type OfficialFormPdfPreviewDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When null and not loading, dialog shows empty body. */
pdfBlob: Blob | null;
fileName: string;
/** Show progress in the dialog body (during generation). */
loading?: boolean;
/** Explains LibreOffice (server / MinIO) vs client fallback, set by {@link createOfficialFormPdfFromDrafts}. */
sourceNote?: string | null;
caseId?: string;
/** Initial values for client-only field edits. */
initialPreviewFields?: OfficialFormPreviewFields;
className?: string;
};
/**
* Inline PDF review via blob URL. Some browsers block embedded PDFs; a “new tab” fallback is provided.
*/
export function OfficialFormPdfPreviewDialog({
open,
onOpenChange,
pdfBlob,
fileName,
loading = false,
sourceNote,
caseId,
initialPreviewFields,
className,
}: OfficialFormPdfPreviewDialogProps) {
const [basePdfBlob, setBasePdfBlob] = useState<Blob | null>(null);
const [workingPdfBlob, setWorkingPdfBlob] = useState<Blob | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [fieldValues, setFieldValues] = useState<OfficialFormPreviewFields>(EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS);
const [applyingFieldPreview, setApplyingFieldPreview] = useState(false);
const [layoutEditorOpen, setLayoutEditorOpen] = useState(false);
const [initialLayoutEdits, setInitialLayoutEdits] = useState<PdfTextLayoutEdit[]>([]);
const applyVersionRef = useRef(0);
useEffect(() => {
if (!open || !workingPdfBlob) {
setObjectUrl(null);
return;
}
const url = URL.createObjectURL(workingPdfBlob);
setObjectUrl(url);
return () => {
URL.revokeObjectURL(url);
};
}, [open, workingPdfBlob]);
useEffect(() => {
setBasePdfBlob(pdfBlob);
setWorkingPdfBlob(pdfBlob);
}, [pdfBlob]);
useEffect(() => {
setFieldValues(sanitizeOfficialFormPreviewFields(initialPreviewFields ?? EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS));
}, [initialPreviewFields, open]);
useEffect(() => {
if (!open || !basePdfBlob || loading) return;
const baseValues = sanitizeOfficialFormPreviewFields(initialPreviewFields ?? EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS);
const nextValues = sanitizeOfficialFormPreviewFields(fieldValues);
const changed = hasOfficialFormPreviewFieldChanges(nextValues, baseValues);
const edits = changed ? toOfficialFormPreviewOverlayEdits(nextValues) : [];
const myVersion = ++applyVersionRef.current;
const timer = window.setTimeout(() => {
if (edits.length === 0) {
setWorkingPdfBlob(basePdfBlob);
setApplyingFieldPreview(false);
return;
}
setApplyingFieldPreview(true);
void (async () => {
try {
const nextBlob = await applyPdfTextLayoutEdits(basePdfBlob, edits);
if (applyVersionRef.current !== myVersion) return;
setWorkingPdfBlob(nextBlob);
} finally {
if (applyVersionRef.current === myVersion) {
setApplyingFieldPreview(false);
}
}
})();
}, 250);
return () => {
window.clearTimeout(timer);
};
}, [basePdfBlob, fieldValues, initialPreviewFields, loading, open]);
const openInNewTab = () => {
if (!objectUrl) return;
window.open(objectUrl, '_blank', 'noopener,noreferrer');
};
const handleDownload = () => {
if (!workingPdfBlob) return;
downloadPdfBlob(workingPdfBlob, fileName);
toast.success('Đã tải PDF mẫu hồ sơ.');
};
const handlePreviewFieldChange = (key: OfficialFormPreviewFieldKey, value: string) => {
setFieldValues((prev) => sanitizeOfficialFormPreviewFields({ ...prev, [key]: value }));
};
const handleResetPreviewFields = () => {
setFieldValues(sanitizeOfficialFormPreviewFields(initialPreviewFields ?? EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS));
setWorkingPdfBlob(basePdfBlob);
toast.success('Đã khôi phục bản xem trước ban đầu.');
};
useEffect(() => {
if (!open || !caseId?.trim()) return;
let cancelled = false;
void (async () => {
try {
const stored = await getOfficialFormLayout(caseId);
if (cancelled) return;
const edits = Array.isArray(stored.layoutEdits) ? stored.layoutEdits : [];
setInitialLayoutEdits(edits);
if (edits.length > 0 && basePdfBlob) {
const overlay = await applyPdfTextLayoutEdits(basePdfBlob, edits);
if (!cancelled) {
setWorkingPdfBlob(overlay);
toast.success('Đã nạp bố cục PDF đã lưu từ máy chủ.');
}
}
} catch (e) {
if (!cancelled) {
setInitialLayoutEdits([]);
const msg = detailFromApiError(e, 'Không tải được bố cục PDF đã lưu.');
toast.error(msg);
}
}
})();
return () => {
cancelled = true;
};
}, [open, caseId, basePdfBlob]);
useEffect(() => {
if (!open) {
setInitialLayoutEdits([]);
return;
}
if (!caseId?.trim()) {
setInitialLayoutEdits([]);
}
}, [open, caseId]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'flex max-h-[90vh] max-w-5xl flex-col gap-0 overflow-hidden p-0 sm:rounded-lg',
className,
)}
>
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 pr-14 text-left">
<DialogTitle>Xem trước PDF mẫu hồ </DialogTitle>
<DialogDescription className="text-xs sm:text-sm space-y-1">
<span className="block">
Kiểm tra nội dung trước khi tải. Trình duyệt thể không hiển thị PDF trong khung bên dưới dùng « Mở tab
mới ».
</span>
{sourceNote ? <span className="block text-muted-foreground">{sourceNote}</span> : null}
<span className="block text-muted-foreground">
Chỉnh dữ liệu trong khung dưới chỉ cập nhật bản xem trước hiện tại (không ghi ngược lên hồ nguồn).
</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-0 border-b border-border md:grid-cols-[340px_minmax(0,1fr)]">
<div className="space-y-3 border-b border-border p-4 md:border-b-0 md:border-r">
<div className="space-y-1">
<h4 className="text-sm font-medium">Chỉnh trường dữ liệu (xem trước)</h4>
<p className="text-xs text-muted-foreground">Nhập đ phủ chữ lên PDF trong phiên hiện tại.</p>
</div>
<div className="space-y-2">
{OFFICIAL_FORM_PREVIEW_FIELD_DEFS.map((field) => (
<label key={field.key} className="block space-y-1">
<span className="text-xs text-muted-foreground">{field.label}</span>
{field.multiline ? (
<textarea
value={fieldValues[field.key]}
onChange={(e) => handlePreviewFieldChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="min-h-[72px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
) : (
<input
type="text"
value={fieldValues[field.key]}
onChange={(e) => handlePreviewFieldChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
/>
)}
</label>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => handleResetPreviewFields()}
disabled={!basePdfBlob}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
Khôi phục bản xem trước ban đu
</Button>
</div>
<div className="relative min-h-[50vh] bg-muted/30">
{applyingFieldPreview ? (
<div className="absolute right-3 top-3 z-10 inline-flex items-center gap-1.5 rounded-md border border-border bg-background/95 px-2.5 py-1 text-xs text-muted-foreground shadow-sm">
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
Đang cập nhật xem trước...
</div>
) : null}
{loading ? (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" aria-hidden />
<p className="text-sm">Đang tạo PDF từ mẫu Word</p>
</div>
) : objectUrl ? (
<iframe
title="PDF mẫu hồ sơ"
src={objectUrl}
className="absolute inset-0 h-full min-h-[50vh] w-full border-0"
/>
) : (
<div className="flex min-h-[50vh] items-center justify-center text-sm text-muted-foreground">
Chưa file PDF.
</div>
)}
</div>
</div>
<div className="relative min-h-[18vh] border-b border-border bg-muted/30">
{loading ? (
<div className="flex min-h-[18vh] flex-col items-center justify-center gap-3 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" aria-hidden />
<p className="text-sm">Đang tạo PDF từ mẫu Word</p>
</div>
) : (
<div className="flex min-h-[18vh] items-center justify-center text-xs text-muted-foreground px-4 text-center">
Mẹo: tiếp tục dùng « Chỉnh bố cục PDF » bên dưới nếu bạn cần tinh chỉnh tọa đ hoặc chèn ghi chú thủ công.
</div>
)}
</div>
<DialogFooter className="flex-row flex-wrap gap-2 border-t border-border px-6 py-4 sm:justify-between">
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={!objectUrl}
onClick={() => openInNewTab()}
>
<ExternalLink className="h-3.5 w-3.5" aria-hidden />
Mở tab mới
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={!workingPdfBlob || loading}
onClick={() => setLayoutEditorOpen(true)}
>
<PenLine className="h-3.5 w-3.5" aria-hidden />
Chỉnh bố cục PDF
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Đóng
</Button>
<Button type="button" size="sm" disabled={!workingPdfBlob || loading} onClick={() => handleDownload()}>
Tải PDF
</Button>
</div>
</DialogFooter>
</DialogContent>
<PdfTextLayoutEditorDialog
open={layoutEditorOpen}
onOpenChange={setLayoutEditorOpen}
pdfBlob={workingPdfBlob}
fileName={fileName}
initialEdits={initialLayoutEdits}
onSaveLayout={
caseId?.trim()
? async (edits, editedPdfBlob) => {
await saveOfficialFormLayout(caseId, edits, editedPdfBlob, fileName || 'official-form-layout.pdf');
setInitialLayoutEdits(edits);
toast.success('Đã lưu bố cục PDF vào máy chủ (PostgreSQL + MinIO).');
}
: undefined
}
onPdfUpdated={(blob) => {
setWorkingPdfBlob(blob);
toast.success('Đã áp dụng chỉnh bố cục chữ lên bản PDF hiện tại.');
}}
/>
</Dialog>
);
}
@@ -0,0 +1,324 @@
import { useEffect, useMemo, useState } from 'react';
import { Loader2, Plus, Trash2 } from 'lucide-react';
import { Button } from '../components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../components/ui/dialog';
import { Input } from '../components/ui/input';
import { Textarea } from '../components/ui/textarea';
import { applyPdfTextLayoutEdits, type PdfTextLayoutEdit } from './pdfLayoutEditor';
import { downloadPdfBlob } from './applicationFormOfficialPdf';
type PdfTextLayoutEditorDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
pdfBlob: Blob | null;
fileName: string;
onPdfUpdated: (blob: Blob) => void;
initialEdits?: PdfTextLayoutEdit[] | null;
onSaveLayout?: (edits: PdfTextLayoutEdit[], editedPdfBlob: Blob) => Promise<void>;
};
const DEFAULT_EDIT = (): PdfTextLayoutEdit => ({
id: `${Date.now()}-${Math.random().toString(16).slice(2)}`,
text: '',
page: 1,
x: 72,
y: 740,
fontSize: 12,
lineHeight: 14,
boxWidth: 240,
letterSpacing: 0,
textAlign: 'left',
fontName: 'TimesRoman',
colorHex: '#111827',
});
const FONT_OPTIONS: Array<{ value: PdfTextLayoutEdit['fontName']; label: string }> = [
{ value: 'TimesRoman', label: 'Times Roman' },
{ value: 'TimesRomanBold', label: 'Times Roman Bold' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'HelveticaBold', label: 'Helvetica Bold' },
];
export function PdfTextLayoutEditorDialog({
open,
onOpenChange,
pdfBlob,
fileName,
onPdfUpdated,
initialEdits,
onSaveLayout,
}: PdfTextLayoutEditorDialogProps) {
const [edits, setEdits] = useState<PdfTextLayoutEdit[]>([DEFAULT_EDIT()]);
const [saving, setSaving] = useState(false);
const [savingLayout, setSavingLayout] = useState(false);
const objectUrl = useMemo(() => (pdfBlob ? URL.createObjectURL(pdfBlob) : null), [pdfBlob]);
useEffect(() => {
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [objectUrl]);
useEffect(() => {
if (!open) {
setEdits([DEFAULT_EDIT()]);
}
}, [open]);
useEffect(() => {
if (!open) return;
if (Array.isArray(initialEdits) && initialEdits.length > 0) {
setEdits(initialEdits);
return;
}
setEdits([DEFAULT_EDIT()]);
}, [open, initialEdits]);
const updateEdit = <K extends keyof PdfTextLayoutEdit>(id: string, key: K, value: PdfTextLayoutEdit[K]) => {
setEdits((prev) => prev.map((edit) => (edit.id === id ? { ...edit, [key]: value } : edit)));
};
const handleApply = async () => {
if (!pdfBlob || saving) return;
setSaving(true);
try {
const nextBlob = await applyPdfTextLayoutEdits(pdfBlob, edits);
onPdfUpdated(nextBlob);
onOpenChange(false);
} finally {
setSaving(false);
}
};
const handleApplyAndDownload = async () => {
if (!pdfBlob || saving) return;
setSaving(true);
try {
const nextBlob = await applyPdfTextLayoutEdits(pdfBlob, edits);
onPdfUpdated(nextBlob);
downloadPdfBlob(nextBlob, fileName || 'application-form-edited.pdf');
onOpenChange(false);
} finally {
setSaving(false);
}
};
const handleSaveLayout = async () => {
if (!pdfBlob || saving || savingLayout || !onSaveLayout) return;
setSavingLayout(true);
try {
const nextBlob = await applyPdfTextLayoutEdits(pdfBlob, edits);
onPdfUpdated(nextBlob);
await onSaveLayout(edits, nextBlob);
onOpenChange(false);
} finally {
setSavingLayout(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[92vh] max-w-7xl flex-col gap-0 overflow-hidden p-0 sm:rounded-lg">
<DialogHeader className="space-y-1 border-b border-border px-6 py-4 pr-14 text-left">
<DialogTitle>Chỉnh bố cục chữ trên PDF</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
Thêm các lớp chữ phủ lên PDF theo tọa đ (đơn vị point). Gốc tọa đ góc trái dưới trang. Font chuẩn PDF hỗ
trợ tiếng Việt hạn chế.
</DialogDescription>
</DialogHeader>
<div className="grid min-h-[68vh] grid-cols-1 gap-0 md:grid-cols-[380px_minmax(0,1fr)]">
<div className="overflow-y-auto border-b border-border p-4 md:border-b-0 md:border-r">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium">Các chỉnh sửa chữ</h3>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setEdits((prev) => [...prev, DEFAULT_EDIT()])}
>
<Plus className="h-3.5 w-3.5" aria-hidden />
Thêm dòng
</Button>
</div>
<div className="space-y-3">
{edits.map((edit, idx) => (
<div key={edit.id} className="rounded-md border border-border p-3">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">Mục {idx + 1}</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-destructive"
disabled={edits.length <= 1}
onClick={() =>
setEdits((prev) => (prev.length <= 1 ? prev : prev.filter((x) => x.id !== edit.id)))
}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</Button>
</div>
<label className="mb-1 block text-xs text-muted-foreground">Nội dung chữ</label>
<Textarea
value={edit.text}
onChange={(e) => updateEdit(edit.id, 'text', e.target.value)}
placeholder="Nhập nội dung muốn chèn"
className="mb-2 min-h-[90px]"
/>
<div className="grid grid-cols-2 gap-2">
<Input
type="number"
value={edit.page}
onChange={(e) => updateEdit(edit.id, 'page', Number(e.target.value))}
placeholder="Trang"
/>
<Input
type="number"
value={edit.fontSize}
onChange={(e) => updateEdit(edit.id, 'fontSize', Number(e.target.value))}
placeholder="Font"
/>
<Input
type="number"
value={edit.x}
onChange={(e) => updateEdit(edit.id, 'x', Number(e.target.value))}
placeholder="X"
/>
<Input
type="number"
value={edit.y}
onChange={(e) => updateEdit(edit.id, 'y', Number(e.target.value))}
placeholder="Y"
/>
<Input
type="number"
value={edit.lineHeight}
onChange={(e) => updateEdit(edit.id, 'lineHeight', Number(e.target.value))}
placeholder="Line height"
/>
<Input
type="number"
value={edit.boxWidth}
onChange={(e) => updateEdit(edit.id, 'boxWidth', Number(e.target.value))}
placeholder="Width (pt)"
/>
<Input
type="number"
value={edit.letterSpacing}
onChange={(e) => updateEdit(edit.id, 'letterSpacing', Number(e.target.value))}
placeholder="Letter spacing"
/>
<Input
type="text"
value={edit.colorHex}
onChange={(e) => updateEdit(edit.id, 'colorHex', e.target.value)}
placeholder="#111827"
/>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<div>
<label className="mb-1 block text-xs text-muted-foreground">Font</label>
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={edit.fontName}
onChange={(e) =>
updateEdit(
edit.id,
'fontName',
(e.target.value as PdfTextLayoutEdit['fontName']) || 'TimesRoman',
)
}
>
{FONT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">Canh lề</label>
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={edit.textAlign}
onChange={(e) =>
updateEdit(
edit.id,
'textAlign',
(e.target.value as PdfTextLayoutEdit['textAlign']) || 'left',
)
}
>
<option value="left">Trái</option>
<option value="center">Giữa</option>
<option value="right">Phải</option>
</select>
</div>
</div>
</div>
))}
</div>
</div>
<div className="relative min-h-[68vh] bg-muted/30">
{objectUrl ? (
<iframe title="PDF chỉnh bố cục" src={objectUrl} className="absolute inset-0 h-full w-full border-0" />
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">Chưa PDF.</div>
)}
</div>
</div>
<DialogFooter className="flex-row flex-wrap gap-2 border-t border-border px-6 py-4 sm:justify-between">
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>
Đóng
</Button>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={!pdfBlob || saving} onClick={() => void handleApply()}>
{saving ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
Đang áp dụng...
</>
) : (
'Áp dụng vào bản xem trước'
)}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={!pdfBlob || saving || savingLayout || !onSaveLayout}
onClick={() => void handleSaveLayout()}
>
{savingLayout ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
Đang lưu máy chủ...
</>
) : (
'Áp dụng và lưu máy chủ'
)}
</Button>
<Button type="button" size="sm" disabled={!pdfBlob || saving} onClick={() => void handleApplyAndDownload()}>
{saving ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
Đang xuất...
</>
) : (
'Áp dụng và tải PDF'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,49 @@
import {
APPLICANT_CASE_ID_SESSION_KEY,
APPLICANT_REPORT_STEP_SESSION_KEY,
CASE_ID_SEARCH_PARAM,
FRESH_DASHBOARD_SEARCH_PARAM,
} from "../lib/applicantRegistrationSession";
/** True when report tab has enough content to treat the first step as done. */
export function reportDraftLooksComplete(report: Record<string, unknown> | undefined): boolean {
if (!report || typeof report !== "object") return false;
const initiativeName = String(report.initiativeName ?? "").trim();
const introduction = String(report.introduction ?? "").trim();
return initiativeName.length > 0 || introduction.length > 0;
}
/**
* Initial case id: prefers `?caseId=` (resume from Postgres), ignores when `?fresh=1`,
* otherwise sessionStorage.
*/
export function readInitialApplicantCaseId(): string {
if (typeof window === "undefined") return "";
const params = new URLSearchParams(window.location.search);
if (params.get(FRESH_DASHBOARD_SEARCH_PARAM) === "1") return "";
const fromUrl = params.get(CASE_ID_SEARCH_PARAM)?.trim();
if (fromUrl) {
try {
sessionStorage.setItem(APPLICANT_CASE_ID_SESSION_KEY, fromUrl);
} catch {
/* ignore */
}
return fromUrl;
}
try {
return sessionStorage.getItem(APPLICANT_CASE_ID_SESSION_KEY) ?? "";
} catch {
return "";
}
}
export function readReportStepDoneFromSession(): boolean {
if (typeof window === "undefined") return false;
try {
return sessionStorage.getItem(APPLICANT_REPORT_STEP_SESSION_KEY) === "1";
} catch {
return false;
}
}
export { APPLICANT_REPORT_STEP_SESSION_KEY as REPORT_STEP_SESSION_KEY };
@@ -0,0 +1,5 @@
/** Prefill header fields when admin reviews an applicant's submitted bundle */
export type ApplicantPrefill = {
initiativeName?: string;
authorName?: string;
};
@@ -0,0 +1,55 @@
import type { Author } from './initiativeFormTypes';
/** Columns shown on “Bản Xác Nhận” tỷ lệ đóng góp — maps to {@link Author} fields used on Đơn §1. */
export type ContributionParticipantColumn = 'fullName' | 'workUnit' | 'contributionPercent';
export function contributionColumnToAuthorField(
col: ContributionParticipantColumn,
): keyof Pick<Author, 'name' | 'workplace' | 'contributionPercent'> {
switch (col) {
case 'fullName':
return 'name';
case 'workUnit':
return 'workplace';
case 'contributionPercent':
return 'contributionPercent';
}
}
export function authorToContributionRow(
author: Author,
isEditing: boolean,
): {
id: number;
fullName: string;
workUnit: string;
contributionPercent: number;
isEditing: boolean;
} {
return {
id: author.id,
fullName: author.name,
workUnit: author.workplace,
contributionPercent: Number(author.contributionPercent) || 0,
isEditing,
};
}
/** Same shape as {@link InitiativeApplicationForm} `addAuthor`. */
export function createEmptyAuthorRow(): Author {
return {
id: Date.now(),
name: '',
dob: '',
workplace: '',
title: '',
qualification: '',
contributionPercent: 0,
};
}
export function normalizeContributionPercent(value: string | number | boolean): number {
if (typeof value === 'boolean') return 0;
const n = typeof value === 'string' ? Number(value) : Number(value);
return Number.isFinite(n) ? n : 0;
}
+107
View File
@@ -0,0 +1,107 @@
import { apiClient } from "../api/client";
import { axiosSuccessStatusOnly } from "../lib/applicationReviewApi";
import { parseContributionSubmissionDateInput } from "../lib/vnDateFormat";
export type ApplicantDraftTab = "report" | "application" | "contribution";
export interface ApplicantDraftBundle {
caseId: string;
updatedAt: string;
tabs: {
report?: Record<string, unknown>;
application?: Record<string, unknown>;
contribution?: Record<string, unknown>;
};
}
/** Normalize values for JSON (Dates → ISO, File → stub). Use for exports & display. */
export function serializeDraftDataForExport(input: unknown): unknown {
if (input instanceof Date) return input.toISOString();
if (typeof File !== "undefined" && input instanceof File) {
return input ? { name: input.name, size: input.size, type: input.type } : null;
}
if (Array.isArray(input)) return input.map(serializeDraftDataForExport);
if (input && typeof input === "object") {
return Object.fromEntries(
Object.entries(input as Record<string, unknown>).map(([key, value]) => [
key,
serializeDraftDataForExport(value),
]),
);
}
return input;
}
function normalizeTabData(tab: ApplicantDraftTab, data: Record<string, unknown>) {
const safe = serializeDraftDataForExport(data) as Record<string, unknown>;
if (tab === "application") {
return {
...safe,
textbookEvidenceFile: null,
researchEvidenceFile: null,
technicalEvidenceFile: null,
};
}
if (tab === "contribution") {
return {
...safe,
submissionDate: typeof safe.submissionDate === "string" ? safe.submissionDate : null,
};
}
return safe;
}
export async function saveApplicantDraftTab(
tab: ApplicantDraftTab,
data: Record<string, unknown>,
caseId?: string,
): Promise<ApplicantDraftBundle> {
return apiClient.post<ApplicantDraftBundle>("/api/v1/application-drafts", {
caseId: caseId || undefined,
tab,
data: normalizeTabData(tab, data),
});
}
function unwrapResponsePayload<T>(raw: unknown): T {
if (raw && typeof raw === "object" && "data" in raw && (raw as { data: unknown }).data !== undefined) {
return (raw as { data: T }).data;
}
return raw as T;
}
function isRecord(x: unknown): x is Record<string, unknown> {
return x !== null && typeof x === "object" && !Array.isArray(x);
}
/**
* Load persisted report / application / contribution tab JSON for a dashboard `caseId`
* (Initiative.case_code, not the submission `sub-…` id).
*/
export async function loadApplicantDraftBundle(caseId: string): Promise<ApplicantDraftBundle> {
const safe = encodeURIComponent(caseId.trim() || caseId);
const raw = await apiClient.get<unknown>(`/api/v1/application-drafts/${safe}`, axiosSuccessStatusOnly);
const body = unwrapResponsePayload<Record<string, unknown>>(raw);
const tabsRaw = body?.tabs;
const tabs = isRecord(tabsRaw) ? tabsRaw : {};
return {
caseId: String(body?.caseId ?? caseId),
updatedAt: String(body?.updatedAt ?? new Date().toISOString()),
tabs: {
report: isRecord(tabs.report) ? (tabs.report as Record<string, unknown>) : undefined,
application: isRecord(tabs.application) ? (tabs.application as Record<string, unknown>) : undefined,
contribution: isRecord(tabs.contribution) ? (tabs.contribution as Record<string, unknown>) : undefined,
},
};
}
export function reviveContributionDraft(data?: Record<string, unknown>) {
if (!data) return undefined;
return {
...data,
submissionDate: (() => {
const d = parseContributionSubmissionDateInput(data.submissionDate);
return d ?? new Date();
})(),
};
}
@@ -0,0 +1,95 @@
import type { AxiosRequestConfig } from "axios";
import { apiClient } from "../api/client";
import { env } from "../config/env";
/** Only treat 2xx as success so 401/403 from evidence API surface as errors (global client allows <500). */
const OK_ONLY: AxiosRequestConfig = {
validateStatus: (status) => status >= 200 && status < 300,
};
export type ServerEvidenceKind = "research" | "textbook" | "technical";
export interface EvidenceUploadResponse {
ok: boolean;
caseId: string;
kind: ServerEvidenceKind;
storageKey: string;
originalName: string | null;
byteSize?: number;
uploadedAt: string;
}
export interface EvidenceItemMeta {
kind: ServerEvidenceKind;
originalName: string | null;
byteSize: number | null;
mimeType?: string | null;
uploadedAt: string | null;
storageKey: string;
/** Presigned GET with Content-Disposition: attachment (save / open in new tab). */
downloadUrl: string | null;
/** Presigned GET with Content-Disposition: inline — use for embedded PDF viewers. */
viewUrl?: string | null;
reviewStatus?: string | null;
reviewedAt?: string | null;
}
export interface EvidenceBundle {
research: EvidenceItemMeta | null;
textbook: EvidenceItemMeta | null;
technical: EvidenceItemMeta | null;
}
export async function uploadApplicationEvidence(
caseId: string,
kind: ServerEvidenceKind,
file: File,
): Promise<EvidenceUploadResponse> {
const fd = new FormData();
fd.append("kind", kind);
fd.append("file", file);
return apiClient.post<EvidenceUploadResponse>(
`/api/v1/application-drafts/${encodeURIComponent(caseId)}/evidence`,
fd,
OK_ONLY,
);
}
export async function deleteApplicationEvidence(caseId: string, kind: ServerEvidenceKind): Promise<void> {
await apiClient.delete(
`/api/v1/application-drafts/${encodeURIComponent(caseId)}/evidence?kind=${encodeURIComponent(kind)}`,
OK_ONLY,
);
}
export async function getApplicationEvidence(caseId: string): Promise<EvidenceBundle> {
return apiClient.get<EvidenceBundle>(
`/api/v1/application-drafts/${encodeURIComponent(caseId)}/evidence`,
OK_ONLY,
);
}
/** Same-origin / HTTPS API path to stream evidence (avoids mixed-content iframes vs http://…:19000 presign). */
export function applicationDraftEvidenceContentUrl(
caseId: string,
kind: ServerEvidenceKind,
opts?: { attachment?: boolean },
): string {
const q = new URLSearchParams({ kind });
if (opts?.attachment) q.set("attachment", "true");
const path = `/api/v1/application-drafts/${encodeURIComponent(caseId)}/evidence/content?${q.toString()}`;
const prefix = env.API_URL.replace(/\/$/, "");
return prefix ? `${prefix}${path}` : path;
}
export async function patchEvidenceReview(
caseId: string,
kind: ServerEvidenceKind,
decision: "approved" | "rejected",
): Promise<void> {
await apiClient.patch(
`/api/v1/application-drafts/${encodeURIComponent(caseId)}/evidence/review?kind=${encodeURIComponent(kind)}`,
{ decision },
OK_ONLY,
);
}
@@ -0,0 +1,112 @@
import type { InitiativeDraft } from './types';
import type { ContributionDraftShape } from './contributionDraftTypes';
import { buildOfficialBieuMauFromDraft } from './mapDraftToOfficialBieuMau';
import {
buildInitialOfficialFormPreviewFields,
type OfficialFormPreviewFields,
} from './officialFormPreviewFieldEdits';
import {
convertDocxToPdfBlob,
createOffScreenDocxCaptureHost,
} from './convertDocxToPdfBlob';
import {
fetchApplicationFormDocxPreview,
fetchApplicationFormOfficialPdf,
} from '../lib/applicationFormDocxApi';
import { buildOfficialFormPdfFileName } from '../lib/officialFormPdfFileName';
import { ApiArrayBufferRequestError } from '../api/client';
export type OfficialFormPdfResult = {
pdfBlob: Blob;
fileName: string;
/** Server LibreOffice PDF matches what submit persists to MinIO (`official_form_pdf`); client is html2canvas fallback. */
source: 'server' | 'client';
/** Initial field values shown in preview-only field editor (client side only). */
initialPreviewFields: OfficialFormPreviewFields;
};
function pdfArrayBufferLooksLikePdf(buf: ArrayBuffer): boolean {
if (buf.byteLength < 5) return false;
const head = new Uint8Array(buf, 0, 5);
return String.fromCharCode(...head).startsWith('%PDF');
}
function shouldFallbackToClientOfficialPdf(err: unknown): boolean {
if (err instanceof ApiArrayBufferRequestError) {
if (err.status === 501) return true;
if (/libreoffice|headless|chưa cài/i.test(err.message)) return true;
return false;
}
if (err instanceof Error) {
const m = err.message.toLowerCase();
if (m.includes('timeout') || m.includes('network')) return true;
if (m.includes('econnrefused') || m.includes('econnreset')) return true;
}
return false;
}
async function createOfficialFormPdfClientRaster(
official: Record<string, unknown>,
options?: { renderScale?: number },
): Promise<Blob> {
const docxBuf = await fetchApplicationFormDocxPreview(official);
const docxBlob = new Blob([docxBuf], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
});
const host = createOffScreenDocxCaptureHost();
try {
return await convertDocxToPdfBlob(docxBlob, host, {
renderScale: options?.renderScale ?? 2,
});
} finally {
host.remove();
}
}
/**
* Prefer server LibreOffice PDF (same conversion as submit → MinIO `official_form_pdf`);
* fall back to client docx-preview + html2canvas when LibreOffice is unavailable or the network fails.
*/
export async function createOfficialFormPdfFromDrafts(
draft: InitiativeDraft,
contribution: ContributionDraftShape,
options?: { renderScale?: number },
): Promise<OfficialFormPdfResult> {
const official = buildOfficialBieuMauFromDraft(
draft,
contribution as unknown as Record<string, unknown> | null,
) as unknown as Record<string, unknown>;
const fileName = buildOfficialFormPdfFileName(official);
const initialPreviewFields = buildInitialOfficialFormPreviewFields(official);
try {
const pdfBuf = await fetchApplicationFormOfficialPdf(official);
if (!pdfArrayBufferLooksLikePdf(pdfBuf)) {
const pdfBlob = await createOfficialFormPdfClientRaster(official, options);
return { pdfBlob, fileName, source: 'client', initialPreviewFields };
}
return {
pdfBlob: new Blob([pdfBuf], { type: 'application/pdf' }),
fileName,
source: 'server',
initialPreviewFields,
};
} catch (e) {
if (!shouldFallbackToClientOfficialPdf(e)) throw e;
const pdfBlob = await createOfficialFormPdfClientRaster(official, options);
return { pdfBlob, fileName, source: 'client', initialPreviewFields };
}
}
export function downloadPdfBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
+5
View File
@@ -0,0 +1,5 @@
import bieuMauTemplate from './bieu_mau_sang_kien_template.json';
/** Canonical Mẫu 0104 + Bản cam kết template structure (shared by the forms and the slice-4 docx engine). */
export { bieuMauTemplate };
export default bieuMauTemplate;
@@ -0,0 +1,235 @@
{
"TRANG BÌA": {
"Tên sáng kiến (Tiếng Việt)": "",
"Tác giả/nhóm tác giả sáng kiến": "",
"Đơn vị công tác": "",
"Thông tin liên hệ (Điện thoại, Email)": "",
"Năm": ""
},
"MẪU SỐ 01 - BÁO CÁO MÔ TẢ SÁNG KIẾN": {
"1. Mở đầu": "",
"2. Tên sáng kiến (tên quy trình, giải pháp, phương pháp)": "",
"3. Lĩnh vực áp dụng của sáng kiến": "",
"4. Mô tả sáng kiến": {
"4.1 Tình trạng giải pháp đã biết hoặc hiện trạng công tác khi chưa có sáng kiến": "",
"4.2 Nội dung giải pháp đề nghị công nhận là sáng kiến": {
"Mục đích của sáng kiến": "",
"Về nội dung của sáng kiến": {
"Các bước thực hiện giải pháp": "",
"Các điều kiện cần thiết để áp dụng giải pháp": "",
"Lĩnh vực áp dụng": "",
"Kết quả thu được": "",
"Danh sách đơn vị/cá nhân đã tham gia áp dụng thử hoặc lần đầu": [
{
"TT": "",
"Tên tổ chức/cá nhân": "",
"Địa chỉ": "",
"Lĩnh vực áp dụng sáng kiến": ""
}
]
},
"Về tính mới của sáng kiến": "",
"Về tính hiệu quả": {
"Tạo ra lợi ích kinh tế": "",
"Đem lại hiệu quả trong giảng dạy": "",
"Tăng năng suất lao động": "",
"Nâng cao hiệu quả công việc": "",
"Nâng cao chất lượng công việc, dịch vụ": "",
"Giảm chi phí": "",
"Cải thiện môi trường, điều kiện học tập, làm việc, sống": "",
"Bảo vệ sức khỏe": "",
"Đảm bảo an toàn lao động, PCCC": "",
"Nâng cao khả năng, trình độ, nhận thức, trách nhiệm": ""
}
}
},
"6. Những thông tin cần được bảo mật (nếu có)": "",
"Ngày ký": {
"Ngày": "",
"Tháng": "",
"Năm": ""
},
"Lãnh đạo đơn vị (Ký, ghi rõ họ tên)": "",
"Tác giả sáng kiến (Ký, ghi rõ họ tên)": ""
},
"MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN": {
"Đơn vị": "",
"Danh sách tác giả": [
{
"STT": "",
"Họ và tên": "",
"Ngày tháng năm sinh": "",
"Nơi công tác": "",
"Chức danh": "",
"Trình độ chuyên môn": "",
"Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến": ""
}
],
"Tên sáng kiến đề nghị xét công nhận": "",
"Chủ đầu tư tạo ra sáng kiến": "",
"Lĩnh vực áp dụng sáng kiến": "",
"Ngày sáng kiến được áp dụng": "",
"Nội dung của sáng kiến": "",
"Phân loại sáng kiến (đánh dấu ☑)": {
"Giải pháp kỹ thuật, quản lý, tác nghiệp, ứng dụng tiến bộ kỹ thuật áp dụng cho ĐHYD TP.HCM": false,
"Sáng kiến cải tiến kỹ thuật từ các nghiên cứu khoa học có kết quả được đăng tải trên các tạp chí, hội nghị trong nước và quốc tế": false,
"Sáng kiến cải tiến kỹ thuật từ sách, giáo trình, tài liệu tham khảo": false
},
"Những thông tin cần được bảo mật (nếu có)": "",
"Các điều kiện cần thiết để áp dụng sáng kiến": "",
"Đánh giá lợi ích theo ý kiến của tác giả": "",
"Đánh giá lợi ích theo ý kiến của tổ chức, cá nhân đã tham gia áp dụng sáng kiến lần đầu": "",
"Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu": [
{
"Số TT": "",
"Họ và tên": "",
"Ngày tháng năm sinh": "",
"Nơi công tác": "",
"Chức danh": "",
"Trình độ chuyên môn": "",
"Nội dung công việc hỗ trợ": ""
}
],
"Ngày ký": {
"Ngày": "",
"Tháng": "",
"Năm": ""
},
"Xác nhận của lãnh đạo Đơn vị": "",
"Tác giả sáng kiến (Ký, ghi rõ họ tên)": ""
},
"MẪU SỐ 03 - BẢN XÁC NHẬN TỶ LỆ (%) ĐÓNG GÓP VÀO VIỆC TẠO RA SÁNG KIẾN": {
"Ngày ký": {
"Ngày": "",
"Tháng": "",
"Năm": ""
},
"1. Tên sáng kiến": "",
"2. Tác giả chính/Đại diện nhóm tác giả sáng kiến": "",
"Chức vụ, đơn vị công tác": "",
"Tỷ lệ đóng góp": [
{
"STT": "",
"Họ và tên": "",
"Đơn vị công tác": "",
"% đóng góp": "",
"Chữ ký xác nhận": ""
}
],
"Tổng % đóng góp": "100",
"Tác giả chính/Đại diện nhóm tác giả sáng kiến (chữ ký và ghi rõ họ tên)": ""
},
"MẪU SỐ 04 - PHIẾU ĐÁNH GIÁ SÁNG KIẾN": {
"1. Tên sáng kiến": "",
"2. Tác giả/đồng tác giả sáng kiến": "",
"Chức vụ, đơn vị công tác": "",
"3. Nội dung đánh giá": {
"Tính mới (Tối đa 40 điểm)": {
"Nhận xét": "",
"Điểm chấm": ""
},
"Tính hiệu quả (Tối đa 60 điểm)": {
"Nhận xét": "",
"Điểm chấm": ""
},
"Tổng cộng": ""
},
"Kết luận": "",
"Ngày ký": {
"Ngày": "",
"Tháng": "",
"Năm": ""
},
"Thành viên Hội đồng (Ký, ghi rõ họ tên)": ""
},
"BẢN CAM KẾT": {
"Ngày ký": {
"Ngày": "",
"Tháng": "",
"Năm": ""
},
"Tiêu đề phụ (Áp dụng đối với cá nhân đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại Đại học Y Dược TP. Hồ Chí Minh là tác giả của bài báo khoa học)": "",
"I. THÔNG TIN CHỦ THỂ CAM KẾT": {
"Tác giả đăng ký sáng kiến": "",
"CCCD/Hộ chiếu số": "",
"Đơn vị": "",
"Tên Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH": "",
"Năm xét công nhận sáng kiến": "",
"Vai trò đối với bài báo (☑ vào ô tương ứng)": {
"Tác giả chính Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH": false,
"Đồng tác giả Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH": false
}
},
"II. CAM KẾT NỘI DUNG (☑ vào ô tương ứng)": {
"1. Quyền sở hữu đối với bài báo trong nước/quốc tế": {
"Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo có tên nêu trên làm sản phẩm đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD": false,
"Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD": false
},
"2. Đồng thuận của đồng tác giả bài báo trong nước/quốc tế": {
"Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến được sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD": false
},
"3. Cam kết bài báo trong nước/quốc tế uy tín": {
"Cá nhân đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD đối với bài báo trong nước/quốc tế cam kết bài báo không thuộc 'Tạp chí săn mồi'. Tôi xin chịu trách nhiệm kiểm tra, đối chiếu và cung cấp bằng chứng khi được yêu cầu": false
},
"4. Tuân thủ pháp luật sở hữu trí tuệ": {
"Tôi cam kết rằng việc sử dụng bài báo đăng ký xét công nhận sáng kiến tại ĐHYD sẽ không gây tranh chấp về: quyền tác giả/quyền liên quan, quyền sở hữu công nghiệp, tiết lộ bí mật kinh doanh, vi phạm bảo mật dữ liệu của bất kỳ bên thứ ba nào. Tôi chịu trách nhiệm trước pháp luật về tính trung thực, hợp pháp của hồ sơ": false
}
},
"III. HẬU QUẢ PHÁP LÝ KHI THÔNG TIN KHÔNG TRUNG THỰC": "Tôi xin cam kết chịu trách nhiệm đối với các thông tin kê khai nêu trên. Nếu thông tin được khai trong bản cam kết này không đúng thì tôi chấp nhận: Hủy kết quả công nhận sáng kiến đã được xét (nếu có); Thu hồi, hủy các danh hiệu thi đua, khen thưởng, hoặc các quyền lợi phát sinh có sử dụng sáng kiến này để xét; Xử lý theo quy định pháp luật hiện hành và theo quy chế/quy định của ĐHYD. Cam kết này có hiệu lực kể từ ngày ký và ràng buộc đối với cá nhân cam kết trong suốt thời gian xét công nhận sáng kiến và sau khi kết thúc 02 năm.",
"Người cam kết (Ký tên, ghi rõ họ tên)": ""
},
"BẢN XÁC NHẬN TÀI LIỆU THAM KHẢO (2.2.2)": {
"Ngày ký": {
"Ngày": "",
"Tháng": "",
"Năm": ""
},
"Tiêu đề phụ (Áp dụng đối với cá nhân đăng ký minh chứng là tài liệu tham khảo nhóm 2.2.2)": "",
"I. THÔNG TIN ĐĂNG KÝ": {
"Tác giả đăng ký sáng kiến": "",
"CCCD/Hộ chiếu số": "",
"Đơn vị": "",
"Tên tài liệu tham khảo (theo Quyết định xuất bản)": "",
"Năm xét công nhận sáng kiến": ""
},
"II. XÁC NHẬN VÀ CAM KẾT (☑ vào ô tương ứng)": {
"1. Trung thực thông tin và minh chứng": {
"Tôi cam đoan các thông tin kê khai và minh chứng đính kèm đối với tài liệu tham khảo là trung thực, đúng sự thật và phù hợp với Quyết định xuất bản trong giai đoạn quy định (15/4/202515/4/2026).": false
},
"2. Trách nhiệm pháp luật": {
"Tôi hoàn toàn chịu trách nhiệm trước pháp luật và trước nhà trường về tính hợp pháp của tài liệu và nội dung đăng ký.": false
},
"3. Bổ sung hồ sơ khi được yêu cầu": {
"Tôi đồng ý bổ sung hoặc chỉnh sửa hồ sơ khi được yêu cầu.": false
}
},
"Người cam kết (Ký tên, ghi rõ họ tên)": ""
},
"BẢN XÁC NHẬN BÀI BÁO TRONG NƯỚC (2.1.2)": {
"Ngày ký": {
"Ngày": "",
"Tháng": "",
"Năm": ""
},
"Tiêu đề phụ (Áp dụng đối với cá nhân đăng ký minh chứng là bài báo tạp chí trong nước nhóm 2.1.2)": "",
"I. THÔNG TIN ĐĂNG KÝ": {
"Tác giả đăng ký sáng kiến": "",
"CCCD/Hộ chiếu số": "",
"Đơn vị": "",
"Tên bài báo (tạp chí trong nước, giai đoạn xuất bản quy định)": "",
"Năm xét công nhận sáng kiến": ""
},
"II. XÁC NHẬN VÀ CAM KẾT (☑ vào ô tương ứng)": {
"1. Trung thực thông tin và minh chứng": {
"Tôi cam đoan các thông tin kê khai và minh chứng đính kèm đối với bài báo trên tạp chí trong nước là trung thực, đúng sự thật và phù hợp với thời điểm xuất bản trong giai đoạn quy định (15/4/202515/4/2026).": false
},
"2. Trách nhiệm pháp luật": {
"Tôi hoàn toàn chịu trách nhiệm trước pháp luật và trước nhà trường về tính hợp pháp của bài báo và nội dung đăng ký.": false
},
"3. Bổ sung hồ sơ khi được yêu cầu": {
"Tôi đồng ý bổ sung hoặc chỉnh sửa hồ sơ khi được yêu cầu.": false
}
},
"Người cam kết (Ký tên, ghi rõ họ tên)": ""
}
}
@@ -0,0 +1,40 @@
import type { InitiativeDraft } from './types';
import { buildTemplateDataFromDraft } from './draftToTemplateData';
import {
toFullApplicationJson,
toFullContributionJson,
toFullReportJson,
} from './draftJsonFullShape';
import { buildOfficialBieuMauFromDraft } from './mapDraftToOfficialBieuMau';
/**
* Single JSON payload for Word templates + archival: flat `templateData` for placeholders,
* plus normalized `application` / `report` / `contribution` trees (same shape as per-tab exports).
*/
export function buildDocxTemplateExportBundle(
draft: InitiativeDraft,
contribution: Record<string, unknown> | null | undefined,
options?: { caseId?: string },
) {
const app = draft.application as unknown as Record<string, unknown>;
const rep = draft.report as unknown as Record<string, unknown>;
const cont = (contribution ?? {}) as Record<string, unknown>;
return {
meta: {
exportedAt: new Date().toISOString(),
caseId: options?.caseId ?? null,
draftId: draft.draftId,
draftUpdatedAt: draft.updatedAt,
version: 1 as const,
description:
'templateData: flat keys for DOCX placeholders; application/report/contribution: full form trees; officialBieuMau: nested Vietnamese keys aligned with public/assets/bieu_mau_sang_kien_template.json and be01.',
},
templateData: buildTemplateDataFromDraft(draft, contribution),
/** Nested official form — same semantics as `bieu_mau_sang_kien_template.json`; backend can store or convert to be01 `data_blank` snake_case. */
officialBieuMau: buildOfficialBieuMauFromDraft(draft, contribution),
application: toFullApplicationJson(app),
report: toFullReportJson(rep),
contribution: toFullContributionJson(cont),
};
}
@@ -0,0 +1,30 @@
import type { InitiativeDraft } from './types';
import { emptyInitiativeDraft } from './types';
import type { ApplicationFormState, InitiativeFormState } from './initiativeFormTypes';
import type { ReviewDraftTabs } from './reviewTabs';
/**
* Builds an {@link InitiativeDraft} from persisted review tabs so DOCX preview matches applicant ReviewPanel data.
*/
export function buildInitiativeDraftFromReviewTabs(caseId: string, tabs: ReviewDraftTabs | undefined): InitiativeDraft {
const base = emptyInitiativeDraft(caseId.trim() || 'review');
const app = tabs?.application as Partial<ApplicationFormState> | undefined;
const rep = tabs?.report as Partial<InitiativeFormState> | undefined;
return {
...base,
draftId: caseId.trim() || base.draftId,
updatedAt: new Date().toISOString(),
application: {
...base.application,
...(app ?? {}),
textbookEvidenceFile: null,
researchEvidenceFile: null,
technicalEvidenceFile: null,
},
report: {
...base.report,
...(rep ?? {}),
},
};
}
@@ -0,0 +1,16 @@
/** Shapes the contribution / « Xác nhận tỷ lệ » tab (`ContributionConfirmationForm`) store for Review + API. */
export interface ContributionDraftShape {
initiativeName?: string;
mainAuthor?: string;
position?: string;
representativePercent?: number;
submissionDate?: string | Date;
participants?: Array<{
id: number;
fullName: string;
workUnit: string;
contributionPercent: number;
isEditing?: boolean;
}>;
digitalSignatureConfirmed?: boolean;
}
@@ -0,0 +1,60 @@
import type { DraftContextValue } from './DraftContext';
import type { InitiativeDraft } from './types';
import type { Author } from './initiativeFormTypes';
function defaultFirstAuthor(): Author {
return {
id: 1,
name: '',
dob: '',
workplace: '',
title: '',
qualification: '',
contributionPercent: 100,
};
}
/**
* Display name for «tác giả chính / đại diện nhóm tác giả sáng kiến»: Đơn tác giả thứ nhất if set, else báo cáo §2.2.
*/
export function canonicalMainAuthorFromDraft(draft: InitiativeDraft): string {
const first = draft.application.authors[0]?.name;
if (typeof first === 'string' && first.trim()) return first.trim();
return (draft.report.representativeAuthor ?? '').trim();
}
/** Writes báo cáo §2.2 + dòng ký (`authorName`) và Đơn `authors[0].name` cùng một lúc. */
export function pushMainAuthorToDraft(draftCtx: DraftContextValue, name: string): void {
draftCtx.updateReport({ representativeAuthor: name, authorName: name });
const authors = draftCtx.draft.application.authors;
const first = authors[0] ?? defaultFirstAuthor();
const rest = authors.length > 0 ? authors.slice(1) : [];
void draftCtx.updateApplication({
authors: [{ ...first, name }, ...rest],
});
}
/**
* If Đơn and báo cáo drift, prefer Đơn tên tác giả 1; if Đơn trống, copy from báo cáo.
*/
export function reconcileRepresentativeAuthorSlices(draftCtx: DraftContextValue, readOnly: boolean): void {
if (readOnly) return;
const draft = draftCtx.draft;
const a0 = draft.application.authors[0];
const rawFirst = typeof a0?.name === 'string' ? a0.name : '';
const firstName = rawFirst.trim();
const reportRep = draft.report.representativeAuthor?.trim() ?? '';
const reportAuth = draft.report.authorName?.trim() ?? '';
if (firstName && (reportRep !== firstName || reportAuth !== firstName)) {
draftCtx.updateReport({ representativeAuthor: rawFirst, authorName: rawFirst });
return;
}
if (!firstName && reportRep) {
const authors = draft.application.authors;
const first = authors[0] ?? defaultFirstAuthor();
const rest = authors.length > 0 ? authors.slice(1) : [];
void draftCtx.updateApplication({
authors: [{ ...first, name: reportRep }, ...rest],
});
}
}
@@ -0,0 +1,288 @@
import { renderAsync } from 'docx-preview';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { injectDocxJustifyMitigationStyles } from '../lib/docxJustifyMitigationCss';
import { injectDocxTableReflowStyles } from '../lib/docxTableReflow';
import { SHARED_DOCX_OFFICIAL_FORM_RENDER_OPTIONS } from '../lib/sharedDocxOfficialFormRenderOptions';
const PX_TO_MM = 25.4 / 96;
async function waitForRenderableAssets(container: HTMLElement): Promise<void> {
const images = Array.from(container.querySelectorAll<HTMLImageElement>('img'));
if (images.length === 0) return;
await Promise.all(
images.map(async (img) => {
if (img.complete) return;
if (typeof img.decode === 'function') {
try {
await img.decode();
return;
} catch {
// Fallback to load/error listeners below.
}
}
await new Promise<void>((resolve) => {
const done = () => resolve();
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
});
}),
);
}
/**
* Matches `docx-to-pdf-demo.html`: `.shell { max-width }` + `.preview { padding }`.
* Visible preview and off-screen capture use the same numbers so line breaks and tables match.
*/
export const DOCX_PDF_PREVIEW_SHELL_MAX_WIDTH_PX = 980;
export const DOCX_PDF_PREVIEW_INNER_PADDING_PX = 28;
export type ConvertDocxToPdfOptions = {
/** html2canvas scale. Default 2. */
renderScale?: number;
/** JPEG quality 01 when not lossless. Default 0.95. */
imageQuality?: number;
/** PNG page images in the PDF instead of JPEG. */
losslessImages?: boolean;
/** Fired before `renderAsync` (docx-preview). */
onPhaseRendering?: () => void;
/** Fired after layout settle, before per-page capture. */
onPhaseCapturing?: (pageCount: number) => void;
/** After each page is rasterised (`current` is 1-based). */
onCaptureProgress?: (current: number, total: number) => void;
/**
* Extra ms after layout settles (tables/fonts). Mirrors docx-to-pdf-demo.html (120).
*/
layoutSettleExtraMs?: number;
/** Optional callback with render-layout signals for QA/advisory UI. */
onLayoutAnalysed?: (insights: ConvertDocxToPdfLayoutInsights) => void;
};
export type ConvertDocxToPdfLayoutInsights = {
/** True when the rendered page appears to include absolute-positioned drawing/shape elements. */
hasFloatingShapeCandidates: boolean;
/** True when Times New Roman is unavailable; capture CSS still uses a serif stack. */
appliedTimesFallbackOverride: boolean;
};
function isTimesNewRomanAvailable(): boolean {
if (!document.fonts || typeof document.fonts.check !== 'function') return true;
try {
return document.fonts.check('16px "Times New Roman"');
} catch {
return true;
}
}
/** Word-accurate typography for raster PDF: force serif stack (html2canvas often used system sans otherwise). */
function injectCaptureTypographyStyles(scope: HTMLElement): void {
if (scope.querySelector('style[data-docx-capture-typography="1"]')) return;
if (!scope.dataset.docxCaptureRoot) scope.dataset.docxCaptureRoot = '1';
const style = document.createElement('style');
style.setAttribute('data-docx-capture-typography', '1');
style.textContent = `
[data-docx-capture-root] .docx-wrapper,
[data-docx-capture-root] .docx-wrapper * {
font-family: "Times New Roman", Times, "Liberation Serif", "Noto Serif", serif !important;
}
`;
scope.appendChild(style);
}
/**
* docx-preview can keep « BỘ Y TẾ » bold (style / strong) and may leak italic from inherited
* styles even when the OOXML run is regular. Normalize the first page letterhead in the DOM
* so the rasterised PDF matches the official template:
*
* - « BỘ Y TẾ » → regular (400), upright
* - « ĐẠI HỌC Y DƯỢC » / « THÀNH PHỐ HỒ CHÍ MINH » → bold (700), upright
*
* The source DOCX contains the typo « ĐẠI HỘC »; match both spellings so we keep working if
* the template is ever corrected.
*/
function normalizeOfficialFormCoverForPdfCapture(root: HTMLElement): void {
const section = root.querySelector<HTMLElement>('section.docx');
if (!section) return;
const setLetterheadTypography = (el: HTMLElement, weight: '400' | '700') => {
el.style.setProperty('font-weight', weight, 'important');
el.style.setProperty('font-style', 'normal', 'important');
el.querySelectorAll<HTMLElement>('*').forEach((c) => {
c.style.setProperty('font-weight', weight, 'important');
c.style.setProperty('font-style', 'normal', 'important');
});
};
const hasUniversity = (line: string) =>
line.includes('ĐẠI HỌC Y DƯỢC') || line.includes('ĐẠI HỘC Y DƯỢC');
const isUniversityOnly = (line: string) =>
line === 'ĐẠI HỌC Y DƯỢC' || line === 'ĐẠI HỘC Y DƯỢC';
const paras = section.querySelectorAll<HTMLElement>('p');
for (const el of paras) {
const line = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
if (line === 'BỘ Y TẾ') {
el.style.setProperty('text-align', 'center', 'important');
setLetterheadTypography(el, '400');
continue;
}
const isUniversity =
(hasUniversity(line) && line.includes('THÀNH PHỐ HỒ CHÍ MINH')) ||
isUniversityOnly(line) ||
line === 'THÀNH PHỐ HỒ CHÍ MINH';
if (isUniversity) {
el.style.setProperty('text-align', 'center', 'important');
setLetterheadTypography(el, '700');
}
}
}
function hasFloatingShapeCandidates(container: HTMLElement): boolean {
const obvious = container.querySelector(
'.docx-drawing, [data-anchor], [data-wrap], [style*="position:absolute"]',
);
if (obvious) return true;
const all = Array.from(container.querySelectorAll<HTMLElement>('.docx *'));
for (const el of all) {
const style = window.getComputedStyle(el);
if (style.position !== 'absolute') continue;
if (el.querySelector('svg, canvas, img') || el.tagName.toLowerCase() === 'svg') {
return true;
}
}
return false;
}
/**
* Creates a body-mounted host positioned off-screen so html2canvas can capture
* without `display` / `visibility` / `opacity` hiding the tree (PDF_converter.md §7 Rule 2).
*/
export function createOffScreenDocxCaptureHost(): HTMLDivElement {
const host = document.createElement('div');
host.setAttribute('aria-hidden', 'true');
host.setAttribute('inert', '');
Object.assign(host.style, {
position: 'fixed',
left: '-100000px',
top: '0',
boxSizing: 'border-box',
width: `${DOCX_PDF_PREVIEW_SHELL_MAX_WIDTH_PX}px`,
maxWidth: `${DOCX_PDF_PREVIEW_SHELL_MAX_WIDTH_PX}px`,
padding: `${DOCX_PDF_PREVIEW_INNER_PADDING_PX}px`,
pointerEvents: 'none',
overflow: 'visible',
backgroundColor: '#ffffff',
});
document.body.appendChild(host);
return host;
}
/**
* Renders a .docx into `container`, then rasterises each `section` page to a multi-page PDF.
* Clears `container.innerHTML` before rendering. The element must be attached to the document.
*/
export async function convertDocxToPdfBlob(
source: Blob | File,
container: HTMLElement,
options: ConvertDocxToPdfOptions = {},
): Promise<Blob> {
const renderScale = options.renderScale ?? 2;
const imageQuality = options.imageQuality ?? 0.95;
const losslessImages = options.losslessImages ?? false;
const layoutSettleExtraMs = options.layoutSettleExtraMs ?? 120;
options.onPhaseRendering?.();
container.innerHTML = '';
container.dataset.docxCaptureRoot = '1';
await renderAsync(source, container, undefined, {
...SHARED_DOCX_OFFICIAL_FORM_RENDER_OPTIONS,
ignoreLastRenderedPageBreak: false,
useBase64URL: true,
renderHeaders: true,
renderFooters: true,
renderFootnotes: true,
});
injectDocxTableReflowStyles(container, { pdfPreviewChrome: true });
injectCaptureTypographyStyles(container);
injectDocxJustifyMitigationStyles(container);
normalizeOfficialFormCoverForPdfCapture(container);
const appliedTimesFallbackOverride = !isTimesNewRomanAvailable();
try {
await (document.fonts?.ready ?? Promise.resolve());
} catch {
/* ignore */
}
await waitForRenderableAssets(container);
await new Promise<void>((r) => requestAnimationFrame(() => r()));
await new Promise<void>((r) => requestAnimationFrame(() => r()));
await new Promise<void>((r) => setTimeout(r, layoutSettleExtraMs));
normalizeOfficialFormCoverForPdfCapture(container);
options.onLayoutAnalysed?.({
hasFloatingShapeCandidates: hasFloatingShapeCandidates(container),
appliedTimesFallbackOverride,
});
let pages = Array.from(container.querySelectorAll<HTMLElement>('section.docx'));
if (pages.length === 0) {
pages = Array.from(container.querySelectorAll<HTMLElement>('section'));
}
if (pages.length === 0) {
throw new Error(
'docx-preview rendered the document but produced no <section> page elements.',
);
}
options.onPhaseCapturing?.(pages.length);
const imgType = losslessImages ? 'PNG' : 'JPEG';
let pdf: jsPDF | null = null;
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const canvas = await html2canvas(page, {
scale: renderScale,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
windowWidth: page.offsetWidth,
windowHeight: page.offsetHeight,
});
const cssWpx = canvas.width / renderScale;
const cssHpx = canvas.height / renderScale;
const pageWidthMm = cssWpx * PX_TO_MM;
const pageHeightMm = cssHpx * PX_TO_MM;
const imgData = canvas.toDataURL(
losslessImages ? 'image/png' : 'image/jpeg',
losslessImages ? undefined : imageQuality,
);
if (!pdf) {
pdf = new jsPDF({
orientation: pageWidthMm > pageHeightMm ? 'landscape' : 'portrait',
unit: 'mm',
format: [pageWidthMm, pageHeightMm],
compress: true,
});
} else {
pdf.addPage(
[pageWidthMm, pageHeightMm],
pageWidthMm > pageHeightMm ? 'landscape' : 'portrait',
);
}
pdf.addImage(imgData, imgType, 0, 0, pageWidthMm, pageHeightMm, undefined, 'FAST');
options.onCaptureProgress?.(i + 1, pages.length);
}
return pdf!.output('blob');
}
+233
View File
@@ -0,0 +1,233 @@
import { serializeDraftDataForExport } from './applicationDrafts';
import {
INITIAL_APPLICATION_FORM_STATE,
INITIAL_REPORT_FORM_STATE,
} from './initiativeFormTypes';
/** One empty support row so every SupportStaff field appears in JSON. */
const EMPTY_SUPPORT_STAFF = {
id: 1,
name: '',
dob: '',
workplace: '',
title: '',
qualification: '',
supportContent: '',
};
/** One empty trial unit row. */
const EMPTY_TRIAL_UNIT = {
id: 1,
name: '',
address: '',
field: '',
};
/** Participant row as persisted (no isEditing). */
const EMPTY_PARTICIPANT = {
id: 1,
fullName: '',
workUnit: '',
contributionPercent: 0,
};
function buildApplicationTemplate(): Record<string, unknown> {
const a = INITIAL_APPLICATION_FORM_STATE;
return {
unitName: a.unitName,
authors: a.authors.map((x) => ({ ...x })),
initiativeName: a.initiativeName,
investorName: a.investorName,
applicationField: a.applicationField,
firstApplyDate: a.firstApplyDate,
initiativeClassification: a.initiativeClassification,
textbookEvidenceFile: null,
textbookEvidenceKind: a.textbookEvidenceKind,
researchEvidenceKind: a.researchEvidenceKind,
researchEvidenceFile: null,
internationalJournalDeclaration: a.internationalJournalDeclaration,
banCamKet: a.banCamKet,
referenceMaterialHonesty: a.referenceMaterialHonesty,
researchDomesticHonesty: a.researchDomesticHonesty,
technicalEvidenceFile: null,
contentSummary: a.contentSummary,
confidentialInfo: a.confidentialInfo,
conditions: a.conditions,
authorEvaluation: a.authorEvaluation,
trialEvaluation: a.trialEvaluation,
supportStaff: a.supportStaff.length > 0 ? a.supportStaff.map((s) => ({ ...s })) : [{ ...EMPTY_SUPPORT_STAFF }],
honestyConfirmed: a.honestyConfirmed,
submissionDay: a.submissionDay,
submissionMonth: a.submissionMonth,
submissionYear: a.submissionYear,
};
}
function buildReportTemplate(): Record<string, unknown> {
const r = INITIAL_REPORT_FORM_STATE;
return {
introduction: r.introduction,
initiativeName: r.initiativeName,
representativeAuthor: r.representativeAuthor,
representativePhone: r.representativePhone,
representativeEmail: r.representativeEmail,
applicationField: r.applicationField,
currentStatus: r.currentStatus,
purpose: r.purpose,
solutionContent: r.solutionContent,
implementationSteps: r.implementationSteps,
firstAppliedUnit: r.firstAppliedUnit,
achievedResult: r.achievedResult,
conditions: r.conditions,
trialUnits: r.trialUnits.length > 0 ? r.trialUnits.map((u) => ({ ...u })) : [{ ...EMPTY_TRIAL_UNIT }],
novelty: r.novelty,
effectiveness: { ...r.effectiveness },
confidentialInfo: r.confidentialInfo,
submissionDate: r.submissionDate,
authorName: r.authorName,
honestyConfirmed: r.honestyConfirmed,
};
}
function buildContributionTemplate(): Record<string, unknown> {
return {
initiativeName: '',
mainAuthor: '',
position: '',
representativePercent: 0,
submissionDate: null,
participants: [{ ...EMPTY_PARTICIPANT }],
digitalSignatureConfirmed: false,
};
}
function mergeRows<T extends Record<string, unknown>>(
templateRow: T,
rawList: unknown,
): T[] {
if (!Array.isArray(rawList) || rawList.length === 0) return [{ ...templateRow }];
return rawList.map((row, i) => {
const o = row && typeof row === 'object' ? (row as Record<string, unknown>) : {};
return { ...templateRow, ...o, id: typeof o.id === 'number' ? o.id : i + 1 } as T;
});
}
function mergeAuthors(rawList: unknown): Record<string, unknown>[] {
const templateRows = INITIAL_APPLICATION_FORM_STATE.authors.map((x) => ({ ...x }));
const rowT = templateRows[0] as unknown as Record<string, unknown>;
return mergeRows(rowT, rawList);
}
function mergeSupportStaff(rawList: unknown): Record<string, unknown>[] {
return mergeRows({ ...EMPTY_SUPPORT_STAFF }, rawList);
}
function mergeTrialUnits(rawList: unknown): Record<string, unknown>[] {
return mergeRows({ ...EMPTY_TRIAL_UNIT }, rawList);
}
function mergeParticipants(rawList: unknown): Record<string, unknown>[] {
if (!Array.isArray(rawList) || rawList.length === 0) return [{ ...EMPTY_PARTICIPANT }];
return rawList.map((row, i) => {
const o = row && typeof row === 'object' ? (row as Record<string, unknown>) : {};
const { isEditing, ...rest } = o;
void isEditing;
return {
...EMPTY_PARTICIPANT,
...rest,
id: typeof o.id === 'number' ? o.id : i + 1,
};
});
}
function omitKeys(raw: Record<string, unknown>, keys: string[]): Record<string, unknown> {
const out = { ...raw };
for (const k of keys) delete out[k];
return out;
}
function mergeScalarRecord(template: Record<string, unknown>, raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...template };
for (const key of Object.keys(template)) {
if (key in raw && raw[key] !== undefined) {
const tv = template[key];
const rv = raw[key];
if (
tv !== null &&
typeof tv === 'object' &&
!Array.isArray(tv) &&
rv !== null &&
typeof rv === 'object' &&
!Array.isArray(rv)
) {
out[key] = mergeScalarRecord(tv as Record<string, unknown>, rv as Record<string, unknown>);
} else {
out[key] = rv;
}
}
}
return out;
}
/**
* Strings `''`, `undefined`, and empty arrays become `null` (booleans and numbers preserved,
* except contribution `representativePercent === 0` → `null`).
*/
function emptyToNullDeep(value: unknown, options?: { contributionRoot?: boolean }): unknown {
if (value === undefined || value === '') return null;
if (value === null) return null;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value;
if (Array.isArray(value)) {
if (value.length === 0) return null;
return value.map((v) => emptyToNullDeep(v));
}
if (value && typeof value === 'object') {
const o = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(o)) {
out[k] = emptyToNullDeep(v);
}
if (options?.contributionRoot && out.representativePercent === 0) {
out.representativePercent = null;
}
return out;
}
return value;
}
export function toFullApplicationJson(raw: Record<string, unknown>): Record<string, unknown> {
const template = buildApplicationTemplate();
const merged = mergeScalarRecord(template, omitKeys(raw, ['authors', 'supportStaff']));
merged.authors = mergeAuthors(raw.authors);
merged.supportStaff = mergeSupportStaff(raw.supportStaff);
const serialized = serializeDraftDataForExport(merged) as Record<string, unknown>;
return emptyToNullDeep(serialized) as Record<string, unknown>;
}
export function toFullReportJson(raw: Record<string, unknown>): Record<string, unknown> {
const template = buildReportTemplate();
const merged = mergeScalarRecord(template, omitKeys(raw, ['trialUnits']));
merged.trialUnits = mergeTrialUnits(raw.trialUnits);
const serialized = serializeDraftDataForExport(merged) as Record<string, unknown>;
return emptyToNullDeep(serialized) as Record<string, unknown>;
}
export function toFullContributionJson(raw: Record<string, unknown>): Record<string, unknown> {
const template = buildContributionTemplate();
const merged = mergeScalarRecord(template, omitKeys(raw, ['participants']));
merged.participants = mergeParticipants(raw.participants);
const serialized = serializeDraftDataForExport(merged) as Record<string, unknown>;
normalizeParticipantContributionZeros(serialized);
return emptyToNullDeep(serialized, { contributionRoot: true }) as Record<string, unknown>;
}
function normalizeParticipantContributionZeros(root: Record<string, unknown>): void {
const parts = root.participants;
if (!Array.isArray(parts)) return;
for (const row of parts) {
if (row && typeof row === 'object' && (row as Record<string, unknown>).contributionPercent === 0) {
(row as Record<string, unknown>).contributionPercent = null;
}
}
}
+68
View File
@@ -0,0 +1,68 @@
const DB_NAME = 'initiative-draft';
const STORE = 'files';
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(STORE)) {
req.result.createObjectStore(STORE);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function withStore<T>(
mode: IDBTransactionMode,
fn: (s: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const db = await openDb();
return new Promise<T>((resolve, reject) => {
const tx = db.transaction(STORE, mode);
const req = fn(tx.objectStore(STORE));
req.onsuccess = () => resolve(req.result as T);
req.onerror = () => reject(req.error);
});
}
export const fileStore = {
put: (key: string, blob: Blob) => withStore('readwrite', (s) => s.put(blob, key)),
get: (key: string) => withStore<Blob | undefined>('readonly', (s) => s.get(key)),
del: (key: string) => withStore('readwrite', (s) => s.delete(key)),
clearPrefix: async (prefix: string) => {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE, 'readwrite');
const store = tx.objectStore(STORE);
const r = store.getAllKeys();
r.onsuccess = () => {
const keys = (r.result as IDBValidKey[]).filter(
(k) => typeof k === 'string' && k.startsWith(prefix),
);
for (const k of keys) store.delete(k);
};
r.onerror = () => reject(r.error);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
};
export const snapshotStore = {
load<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
},
save<T>(key: string, value: T) {
localStorage.setItem(key, JSON.stringify(value));
},
clear(key: string) {
localStorage.removeItem(key);
},
};
@@ -0,0 +1,122 @@
import type { InitiativeDraft } from './types';
import type { DataRecord } from './templateTypes';
import type { ApplicationFormState } from './initiativeFormTypes';
import {
authorByIdMap,
resolveDonViCongTacDisplay,
resolveWorkUnitForContributionRow,
} from './mergeContributionWorkUnits';
interface ContributionLike {
initiativeName?: string;
mainAuthor?: string;
position?: string;
representativePercent?: number;
participants?: Array<{
id?: number;
fullName?: string;
workUnit?: string;
contributionPercent?: number;
}>;
}
/**
* Maps the current initiative draft (+ optional contribution blob) into flat strings
* for common DOCX placeholders. Adjust your .docx template to use these names, or
* edit values manually in Template Filler after upload.
*/
export function buildTemplateDataFromDraft(
draft: InitiativeDraft,
contribution?: Record<string, unknown> | null,
): DataRecord {
const a = draft.application;
const r = draft.report;
const c = (contribution ?? {}) as ContributionLike;
const app = a as ApplicationFormState;
const donViResolved = resolveDonViCongTacDisplay(app, c);
const contributionPositionResolved =
(app.authors[0]?.workplace != null && String(app.authors[0].workplace).trim()) ||
(c.position != null && String(c.position).trim()) ||
app.unitName ||
'';
const authorsLines = a.authors
.map((x) => `${x.name}${x.title} (${x.contributionPercent}%) · ${x.workplace}`)
.join('\n');
const trialLines =
r.trialUnits?.map((u) => `${u.name}${u.address}`).join('\n') ?? '';
const supportLines =
a.supportStaff?.map((s) => `${s.name}: ${s.supportContent}`).join('\n') ?? '';
const authorIds = authorByIdMap(app.authors);
const participantsLoop =
c.participants?.map((p) => ({
fullName: p.fullName ?? '',
workUnit: resolveWorkUnitForContributionRow(authorIds, p),
contributionPercent: String(p.contributionPercent ?? ''),
})) ?? [];
const authorsLoop = app.authors.map((x) => ({
name: x.name,
title: x.title,
workplace: x.workplace,
contributionPercent: String(x.contributionPercent),
}));
const data: DataRecord = {
initiativeName: a.initiativeName || r.initiativeName || '',
initiativeNameReport: r.initiativeName || '',
unitName: donViResolved,
investorName: a.investorName,
applicationField: a.applicationField || r.applicationField,
firstApplyDate: a.firstApplyDate,
contentSummary: a.contentSummary,
confidentialInfoApp: a.confidentialInfo,
conditionsApp: a.conditions,
authorEvaluation: a.authorEvaluation,
trialEvaluation: a.trialEvaluation,
authorsList: authorsLines,
representativeAuthor: r.representativeAuthor,
representativePhone: r.representativePhone,
representativeEmail: r.representativeEmail,
introduction: r.introduction,
currentStatus: r.currentStatus,
purpose: r.purpose,
solutionContent: r.solutionContent,
implementationSteps: r.implementationSteps,
firstAppliedUnit: r.firstAppliedUnit,
achievedResult: r.achievedResult,
conditionsReport: r.conditions,
novelty: r.novelty,
trialUnitsList: trialLines,
supportStaffList: supportLines,
effectivenessEconomic: r.effectiveness.economic,
effectivenessSocial: r.effectiveness.social,
effectivenessTeaching: r.effectiveness.teaching,
effectivenessProductivity: r.effectiveness.productivity,
effectivenessQuality: r.effectiveness.quality,
effectivenessEnvironment: r.effectiveness.environment,
effectivenessSafety: r.effectiveness.safety,
confidentialInfoReport: r.confidentialInfo,
submissionDate: r.submissionDate,
authorName: r.authorName,
contributionInitiativeName: c.initiativeName ?? '',
contributionMainAuthor: c.mainAuthor ?? '',
contributionPosition: contributionPositionResolved,
contributionRepresentativePercent:
typeof c.representativePercent === 'number' ? String(c.representativePercent) : '',
};
if (authorsLoop.length > 0) {
data.authors = authorsLoop as unknown as DataRecord[];
}
if (participantsLoop.length > 0) {
data.participants = participantsLoop as unknown as DataRecord[];
}
return data;
}
@@ -0,0 +1,415 @@
/** Shared form state types for application + report (used by forms and initiative-draft). */
import { formatDdMmYyyy } from '../lib/vnDateFormat';
export interface Author {
id: number;
name: string;
dob: string;
workplace: string;
title: string;
qualification: string;
contributionPercent: number;
}
export interface SupportStaff {
id: number;
name: string;
dob: string;
workplace: string;
title: string;
qualification: string;
supportContent: string;
}
export type ResearchEvidenceKind =
| ''
| 'international'
| 'domestic'
| 'poster'
| 'poster-without-review';
/** Nhóm 2.2 — Xuất sắc / sách giáo trình (mã nội bộ 2.2.1, bản cam kết tác giả); 2.2.2 tài liệu tham khảo (biểu xác nhận riêng). */
export type TextbookEvidenceKind = '' | 'book' | 'reference';
/** Chỉ một loại được chọn tại một thời điểm */
export type InitiativeClassification = null | 'technical' | 'research' | 'textbook';
/** Ngày ký — chuỗi để giữ số 0 đầu (theo `data_blank.json` / docx). */
export interface NgayKy {
ngay: string;
thang: string;
nam: string;
}
export interface VaiTroBaiBao {
tac_gia_chinh: boolean;
dong_tac_gia: boolean;
}
export interface CamKetBaiBao {
quyen_so_huu_1: boolean;
quyen_so_huu_2: boolean;
dong_thuan: boolean;
bai_bao_uy_tin: boolean;
tuan_thu_phap_luat: boolean;
}
/** Bản cam kết tác giả bài báo (mục 3.6 hướng dẫn / `ban_cam_ket` trong `data_blank.json`). */
export interface BanCamKet {
ngay_ky: NgayKy;
tac_gia_dang_ky: string;
cccd: string;
don_vi: string;
ten_bai_bao: string;
nam_xet: string;
vai_tro: VaiTroBaiBao;
cam_ket: CamKetBaiBao;
nguoi_cam_ket: string;
}
const EMPTY_BAN_CAM_KET: BanCamKet = {
ngay_ky: { ngay: '', thang: '', nam: '' },
tac_gia_dang_ky: '',
cccd: '',
don_vi: '',
ten_bai_bao: '',
nam_xet: '',
vai_tro: { tac_gia_chinh: false, dong_tac_gia: false },
cam_ket: {
quyen_so_huu_1: false,
quyen_so_huu_2: false,
dong_thuan: false,
bai_bao_uy_tin: false,
tuan_thu_phap_luat: false,
},
nguoi_cam_ket: '',
};
export function createEmptyBanCamKet(): BanCamKet {
return {
...EMPTY_BAN_CAM_KET,
ngay_ky: { ...EMPTY_BAN_CAM_KET.ngay_ky },
vai_tro: { ...EMPTY_BAN_CAM_KET.vai_tro },
cam_ket: { ...EMPTY_BAN_CAM_KET.cam_ket },
};
}
export function ensureBanCamKet(raw: Partial<BanCamKet> | null | undefined): BanCamKet {
if (!raw) return createEmptyBanCamKet();
return {
...EMPTY_BAN_CAM_KET,
...raw,
ngay_ky: { ...EMPTY_BAN_CAM_KET.ngay_ky, ...raw.ngay_ky },
vai_tro: { ...EMPTY_BAN_CAM_KET.vai_tro, ...raw.vai_tro },
cam_ket: { ...EMPTY_BAN_CAM_KET.cam_ket, ...raw.cam_ket },
};
}
/** Đủ điều kiện coi là đã hoàn tất bản cam kết (bài báo quốc tế). */
export function isBanCamKetComplete(b: BanCamKet): boolean {
if (!b.tac_gia_dang_ky.trim() || !b.don_vi.trim() || !b.ten_bai_bao.trim() || !b.nguoi_cam_ket.trim()) {
return false;
}
const cccdDigits = b.cccd.replace(/\D/g, '');
if (cccdDigits.length < 8 || cccdDigits.length > 12) return false;
if (!/^\d{4}$/.test(b.nam_xet.trim())) return false;
if (Number(b.vai_tro.tac_gia_chinh) + Number(b.vai_tro.dong_tac_gia) !== 1) return false;
if (!b.cam_ket.quyen_so_huu_1 && !b.cam_ket.quyen_so_huu_2) return false;
if (!b.cam_ket.dong_thuan || !b.cam_ket.bai_bao_uy_tin || !b.cam_ket.tuan_thu_phap_luat) {
return false;
}
if (!String(b.ngay_ky.ngay).trim() || !String(b.ngay_ky.thang).trim() || !/^\d{4}$/.test(String(b.ngay_ky.nam).trim())) {
return false;
}
return true;
}
/** Biểu xác minh trung thực — nhóm 2.2.2 (tài liệu tham khảo); lưu trong JSON tab Đơn (không phải file MinIO). */
export interface ReferenceMaterialHonesty {
ngay_ky: NgayKy;
tac_gia_dang_ky: string;
cccd: string;
don_vi: string;
/** Theo Quyết định xuất bản (không nhập toàn bộ nội dung tài liệu). */
ten_tai_lieu: string;
nam_xet: string;
cam_ket: {
thong_tin_trung_thuc: boolean;
trach_nhiem_phap_luat: boolean;
bo_sung_khi_yeu_cau: boolean;
};
nguoi_cam_ket: string;
}
const EMPTY_REFERENCE_HONESTY: ReferenceMaterialHonesty = {
ngay_ky: { ngay: '', thang: '', nam: '' },
tac_gia_dang_ky: '',
cccd: '',
don_vi: '',
ten_tai_lieu: '',
nam_xet: '',
cam_ket: {
thong_tin_trung_thuc: false,
trach_nhiem_phap_luat: false,
bo_sung_khi_yeu_cau: false,
},
nguoi_cam_ket: '',
};
export function createEmptyReferenceMaterialHonesty(): ReferenceMaterialHonesty {
return {
...EMPTY_REFERENCE_HONESTY,
ngay_ky: { ...EMPTY_REFERENCE_HONESTY.ngay_ky },
cam_ket: { ...EMPTY_REFERENCE_HONESTY.cam_ket },
};
}
export function ensureReferenceMaterialHonesty(
raw: Partial<ReferenceMaterialHonesty> | null | undefined,
): ReferenceMaterialHonesty {
if (!raw) return createEmptyReferenceMaterialHonesty();
return {
...EMPTY_REFERENCE_HONESTY,
...raw,
ngay_ky: { ...EMPTY_REFERENCE_HONESTY.ngay_ky, ...raw.ngay_ky },
cam_ket: { ...EMPTY_REFERENCE_HONESTY.cam_ket, ...raw.cam_ket },
};
}
export function isReferenceMaterialHonestyComplete(h: ReferenceMaterialHonesty): boolean {
if (!h.tac_gia_dang_ky.trim() || !h.don_vi.trim() || !h.ten_tai_lieu.trim() || !h.nguoi_cam_ket.trim()) {
return false;
}
const cccdDigits = h.cccd.replace(/\D/g, '');
if (cccdDigits.length < 8 || cccdDigits.length > 12) return false;
if (!/^\d{4}$/.test(h.nam_xet.trim())) return false;
if (
!h.cam_ket.thong_tin_trung_thuc ||
!h.cam_ket.trach_nhiem_phap_luat ||
!h.cam_ket.bo_sung_khi_yeu_cau
) {
return false;
}
if (!String(h.ngay_ky.ngay).trim() || !String(h.ngay_ky.thang).trim() || !/^\d{4}$/.test(String(h.ngay_ky.nam).trim())) {
return false;
}
return true;
}
/** Khóa trong `bieu_mau_sang_kien_template.json` → `BẢN XÁC NHẬN BÀI BÁO TRONG NƯỚC (2.1.2)` (tiêu đề phụ). */
export const RESEARCH_DOMESTIC_HONESTY_TIEU_DE_PHU_TEMPLATE_KEY =
'Tiêu đề phụ (Áp dụng đối với cá nhân đăng ký minh chứng là bài báo tạp chí trong nước nhóm 2.1.2)';
/** Biểu xác nhận — nhóm 2.1.2 (bài báo tạp chí trong nước); JSON tab Đơn; PDF vẫn `researchEvidenceFile` → MinIO `kind=research`. */
export interface ResearchDomesticHonesty {
ngay_ky: NgayKy;
/** Đồng bộ trường tiêu đề phụ trong mẫu DOCX / `research_domestic_honesty.tieu_de_phu` (be01). */
tieu_de_phu: string;
tac_gia_dang_ky: string;
cccd: string;
don_vi: string;
ten_bai_bao: string;
nam_xet: string;
cam_ket: {
thong_tin_trung_thuc: boolean;
trach_nhiem_phap_luat: boolean;
bo_sung_khi_yeu_cau: boolean;
};
nguoi_cam_ket: string;
}
const EMPTY_DOMESTIC_HONESTY: ResearchDomesticHonesty = {
ngay_ky: { ngay: '', thang: '', nam: '' },
tieu_de_phu: '',
tac_gia_dang_ky: '',
cccd: '',
don_vi: '',
ten_bai_bao: '',
nam_xet: '',
cam_ket: {
thong_tin_trung_thuc: false,
trach_nhiem_phap_luat: false,
bo_sung_khi_yeu_cau: false,
},
nguoi_cam_ket: '',
};
export function createEmptyResearchDomesticHonesty(): ResearchDomesticHonesty {
return {
...EMPTY_DOMESTIC_HONESTY,
ngay_ky: { ...EMPTY_DOMESTIC_HONESTY.ngay_ky },
cam_ket: { ...EMPTY_DOMESTIC_HONESTY.cam_ket },
};
}
export function ensureResearchDomesticHonesty(
raw: Partial<ResearchDomesticHonesty> | null | undefined,
): ResearchDomesticHonesty {
if (!raw) return createEmptyResearchDomesticHonesty();
return {
...EMPTY_DOMESTIC_HONESTY,
...raw,
ngay_ky: { ...EMPTY_DOMESTIC_HONESTY.ngay_ky, ...raw.ngay_ky },
cam_ket: { ...EMPTY_DOMESTIC_HONESTY.cam_ket, ...raw.cam_ket },
};
}
export function isResearchDomesticHonestyComplete(h: ResearchDomesticHonesty): boolean {
if (!h.tac_gia_dang_ky.trim() || !h.don_vi.trim() || !h.ten_bai_bao.trim() || !h.nguoi_cam_ket.trim()) {
return false;
}
const cccdDigits = h.cccd.replace(/\D/g, '');
if (cccdDigits.length < 8 || cccdDigits.length > 12) return false;
if (!/^\d{4}$/.test(h.nam_xet.trim())) return false;
if (
!h.cam_ket.thong_tin_trung_thuc ||
!h.cam_ket.trach_nhiem_phap_luat ||
!h.cam_ket.bo_sung_khi_yeu_cau
) {
return false;
}
if (!String(h.ngay_ky.ngay).trim() || !String(h.ngay_ky.thang).trim() || !/^\d{4}$/.test(String(h.ngay_ky.nam).trim())) {
return false;
}
return true;
}
export interface ApplicationFormState {
unitName: string;
authors: Author[];
initiativeName: string;
investorName: string;
applicationField: string;
firstApplyDate: string;
initiativeClassification: InitiativeClassification;
/** Minh chứng PDF — sách / giáo trình / tài liệu (nhóm 2.2), một tệp trên MinIO (`kind=textbook`). */
textbookEvidenceFile: File | null;
/** Phân nhánh Xuất sắc (sách/giáo trình) / 2.2.2 — độc lập với {@link researchEvidenceKind}. */
textbookEvidenceKind: TextbookEvidenceKind;
/** Loại minh chứng cho hồ sơ nghiên cứu */
researchEvidenceKind: ResearchEvidenceKind;
researchEvidenceFile: File | null;
/** Tuyên bố tạp chí (legacy) — ưu tiên hiển thị `banCamKet` khi đã điền. */
internationalJournalDeclaration: string;
/** Bản cam kết tác giả (2.1.1 và Xuất sắc — Sách, giáo trình) — cấu trúc theo `ban_cam_ket` / hướng dẫn TypeScript. */
banCamKet: BanCamKet;
/** Xác minh trung thực — nhóm 2.2.2 (`reference_material_honesty` trong data_blank / DOCX). */
referenceMaterialHonesty: ReferenceMaterialHonesty;
/** Biểu xác nhận — nhóm 2.1.2 (`research_domestic_honesty` trong data_blank / DOCX). */
researchDomesticHonesty: ResearchDomesticHonesty;
/** Nhóm 01: văn bản chính thức do lãnh đạo đơn vị xác nhận */
technicalEvidenceFile: File | null;
contentSummary: string;
confidentialInfo: string;
conditions: string;
authorEvaluation: string;
trialEvaluation: string;
supportStaff: SupportStaff[];
/** Cam kết trung thực (§ cuối Đơn) — lưu trong bản nháp / JSON tab. */
honestyConfirmed: boolean;
submissionDay: number;
submissionMonth: number;
submissionYear: string;
}
export interface TrialUnit {
id: number;
name: string;
address: string;
field: string;
}
export interface InitiativeFormState {
introduction: string;
initiativeName: string;
representativeAuthor: string;
representativePhone: string;
representativeEmail: string;
applicationField: string;
currentStatus: string;
purpose: string;
solutionContent: string;
implementationSteps: string;
firstAppliedUnit: string;
achievedResult: string;
conditions: string;
trialUnits: TrialUnit[];
novelty: string;
effectiveness: {
economic: string;
social: string;
teaching: string;
productivity: string;
quality: string;
environment: string;
safety: string;
};
confidentialInfo: string;
submissionDate: string;
authorName: string;
/** Cam kết trung thực (§ cuối Báo cáo) — lưu trong bản nháp / JSON tab. */
honestyConfirmed: boolean;
}
export const INITIAL_APPLICATION_FORM_STATE: ApplicationFormState = {
unitName: '',
authors: [
{ id: 1, name: '', dob: '', workplace: '', title: '', qualification: '', contributionPercent: 100 },
],
initiativeName: '',
investorName: '',
applicationField: '',
firstApplyDate: '',
initiativeClassification: null,
textbookEvidenceFile: null,
textbookEvidenceKind: '',
researchEvidenceKind: '',
researchEvidenceFile: null,
internationalJournalDeclaration: '',
banCamKet: createEmptyBanCamKet(),
referenceMaterialHonesty: createEmptyReferenceMaterialHonesty(),
researchDomesticHonesty: createEmptyResearchDomesticHonesty(),
technicalEvidenceFile: null,
contentSummary: '',
confidentialInfo: '',
conditions: '',
authorEvaluation: '',
trialEvaluation: '',
supportStaff: [],
honestyConfirmed: false,
submissionDay: new Date().getDate(),
submissionMonth: new Date().getMonth() + 1,
submissionYear: '2026',
};
export const INITIAL_REPORT_FORM_STATE: InitiativeFormState = {
introduction: '',
initiativeName: '',
representativeAuthor: '',
representativePhone: '',
representativeEmail: '',
applicationField: '',
currentStatus: '',
purpose: '',
solutionContent: '',
implementationSteps: '',
firstAppliedUnit: '',
achievedResult: '',
conditions: '',
trialUnits: [],
novelty: '',
effectiveness: {
economic: '',
social: '',
teaching: '',
productivity: '',
quality: '',
environment: '',
safety: '',
},
confidentialInfo: '',
submissionDate: formatDdMmYyyy(new Date()),
authorName: '',
honestyConfirmed: false,
};
@@ -0,0 +1,94 @@
/**
* Maps `ApplicationFormState.banCamKet` into `bieu_mau_sang_kien_template.json` keys under `BẢN CAM KẾT`,
* so `official_to_data_blank` (be01) can fill `ban_cam_ket` for `template_application_form.docx`.
*/
import emptyTemplate from './bieu_mau_sang_kien_template.json';
import { type BanCamKet, ensureBanCamKet } from './initiativeFormTypes';
type OfficialDoc = typeof emptyTemplate;
/** Must match `fe0/public/assets/bieu_mau_sang_kien_template.json` → `BẢN CAM KẾT` → `II...` keys. */
const K_II_QUYEN = '1. Quyền sở hữu đối với bài báo trong nước/quốc tế';
const K_II_DONG_THUAN = '2. Đồng thuận của đồng tác giả bài báo trong nước/quốc tế';
const K_II_UY_TIN = '3. Cam kết bài báo trong nước/quốc tế uy tín';
const K_II_TUAN_THU = '4. Tuân thủ pháp luật sở hữu trí tuệ';
const K_QUYEN_1 =
'Tôi là chủ sở hữu hợp pháp của bài báo hoặc được chủ sở hữu/đồng chủ sở hữu đồng ý cho sử dụng bài báo có tên nêu trên làm sản phẩm đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD';
const K_QUYEN_2 =
'Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH: chủ sở hữu bài báo (cơ quan) đồng ý cho tác giả/nhóm tác giả sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD';
const K_DONG_THUAN_LONG =
'Tất cả đồng tác giả đã biết, đồng ý và ký xác nhận cho phép Tác giả đăng ký sáng kiến được sử dụng bài báo có tên nêu trên để đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD';
const K_UY_TIN_LONG =
"Cá nhân đăng ký xét công nhận sáng kiến cải tiến kỹ thuật tại ĐHYD đối với bài báo trong nước/quốc tế cam kết bài báo không thuộc 'Tạp chí săn mồi'. Tôi xin chịu trách nhiệm kiểm tra, đối chiếu và cung cấp bằng chứng khi được yêu cầu";
const K_TUAN_THU_LONG =
'Tôi cam kết rằng việc sử dụng bài báo đăng ký xét công nhận sáng kiến tại ĐHYD sẽ không gây tranh chấp về: quyền tác giả/quyền liên quan, quyền sở hữu công nghiệp, tiết lộ bí mật kinh doanh, vi phạm bảo mật dữ liệu của bất kỳ bên thứ ba nào. Tôi chịu trách nhiệm trước pháp luật về tính trung thực, hợp pháp của hồ sơ';
const K_TEN_BAI_BAO =
'Tên Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH';
export function applyBanCamKetToOfficialDoc(
doc: OfficialDoc,
raw: BanCamKet | null | undefined,
): void {
const b = ensureBanCamKet(raw);
const bck = doc['BẢN CAM KẾT'] as Record<string, unknown> | undefined;
if (!bck || typeof bck !== 'object') return;
const ngay = bck['Ngày ký'];
if (ngay && typeof ngay === 'object') {
const n = ngay as Record<string, string>;
n['Ngày'] = String(b.ngay_ky.ngay ?? '');
n['Tháng'] = String(b.ngay_ky.thang ?? '');
n['Năm'] = String(b.ngay_ky.nam ?? '');
}
const i1 = bck['I. THÔNG TIN CHỦ THỂ CAM KẾT'];
if (i1 && typeof i1 === 'object') {
const i = i1 as Record<string, unknown>;
i['Tác giả đăng ký sáng kiến'] = b.tac_gia_dang_ky;
i['CCCD/Hộ chiếu số'] = b.cccd;
i['Đơn vị'] = b.don_vi;
i[K_TEN_BAI_BAO] = b.ten_bai_bao;
i['Năm xét công nhận sáng kiến'] = b.nam_xet;
const vt = i['Vai trò đối với bài báo (☑ vào ô tương ứng)'];
if (vt && typeof vt === 'object') {
const v = vt as Record<string, boolean>;
v['Tác giả chính Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH'] =
Boolean(b.vai_tro.tac_gia_chinh);
v['Đồng tác giả Bài báo trong nước/quốc tế là sản phẩm của nhiệm vụ NCKH'] =
Boolean(b.vai_tro.dong_tac_gia);
}
}
const ii = bck['II. CAM KẾT NỘI DUNG (☑ vào ô tương ứng)'];
if (ii && typeof ii === 'object') {
const section = ii as Record<string, unknown>;
const quyen = section[K_II_QUYEN];
if (quyen && typeof quyen === 'object') {
const q = quyen as Record<string, boolean>;
q[K_QUYEN_1] = Boolean(b.cam_ket.quyen_so_huu_1);
q[K_QUYEN_2] = Boolean(b.cam_ket.quyen_so_huu_2);
}
const dong = section[K_II_DONG_THUAN];
if (dong && typeof dong === 'object') {
const d = dong as Record<string, boolean>;
d[K_DONG_THUAN_LONG] = Boolean(b.cam_ket.dong_thuan);
}
const uy = section[K_II_UY_TIN];
if (uy && typeof uy === 'object') {
const u = uy as Record<string, boolean>;
u[K_UY_TIN_LONG] = Boolean(b.cam_ket.bai_bao_uy_tin);
}
const tt = section[K_II_TUAN_THU];
if (tt && typeof tt === 'object') {
const t = tt as Record<string, boolean>;
t[K_TUAN_THU_LONG] = Boolean(b.cam_ket.tuan_thu_phap_luat);
}
}
bck['Người cam kết (Ký tên, ghi rõ họ tên)'] = b.nguoi_cam_ket;
}
@@ -0,0 +1,282 @@
import type { InitiativeDraft } from './types';
import type { ApplicationFormState, InitiativeFormState } from './initiativeFormTypes';
import type { ContributionDraftShape } from './contributionDraftTypes';
import {
authorByIdMap,
resolveDonViCongTacDisplay,
resolveWorkUnitForContributionRow,
} from './mergeContributionWorkUnits';
import emptyTemplate from './bieu_mau_sang_kien_template.json';
import { applyBanCamKetToOfficialDoc } from './mapBanCamKetToOfficialBieuMau';
import { applyReferenceMaterialHonestyToOfficialDoc } from './mapReferenceMaterialHonestyToOfficialBieuMau';
import { applyResearchDomesticHonestyToOfficialDoc } from './mapResearchDomesticHonestyToOfficialBieuMau';
import {
displayDobStoredAsDdMm,
parseContributionSubmissionDateInput,
} from '../lib/vnDateFormat';
/**
* Canonical shape of `bieu_mau_sang_kien_template.json` (public + bundled copy).
* Used for DOCX/Jinja pipelines (be01) and API persistence aligned with official form labels.
*/
export type OfficialBieuMauDocument = typeof emptyTemplate;
function cloneEmpty(): OfficialBieuMauDocument {
return JSON.parse(JSON.stringify(emptyTemplate)) as OfficialBieuMauDocument;
}
function padTrialRows(
rows: Array<{ TT: string; 'Tên tổ chức/cá nhân': string; 'Địa chỉ': string; 'Lĩnh vực áp dụng sáng kiến': string }>,
source: { name: string; address: string; field: string }[],
): void {
rows.length = 0;
source.forEach((u, i) => {
rows.push({
TT: String(i + 1),
'Tên tổ chức/cá nhân': u.name,
'Địa chỉ': u.address,
'Lĩnh vực áp dụng sáng kiến': u.field,
});
});
if (rows.length === 0) {
rows.push({
TT: '',
'Tên tổ chức/cá nhân': '',
'Địa chỉ': '',
'Lĩnh vực áp dụng sáng kiến': '',
});
}
}
function classificationToCheckboxes(
c: ApplicationFormState['initiativeClassification'],
): Record<string, boolean> {
return {
'Giải pháp kỹ thuật, quản lý, tác nghiệp, ứng dụng tiến bộ kỹ thuật áp dụng cho ĐHYD TP.HCM':
c === 'technical',
'Sáng kiến cải tiến kỹ thuật từ các nghiên cứu khoa học có kết quả được đăng tải trên các tạp chí, hội nghị trong nước và quốc tế':
c === 'research',
'Sáng kiến cải tiến kỹ thuật từ sách, giáo trình, tài liệu tham khảo': c === 'textbook',
};
}
/**
* Maps applicant draft + contribution into the nested Vietnamese-key JSON used by
* `public/assets/bieu_mau_sang_kien_template.json` and `be0/src/be01/data_blank.json` (same semantics; be01 uses snake_case keys).
*
* **ReviewPanel** sources: `useDraft()` → `draft.application`, `draft.report`, and `contributionDraft`
* (same objects passed to {@link buildTemplateDataFromDraft}).
*
* **Gaps (left empty / false):** Mẫu 04 (council evaluation). **Bản cam kết** is filled from
* `application.banCamKet` via {@link applyBanCamKetToOfficialDoc}.
*
* **Effectiveness:** the form stores 7 text fields; the official template lists 10 bullets — see `mapEffectivenessToMau01`.
*/
export function buildOfficialBieuMauFromDraft(
draft: InitiativeDraft,
contribution?: Record<string, unknown> | null,
): OfficialBieuMauDocument {
const a = draft.application as unknown as ApplicationFormState;
const r = draft.report as unknown as InitiativeFormState;
const c = (contribution ?? {}) as ContributionDraftShape;
const doc = cloneEmpty();
const initiativeTitle = (a.initiativeName || r.initiativeName || '').trim();
const authorsLine = a.authors.map((x) => x.name).filter(Boolean).join('; ');
const donViCongTacDisplay = resolveDonViCongTacDisplay(a, c);
// —— TRANG BÌA ——
const bia = doc['TRANG BÌA'];
bia['Tên sáng kiến (Tiếng Việt)'] = initiativeTitle;
bia['Tác giả/nhóm tác giả sáng kiến'] = authorsLine;
bia['Đơn vị công tác'] = donViCongTacDisplay;
bia['Thông tin liên hệ (Điện thoại, Email)'] = [r.representativePhone, r.representativeEmail]
.filter(Boolean)
.join(' · ');
bia['Năm'] = String(a.submissionYear || new Date().getFullYear());
// —— MẪU 01 ——
const m01 = doc['MẪU SỐ 01 - BÁO CÁO MÔ TẢ SÁNG KIẾN'];
m01['1. Mở đầu'] = r.introduction;
m01['2. Tên sáng kiến (tên quy trình, giải pháp, phương pháp)'] = r.initiativeName || initiativeTitle;
m01['3. Lĩnh vực áp dụng của sáng kiến'] = r.applicationField || a.applicationField;
const m4 = m01['4. Mô tả sáng kiến'];
m4['4.1 Tình trạng giải pháp đã biết hoặc hiện trạng công tác khi chưa có sáng kiến'] =
r.currentStatus;
const inner = m4['4.2 Nội dung giải pháp đề nghị công nhận là sáng kiến'];
inner['Mục đích của sáng kiến'] = r.purpose;
const nd = inner['Về nội dung của sáng kiến'];
nd['Các bước thực hiện giải pháp'] = r.implementationSteps;
nd['Các điều kiện cần thiết để áp dụng giải pháp'] = r.conditions;
nd['Lĩnh vực áp dụng'] = r.applicationField || a.applicationField;
nd['Kết quả thu được'] = r.achievedResult;
padTrialRows(nd['Danh sách đơn vị/cá nhân đã tham gia áp dụng thử hoặc lần đầu'], r.trialUnits);
inner['Về tính mới của sáng kiến'] = r.novelty;
mapEffectivenessToMau01(inner['Về tính hiệu quả'], r);
m01['6. Những thông tin cần được bảo mật (nếu có)'] = r.confidentialInfo;
const nk = m01['Ngày ký'];
nk['Ngày'] = String(a.submissionDay);
nk['Tháng'] = String(a.submissionMonth);
nk['Năm'] = String(a.submissionYear);
m01['Lãnh đạo đơn vị (Ký, ghi rõ họ tên)'] = '';
m01['Tác giả sáng kiến (Ký, ghi rõ họ tên)'] = r.authorName || r.representativeAuthor || authorsLine;
// —— MẪU 02 —— (Đơn — application tab)
const m02 = doc['MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN'];
m02['Đơn vị'] = donViCongTacDisplay;
m02['Danh sách tác giả'] = a.authors.map((x, i) => ({
STT: String(i + 1),
'Họ và tên': x.name,
'Ngày tháng năm sinh': displayDobStoredAsDdMm(x.dob),
'Nơi công tác': x.workplace,
'Chức danh': x.title,
'Trình độ chuyên môn': x.qualification,
'Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến': String(x.contributionPercent),
}));
if (m02['Danh sách tác giả'].length === 0) {
m02['Danh sách tác giả'].push({
STT: '',
'Họ và tên': '',
'Ngày tháng năm sinh': '',
'Nơi công tác': '',
'Chức danh': '',
'Trình độ chuyên môn': '',
'Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến': '',
});
}
m02['Tên sáng kiến đề nghị xét công nhận'] = a.initiativeName;
m02['Chủ đầu tư tạo ra sáng kiến'] = a.investorName;
m02['Lĩnh vực áp dụng sáng kiến'] = a.applicationField;
m02['Ngày sáng kiến được áp dụng'] = a.firstApplyDate;
m02['Nội dung của sáng kiến'] = a.contentSummary;
Object.assign(m02['Phân loại sáng kiến (đánh dấu ☑)'], classificationToCheckboxes(a.initiativeClassification));
m02['Những thông tin cần được bảo mật (nếu có)'] = a.confidentialInfo;
m02['Các điều kiện cần thiết để áp dụng sáng kiến'] = a.conditions;
m02['Đánh giá lợi ích theo ý kiến của tác giả'] = a.authorEvaluation;
m02['Đánh giá lợi ích theo ý kiến của tổ chức, cá nhân đã tham gia áp dụng sáng kiến lần đầu'] =
a.trialEvaluation;
m02['Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu'] =
a.supportStaff.map((x, i) => ({
'Số TT': String(i + 1),
'Họ và tên': x.name,
'Ngày tháng năm sinh': displayDobStoredAsDdMm(x.dob),
'Nơi công tác': x.workplace,
'Chức danh': x.title,
'Trình độ chuyên môn': x.qualification,
'Nội dung công việc hỗ trợ': x.supportContent,
}));
if (m02['Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu'].length === 0) {
m02['Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu'].push({
'Số TT': '',
'Họ và tên': '',
'Ngày tháng năm sinh': '',
'Nơi công tác': '',
'Chức danh': '',
'Trình độ chuyên môn': '',
'Nội dung công việc hỗ trợ': '',
});
}
const m02nk = m02['Ngày ký'];
m02nk['Ngày'] = String(a.submissionDay);
m02nk['Tháng'] = String(a.submissionMonth);
m02nk['Năm'] = String(a.submissionYear);
m02['Xác nhận của lãnh đạo Đơn vị'] = '';
m02['Tác giả sáng kiến (Ký, ghi rõ họ tên)'] = r.authorName || r.representativeAuthor || '';
// —— MẪU 03 —— (contribution tab)
const m03 = doc['MẪU SỐ 03 - BẢN XÁC NHẬN TỶ LỆ (%) ĐÓNG GÓP VÀO VIỆC TẠO RA SÁNG KIẾN'];
const m03nk = m03['Ngày ký'];
if (c.submissionDate) {
const d = parseContributionSubmissionDateInput(c.submissionDate);
if (d && !Number.isNaN(d.getTime())) {
m03nk['Ngày'] = String(d.getDate());
m03nk['Tháng'] = String(d.getMonth() + 1);
m03nk['Năm'] = String(d.getFullYear());
}
}
m03['1. Tên sáng kiến'] = c.initiativeName || initiativeTitle;
m03['2. Tác giả chính/Đại diện nhóm tác giả sáng kiến'] = c.mainAuthor || r.representativeAuthor || '';
/**
* `position` is last-saved contribution JSON; `authors[0].workplace` is live Đơn « Nơi công tác »
* (dropdown). Prefer the latter so preview matches the application tab without re-saving Xác nhận.
*/
const contribPosition = (c.position != null && String(c.position).trim()) || '';
const firstAuthorUnit = (a.authors[0]?.workplace != null && String(a.authors[0].workplace).trim()) || '';
m03['Chức vụ, đơn vị công tác'] = firstAuthorUnit || contribPosition || donViCongTacDisplay || '';
const tyLe = m03['Tỷ lệ đóng góp'];
tyLe.length = 0;
const parts = c.participants ?? [];
const authorIds = authorByIdMap(a.authors);
parts.forEach((p, i) => {
tyLe.push({
STT: String(i + 1),
'Họ và tên': p.fullName,
'Đơn vị công tác': resolveWorkUnitForContributionRow(authorIds, p),
'% đóng góp': String(p.contributionPercent),
'Chữ ký xác nhận': '',
});
});
if (a.authors.length && tyLe.length === 0) {
a.authors.forEach((x, i) => {
tyLe.push({
STT: String(i + 1),
'Họ và tên': x.name,
'Đơn vị công tác': x.workplace,
'% đóng góp': String(x.contributionPercent),
'Chữ ký xác nhận': '',
});
});
}
if (tyLe.length === 0) {
tyLe.push({
STT: '',
'Họ và tên': '',
'Đơn vị công tác': '',
'% đóng góp': '',
'Chữ ký xác nhận': '',
});
}
const sum = parts.reduce((s, p) => s + (Number(p.contributionPercent) || 0), 0);
m03['Tổng % đóng góp'] = sum > 0 ? String(sum) : '100';
m03['Tác giả chính/Đại diện nhóm tác giả sáng kiến (chữ ký và ghi rõ họ tên)'] =
c.mainAuthor || r.representativeAuthor || '';
// Mẫu 04: hội đồng — giữ mặc định từ template
applyBanCamKetToOfficialDoc(doc, a.banCamKet);
applyReferenceMaterialHonestyToOfficialDoc(doc, a.referenceMaterialHonesty);
applyResearchDomesticHonestyToOfficialDoc(doc, a.researchDomesticHonesty);
return doc;
}
function mapEffectivenessToMau01(
target: OfficialBieuMauDocument['MẪU SỐ 01 - BÁO CÁO MÔ TẢ SÁNG KIẾN']['4. Mô tả sáng kiến']['4.2 Nội dung giải pháp đề nghị công nhận là sáng kiến']['Về tính hiệu quả'],
r: InitiativeFormState,
): void {
const e = r.effectiveness;
target['Tạo ra lợi ích kinh tế'] = e.economic;
target['Đem lại hiệu quả trong giảng dạy'] = e.teaching;
target['Tăng năng suất lao động'] = e.productivity;
target['Nâng cao hiệu quả công việc'] = e.social;
target['Nâng cao chất lượng công việc, dịch vụ'] = e.quality;
target['Giảm chi phí'] = '';
target['Cải thiện môi trường, điều kiện học tập, làm việc, sống'] = e.environment;
target['Bảo vệ sức khỏe'] = '';
target['Đảm bảo an toàn lao động, PCCC'] = e.safety;
target['Nâng cao khả năng, trình độ, nhận thức, trách nhiệm'] = '';
}
@@ -0,0 +1,70 @@
/**
* Maps `ApplicationFormState.referenceMaterialHonesty` into `bieu_mau_sang_kien_template.json`
* under `BẢN XÁC NHẬN TÀI LIỆU THAM KHẢO (2.2.2)` for be01 / DOCX preview.
*/
import emptyTemplate from './bieu_mau_sang_kien_template.json';
import {
type ReferenceMaterialHonesty,
ensureReferenceMaterialHonesty,
} from './initiativeFormTypes';
type OfficialDoc = typeof emptyTemplate;
const K_II = 'II. XÁC NHẬN VÀ CAM KẾT (☑ vào ô tương ứng)';
const K_II_1 = '1. Trung thực thông tin và minh chứng';
const K_II_2 = '2. Trách nhiệm pháp luật';
const K_II_3 = '3. Bổ sung hồ sơ khi được yêu cầu';
const TEXT_II_1 =
'Tôi cam đoan các thông tin kê khai và minh chứng đính kèm đối với tài liệu tham khảo là trung thực, đúng sự thật và phù hợp với Quyết định xuất bản trong giai đoạn quy định (15/4/202515/4/2026).';
const TEXT_II_2 =
'Tôi hoàn toàn chịu trách nhiệm trước pháp luật và trước nhà trường về tính hợp pháp của tài liệu và nội dung đăng ký.';
const TEXT_II_3 = 'Tôi đồng ý bổ sung hoặc chỉnh sửa hồ sơ khi được yêu cầu.';
const K_TEN_TL = 'Tên tài liệu tham khảo (theo Quyết định xuất bản)';
export function applyReferenceMaterialHonestyToOfficialDoc(
doc: OfficialDoc,
raw: ReferenceMaterialHonesty | null | undefined,
): void {
const r = ensureReferenceMaterialHonesty(raw);
const sec = doc['BẢN XÁC NHẬN TÀI LIỆU THAM KHẢO (2.2.2)'] as Record<string, unknown> | undefined;
if (!sec || typeof sec !== 'object') return;
const ngay = sec['Ngày ký'];
if (ngay && typeof ngay === 'object') {
const n = ngay as Record<string, string>;
n['Ngày'] = String(r.ngay_ky.ngay ?? '');
n['Tháng'] = String(r.ngay_ky.thang ?? '');
n['Năm'] = String(r.ngay_ky.nam ?? '');
}
const i1 = sec['I. THÔNG TIN ĐĂNG KÝ'];
if (i1 && typeof i1 === 'object') {
const i = i1 as Record<string, unknown>;
i['Tác giả đăng ký sáng kiến'] = r.tac_gia_dang_ky;
i['CCCD/Hộ chiếu số'] = r.cccd;
i['Đơn vị'] = r.don_vi;
i[K_TEN_TL] = r.ten_tai_lieu;
i['Năm xét công nhận sáng kiến'] = r.nam_xet;
}
const ii = sec[K_II];
if (ii && typeof ii === 'object') {
const section = ii as Record<string, unknown>;
const b1 = section[K_II_1];
if (b1 && typeof b1 === 'object') {
(b1 as Record<string, boolean>)[TEXT_II_1] = Boolean(r.cam_ket.thong_tin_trung_thuc);
}
const b2 = section[K_II_2];
if (b2 && typeof b2 === 'object') {
(b2 as Record<string, boolean>)[TEXT_II_2] = Boolean(r.cam_ket.trach_nhiem_phap_luat);
}
const b3 = section[K_II_3];
if (b3 && typeof b3 === 'object') {
(b3 as Record<string, boolean>)[TEXT_II_3] = Boolean(r.cam_ket.bo_sung_khi_yeu_cau);
}
}
sec['Người cam kết (Ký tên, ghi rõ họ tên)'] = r.nguoi_cam_ket;
}
@@ -0,0 +1,74 @@
/**
* Maps `ApplicationFormState.researchDomesticHonesty` into `bieu_mau_sang_kien_template.json`
* under `BẢN XÁC NHẬN BÀI BÁO TRONG NƯỚC (2.1.2)` for be01 / DOCX preview.
*/
import emptyTemplate from './bieu_mau_sang_kien_template.json';
import {
RESEARCH_DOMESTIC_HONESTY_TIEU_DE_PHU_TEMPLATE_KEY,
type ResearchDomesticHonesty,
ensureResearchDomesticHonesty,
} from './initiativeFormTypes';
type OfficialDoc = typeof emptyTemplate;
const K_II = 'II. XÁC NHẬN VÀ CAM KẾT (☑ vào ô tương ứng)';
const K_II_1 = '1. Trung thực thông tin và minh chứng';
const K_II_2 = '2. Trách nhiệm pháp luật';
const K_II_3 = '3. Bổ sung hồ sơ khi được yêu cầu';
const TEXT_II_1 =
'Tôi cam đoan các thông tin kê khai và minh chứng đính kèm đối với bài báo trên tạp chí trong nước là trung thực, đúng sự thật và phù hợp với thời điểm xuất bản trong giai đoạn quy định (15/4/202515/4/2026).';
const TEXT_II_2 =
'Tôi hoàn toàn chịu trách nhiệm trước pháp luật và trước nhà trường về tính hợp pháp của bài báo và nội dung đăng ký.';
const TEXT_II_3 = 'Tôi đồng ý bổ sung hoặc chỉnh sửa hồ sơ khi được yêu cầu.';
const K_TEN_BAI =
'Tên bài báo (tạp chí trong nước, giai đoạn xuất bản quy định)';
export function applyResearchDomesticHonestyToOfficialDoc(
doc: OfficialDoc,
raw: ResearchDomesticHonesty | null | undefined,
): void {
const r = ensureResearchDomesticHonesty(raw);
const sec = doc['BẢN XÁC NHẬN BÀI BÁO TRONG NƯỚC (2.1.2)'] as Record<string, unknown> | undefined;
if (!sec || typeof sec !== 'object') return;
const ngay = sec['Ngày ký'];
if (ngay && typeof ngay === 'object') {
const n = ngay as Record<string, string>;
n['Ngày'] = String(r.ngay_ky.ngay ?? '');
n['Tháng'] = String(r.ngay_ky.thang ?? '');
n['Năm'] = String(r.ngay_ky.nam ?? '');
}
sec[RESEARCH_DOMESTIC_HONESTY_TIEU_DE_PHU_TEMPLATE_KEY] = r.tieu_de_phu ?? '';
const i1 = sec['I. THÔNG TIN ĐĂNG KÝ'];
if (i1 && typeof i1 === 'object') {
const i = i1 as Record<string, unknown>;
i['Tác giả đăng ký sáng kiến'] = r.tac_gia_dang_ky;
i['CCCD/Hộ chiếu số'] = r.cccd;
i['Đơn vị'] = r.don_vi;
i[K_TEN_BAI] = r.ten_bai_bao;
i['Năm xét công nhận sáng kiến'] = r.nam_xet;
}
const ii = sec[K_II];
if (ii && typeof ii === 'object') {
const section = ii as Record<string, unknown>;
const b1 = section[K_II_1];
if (b1 && typeof b1 === 'object') {
(b1 as Record<string, boolean>)[TEXT_II_1] = Boolean(r.cam_ket.thong_tin_trung_thuc);
}
const b2 = section[K_II_2];
if (b2 && typeof b2 === 'object') {
(b2 as Record<string, boolean>)[TEXT_II_2] = Boolean(r.cam_ket.trach_nhiem_phap_luat);
}
const b3 = section[K_II_3];
if (b3 && typeof b3 === 'object') {
(b3 as Record<string, boolean>)[TEXT_II_3] = Boolean(r.cam_ket.bo_sung_khi_yeu_cau);
}
}
sec['Người cam kết (Ký tên, ghi rõ họ tên)'] = r.nguoi_cam_ket;
}
@@ -0,0 +1,52 @@
import type { ApplicationFormState } from './initiativeFormTypes';
type ParticipantRow = {
id?: number;
workUnit?: string;
};
/**
* DOCX / template builders merge live `draft.application.authors` with last-saved
* `contribution.participants`. Row IDs match {@link authorToContributionRow}; workplace
* should follow the Đơn “Nơi công tác” field (dropdown) even when the contribution
* tab JSON has not been re-saved.
*/
export function authorByIdMap(authors: ApplicationFormState['authors']): Map<
number,
ApplicationFormState['authors'][number]
> {
return new Map(authors.map((x) => [x.id, x]));
}
export function resolveWorkUnitForContributionRow(
authorById: Map<number, ApplicationFormState['authors'][number]>,
p: ParticipantRow,
): string {
const id = p.id;
if (id != null) {
const author = authorById.get(id);
const fromAuthor = author?.workplace != null ? String(author.workplace).trim() : '';
if (fromAuthor) return fromAuthor;
}
return p.workUnit != null ? String(p.workUnit).trim() : '';
}
type ContributionPositionSlice = { position?: string };
/**
* TRANG BÌA « Đơn vị công tác » và Mẫu 02 « Đơn vị ».
* `application.unitName` has no dedicated control on the Đơn form today, so we
* fall back to tác giả đầu « Nơi công tác » (dropdown) then contribution « Chức vụ, đơn vị ».
*/
export function resolveDonViCongTacDisplay(
a: ApplicationFormState,
c: ContributionPositionSlice,
): string {
const explicit = a.unitName != null ? String(a.unitName).trim() : '';
if (explicit) return explicit;
const w0 = a.authors[0]?.workplace != null ? String(a.authors[0].workplace).trim() : '';
if (w0) return w0;
const pos = c.position != null ? String(c.position).trim() : '';
if (pos) return pos;
return '';
}
@@ -0,0 +1,135 @@
import type { DataRecord } from './templateTypes';
import type { OfficialBieuMauDocument } from './mapDraftToOfficialBieuMau';
/**
* Reverses {@link buildOfficialBieuMauFromDraft} into the same flat
* `DataRecord` shape as {@link buildTemplateDataFromDraft} so DOCX placeholders
* match `public/assets/bieu_mau_sang_kien_template.json` (Review merged JSON).
*/
export function officialBieuMauToTemplateDataRecord(
o: OfficialBieuMauDocument,
): DataRecord {
const bia = o['TRANG BÌA'];
const m01 = o['MẪU SỐ 01 - BÁO CÁO MÔ TẢ SÁNG KIẾN'];
const m4 = m01['4. Mô tả sáng kiến'];
const m42 = m4['4.2 Nội dung giải pháp đề nghị công nhận là sáng kiến'];
const vnd = m42['Về nội dung của sáng kiến'];
const hieuQua = m42['Về tính hiệu quả'];
const m02 = o['MẪU SỐ 02 - ĐƠN ĐỀ NGHỊ CÔNG NHẬN SÁNG KIẾN'];
const m03 = o['MẪU SỐ 03 - BẢN XÁC NHẬN TỶ LỆ (%) ĐÓNG GÓP VÀO VIỆC TẠO RA SÁNG KIẾN'];
const trialRows = vnd['Danh sách đơn vị/cá nhân đã tham gia áp dụng thử hoặc lần đầu'];
const supportRows =
m02['Danh sách những người đã tham gia áp dụng thử hoặc áp dụng sáng kiến lần đầu'];
const m02Authors = m02['Danh sách tác giả'];
const m03tyLe = m03['Tỷ lệ đóng góp'];
const contactLine = bia['Thông tin liên hệ (Điện thoại, Email)'] || '';
let representativePhone = '';
let representativeEmail = '';
if (contactLine.includes(' · ')) {
const parts = contactLine.split(' · ').map((p) => p.trim());
representativePhone = parts[0] || '';
representativeEmail = parts.slice(1).join(' · ') || '';
} else if (contactLine.includes('@')) {
representativeEmail = contactLine;
} else {
representativePhone = contactLine;
}
const trialLines = trialRows
.map((u) => {
const n = u['Tên tổ chức/cá nhân']?.trim() ?? '';
const a = u['Địa chỉ']?.trim() ?? '';
if (!n && !a) return '';
return a ? `${n}${a}` : n;
})
.filter(Boolean)
.join('\n');
const supportLines = supportRows
.map((s) => {
const n = s['Họ và tên']?.trim() ?? '';
const c = s['Nội dung công việc hỗ trợ']?.trim() ?? '';
if (!n && !c) return '';
return c ? `${n}: ${c}` : n;
})
.filter(Boolean)
.join('\n');
const authorsListFromM02 = m02Authors
.map(
(x) =>
`${x['Họ và tên'] ?? ''}${x['Chức danh'] ?? ''} (${x['Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến'] ?? ''}%) · ${x['Nơi công tác'] ?? ''}`.trim(),
)
.filter((line) => line.length > 0);
const authorsList = bia['Tác giả/nhóm tác giả sáng kiến'] || authorsListFromM02.join('\n');
const data: DataRecord = {
initiativeName: bia['Tên sáng kiến (Tiếng Việt)'] || '',
initiativeNameReport: m01['2. Tên sáng kiến (tên quy trình, giải pháp, phương pháp)'] || '',
unitName: bia['Đơn vị công tác'] || m02['Đơn vị'] || '',
investorName: m02['Chủ đầu tư tạo ra sáng kiến'] || '',
applicationField: m02['Lĩnh vực áp dụng sáng kiến'] || m01['3. Lĩnh vực áp dụng của sáng kiến'] || vnd['Lĩnh vực áp dụng'] || '',
firstApplyDate: m02['Ngày sáng kiến được áp dụng'] || '',
contentSummary: m02['Nội dung của sáng kiến'] || '',
confidentialInfoApp: m02['Những thông tin cần được bảo mật (nếu có)'] || '',
conditionsApp: m02['Các điều kiện cần thiết để áp dụng sáng kiến'] || '',
authorEvaluation: m02['Đánh giá lợi ích theo ý kiến của tác giả'] || '',
trialEvaluation: m02['Đánh giá lợi ích theo ý kiến của tổ chức, cá nhân đã tham gia áp dụng sáng kiến lần đầu'] || '',
authorsList,
representativePhone,
representativeEmail,
introduction: m01['1. Mở đầu'] || '',
currentStatus: m4['4.1 Tình trạng giải pháp đã biết hoặc hiện trạng công tác khi chưa có sáng kiến'] || '',
purpose: m42['Mục đích của sáng kiến'] || '',
implementationSteps: vnd['Các bước thực hiện giải pháp'] || '',
achievedResult: vnd['Kết quả thu được'] || '',
conditionsReport: vnd['Các điều kiện cần thiết để áp dụng giải pháp'] || '',
novelty: m42['Về tính mới của sáng kiến'] || '',
trialUnitsList: trialLines,
supportStaffList: supportLines,
effectivenessEconomic: hieuQua['Tạo ra lợi ích kinh tế'] || '',
effectivenessSocial: hieuQua['Nâng cao hiệu quả công việc'] || '',
effectivenessTeaching: hieuQua['Đem lại hiệu quả trong giảng dạy'] || '',
effectivenessProductivity: hieuQua['Tăng năng suất lao động'] || '',
effectivenessQuality: hieuQua['Nâng cao chất lượng công việc, dịch vụ'] || '',
effectivenessEnvironment: hieuQua['Cải thiện môi trường, điều kiện học tập, làm việc, sống'] || '',
effectivenessSafety: hieuQua['Đảm bảo an toàn lao động, PCCC'] || '',
confidentialInfoReport: m01['6. Những thông tin cần được bảo mật (nếu có)'] || '',
authorName: m01['Tác giả sáng kiến (Ký, ghi rõ họ tên)'] || m02['Tác giả sáng kiến (Ký, ghi rõ họ tên)'] || '',
contributionInitiativeName: m03['1. Tên sáng kiến'] || '',
contributionMainAuthor: m03['2. Tác giả chính/Đại diện nhóm tác giả sáng kiến'] || '',
contributionPosition: m03['Chức vụ, đơn vị công tác'] || '',
representativeAuthor:
m01['Tác giả sáng kiến (Ký, ghi rõ họ tên)'] || bia['Tác giả/nhóm tác giả sáng kiến'] || '',
};
const authorRows = m02Authors
.map((x) => ({
name: x['Họ và tên'] || '',
title: x['Chức danh'] || '',
workplace: x['Nơi công tác'] || '',
contributionPercent: String(x['Tỷ lệ (%) đóng góp vào việc tạo ra sáng kiến'] || '').replace(
/%$/,
'',
),
}))
.filter((row) => Object.values(row).some((v) => String(v).trim() !== ''));
if (authorRows.length > 0) {
data.authors = authorRows as unknown as DataRecord[];
}
const partRows = m03tyLe
.map((p) => ({
fullName: p['Họ và tên'] || '',
workUnit: p['Đơn vị công tác'] || '',
contributionPercent: String(p['% đóng góp'] || '').replace(/%$/, ''),
}))
.filter((row) => Object.values(row).some((v) => String(v).trim() !== ''));
if (partRows.length > 0) {
data.participants = partRows as unknown as DataRecord[];
}
return data;
}
@@ -0,0 +1,156 @@
import type { PdfTextLayoutEdit } from './pdfLayoutEditor';
export type OfficialFormPreviewFieldKey = 'initiativeTitle' | 'authorGroup' | 'workUnit' | 'contactInfo';
export type OfficialFormPreviewFields = Record<OfficialFormPreviewFieldKey, string>;
type OfficialFormPreviewFieldDef = {
key: OfficialFormPreviewFieldKey;
label: string;
placeholder: string;
maxLength: number;
multiline?: boolean;
page: number;
x: number;
y: number;
fontSize: number;
lineHeight: number;
fontName: PdfTextLayoutEdit['fontName'];
colorHex: string;
};
export const OFFICIAL_FORM_PREVIEW_FIELD_DEFS: OfficialFormPreviewFieldDef[] = [
{
key: 'initiativeTitle',
label: 'Tên sáng kiến',
placeholder: 'Nhập tên sáng kiến để phủ lên bản xem trước',
maxLength: 240,
multiline: true,
page: 1,
x: 84,
y: 530,
fontSize: 13,
lineHeight: 16,
fontName: 'TimesRomanBold',
colorHex: '#111827',
},
{
key: 'authorGroup',
label: 'Tác giả/nhóm tác giả',
placeholder: 'Nhập tên tác giả hoặc nhóm tác giả',
maxLength: 240,
multiline: true,
page: 1,
x: 84,
y: 492,
fontSize: 12,
lineHeight: 15,
fontName: 'TimesRoman',
colorHex: '#111827',
},
{
key: 'workUnit',
label: 'Đơn vị công tác',
placeholder: 'Nhập đơn vị công tác',
maxLength: 200,
page: 1,
x: 84,
y: 456,
fontSize: 12,
lineHeight: 15,
fontName: 'TimesRoman',
colorHex: '#111827',
},
{
key: 'contactInfo',
label: 'Thông tin liên hệ',
placeholder: 'Số điện thoại, email',
maxLength: 220,
multiline: true,
page: 1,
x: 84,
y: 420,
fontSize: 11,
lineHeight: 14,
fontName: 'TimesRoman',
colorHex: '#111827',
},
];
export const EMPTY_OFFICIAL_FORM_PREVIEW_FIELDS: OfficialFormPreviewFields = {
initiativeTitle: '',
authorGroup: '',
workUnit: '',
contactInfo: '',
};
function toText(value: unknown): string {
if (value == null) return '';
return String(value).trim();
}
function sanitizeValue(value: string, maxLength: number): string {
const compact = value.replace(/\s+\n/g, '\n').replace(/\n\s+/g, '\n').trim();
if (compact.length <= maxLength) return compact;
return compact.slice(0, maxLength);
}
function readTrangBiaValue(official: Record<string, unknown>, key: string): string {
const trangBia = official['TRANG BÌA'];
if (!trangBia || typeof trangBia !== 'object') return '';
return toText((trangBia as Record<string, unknown>)[key]);
}
export function buildInitialOfficialFormPreviewFields(
officialBieuMau: Record<string, unknown>,
): OfficialFormPreviewFields {
const next: OfficialFormPreviewFields = {
initiativeTitle: readTrangBiaValue(officialBieuMau, 'Tên sáng kiến (Tiếng Việt)'),
authorGroup: readTrangBiaValue(officialBieuMau, 'Tác giả/nhóm tác giả sáng kiến'),
workUnit: readTrangBiaValue(officialBieuMau, 'Đơn vị công tác'),
contactInfo: readTrangBiaValue(officialBieuMau, 'Thông tin liên hệ (Điện thoại, Email)'),
};
return sanitizeOfficialFormPreviewFields(next);
}
export function sanitizeOfficialFormPreviewFields(input: Partial<OfficialFormPreviewFields>): OfficialFormPreviewFields {
const byKey = new Map(OFFICIAL_FORM_PREVIEW_FIELD_DEFS.map((x) => [x.key, x]));
return {
initiativeTitle: sanitizeValue(toText(input.initiativeTitle), byKey.get('initiativeTitle')?.maxLength ?? 240),
authorGroup: sanitizeValue(toText(input.authorGroup), byKey.get('authorGroup')?.maxLength ?? 240),
workUnit: sanitizeValue(toText(input.workUnit), byKey.get('workUnit')?.maxLength ?? 200),
contactInfo: sanitizeValue(toText(input.contactInfo), byKey.get('contactInfo')?.maxLength ?? 220),
};
}
export function hasOfficialFormPreviewFieldChanges(
fields: OfficialFormPreviewFields,
baseFields: OfficialFormPreviewFields,
): boolean {
return (
fields.initiativeTitle !== baseFields.initiativeTitle ||
fields.authorGroup !== baseFields.authorGroup ||
fields.workUnit !== baseFields.workUnit ||
fields.contactInfo !== baseFields.contactInfo
);
}
export function toOfficialFormPreviewOverlayEdits(fields: OfficialFormPreviewFields): PdfTextLayoutEdit[] {
return OFFICIAL_FORM_PREVIEW_FIELD_DEFS.map((def) => {
const value = fields[def.key];
return {
id: `preview-${def.key}`,
text: value,
page: def.page,
x: def.x,
y: def.y,
fontSize: def.fontSize,
lineHeight: def.lineHeight,
boxWidth: 420,
letterSpacing: 0,
textAlign: 'left',
fontName: def.fontName,
colorHex: def.colorHex,
} satisfies PdfTextLayoutEdit;
}).filter((x) => x.text.trim().length > 0);
}
+138
View File
@@ -0,0 +1,138 @@
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
export type PdfLayoutFontName = 'TimesRoman' | 'TimesRomanBold' | 'Helvetica' | 'HelveticaBold';
export type PdfTextLayoutEdit = {
id: string;
text: string;
page: number;
x: number;
y: number;
fontSize: number;
lineHeight: number;
boxWidth: number;
letterSpacing: number;
textAlign: 'left' | 'center' | 'right';
fontName: PdfLayoutFontName;
colorHex: string;
};
const DEFAULT_TEXT_COLOR = '#111827';
function parseHexColor(colorHex?: string): { r: number; g: number; b: number } {
const normalized = (colorHex || DEFAULT_TEXT_COLOR).trim();
const short = /^#([0-9a-fA-F]{3})$/;
const long = /^#([0-9a-fA-F]{6})$/;
if (short.test(normalized)) {
const m = normalized.match(short)?.[1] ?? '111';
return {
r: parseInt(m[0] + m[0], 16) / 255,
g: parseInt(m[1] + m[1], 16) / 255,
b: parseInt(m[2] + m[2], 16) / 255,
};
}
if (long.test(normalized)) {
const m = normalized.match(long)?.[1] ?? '111827';
return {
r: parseInt(m.slice(0, 2), 16) / 255,
g: parseInt(m.slice(2, 4), 16) / 255,
b: parseInt(m.slice(4, 6), 16) / 255,
};
}
return { r: 17 / 255, g: 24 / 255, b: 39 / 255 };
}
function sanitizeEdits(edits: PdfTextLayoutEdit[], pageCount: number): PdfTextLayoutEdit[] {
return edits
.map((edit): PdfTextLayoutEdit => ({
...edit,
text: String(edit.text || ''),
page: Number.isFinite(edit.page) ? Math.max(1, Math.min(pageCount, Math.round(edit.page))) : 1,
x: Number.isFinite(edit.x) ? edit.x : 48,
y: Number.isFinite(edit.y) ? edit.y : 780,
fontSize: Number.isFinite(edit.fontSize) ? Math.max(1, edit.fontSize) : 12,
lineHeight: Number.isFinite(edit.lineHeight) ? Math.max(1, edit.lineHeight) : 14,
boxWidth: Number.isFinite(edit.boxWidth) ? Math.max(1, edit.boxWidth) : 240,
letterSpacing: Number.isFinite(edit.letterSpacing) ? edit.letterSpacing : 0,
textAlign: edit.textAlign === 'center' || edit.textAlign === 'right' ? edit.textAlign : 'left',
colorHex: edit.colorHex || DEFAULT_TEXT_COLOR,
}))
.filter((edit) => edit.text.trim().length > 0);
}
function measureLineWidth(line: string, fontSize: number, letterSpacing: number, font: Awaited<ReturnType<PDFDocument['embedFont']>>): number {
if (!line) return 0;
const chars = Array.from(line);
const baseWidth = chars.reduce((sum, char) => sum + font.widthOfTextAtSize(char, fontSize), 0);
return baseWidth + Math.max(0, chars.length - 1) * letterSpacing;
}
function drawSpacedLine(params: {
page: ReturnType<PDFDocument['getPages']>[number];
line: string;
x: number;
y: number;
fontSize: number;
letterSpacing: number;
font: Awaited<ReturnType<PDFDocument['embedFont']>>;
color: { r: number; g: number; b: number };
}) {
const { page, line, x, y, fontSize, letterSpacing, font, color } = params;
if (!line) return;
if (letterSpacing === 0) {
page.drawText(line, { x, y, size: fontSize, font, color: rgb(color.r, color.g, color.b) });
return;
}
let cursorX = x;
for (const char of Array.from(line)) {
page.drawText(char, { x: cursorX, y, size: fontSize, font, color: rgb(color.r, color.g, color.b) });
cursorX += font.widthOfTextAtSize(char, fontSize) + letterSpacing;
}
}
export async function applyPdfTextLayoutEdits(pdfBlob: Blob, edits: PdfTextLayoutEdit[]): Promise<Blob> {
const inputBytes = new Uint8Array(await pdfBlob.arrayBuffer());
const pdfDoc = await PDFDocument.load(inputBytes, { updateMetadata: false });
const pages = pdfDoc.getPages();
const safeEdits = sanitizeEdits(edits, pages.length);
const fontMap = {
TimesRoman: await pdfDoc.embedFont(StandardFonts.TimesRoman),
TimesRomanBold: await pdfDoc.embedFont(StandardFonts.TimesRomanBold),
Helvetica: await pdfDoc.embedFont(StandardFonts.Helvetica),
HelveticaBold: await pdfDoc.embedFont(StandardFonts.HelveticaBold),
} as const;
for (const edit of safeEdits) {
const page = pages[edit.page - 1];
if (!page) continue;
const color = parseHexColor(edit.colorHex);
const lines = edit.text.split('\n');
const font = fontMap[edit.fontName];
lines.forEach((line, index) => {
const lineWidth = measureLineWidth(line, edit.fontSize, edit.letterSpacing, font);
const availableWidth = Math.max(1, edit.boxWidth);
let startX = edit.x;
if (edit.textAlign === 'center') {
startX = edit.x + (availableWidth - lineWidth) / 2;
} else if (edit.textAlign === 'right') {
startX = edit.x + (availableWidth - lineWidth);
}
drawSpacedLine({
page,
line,
x: startX,
y: edit.y - index * edit.lineHeight,
fontSize: edit.fontSize,
letterSpacing: edit.letterSpacing,
font,
color,
});
});
}
const out = await pdfDoc.save({ useObjectStreams: true });
return new Blob([new Uint8Array(out)], { type: 'application/pdf' });
}
@@ -0,0 +1,32 @@
import type { ApplicationFormState } from './initiativeFormTypes';
import type { InitiativeFormState } from './initiativeFormTypes';
import { parseContentSummaryToSection42 } from './reportContentSummaryMerge';
/** Báo cáo §4.2 fields that roll into Đơn §4 `contentSummary` (merged + labeled). */
export type ReportSection42Key =
| 'purpose'
| 'implementationSteps'
| 'firstAppliedUnit'
| 'conditions'
| 'achievedResult';
/**
* Builds a patch for báo cáo §4.2 from đơn `contentSummary` (parsed blocks).
* `conditions` / `trialEvaluation` on the application are still kept in sync separately when edited alone.
*/
export function buildReportSection42PatchFromApplication(
app: Pick<ApplicationFormState, 'contentSummary'>,
report: InitiativeFormState,
): Partial<Pick<InitiativeFormState, ReportSection42Key>> | null {
const parsed = parseContentSummaryToSection42(app.contentSummary ?? '');
const patch: Partial<Pick<InitiativeFormState, ReportSection42Key>> = {};
let dirty = false;
(Object.entries(parsed) as [ReportSection42Key, string][]).forEach(([key, v]) => {
const next = v ?? '';
if ((report[key] ?? '') !== next) {
patch[key] = next;
dirty = true;
}
});
return dirty ? patch : null;
}
@@ -0,0 +1,80 @@
import type { InitiativeFormState } from './initiativeFormTypes';
/** Subset of báo cáo §56 fields that feed Đơn §5 (`authorEvaluation`). */
export type AuthorEvaluationMergeMetricKey = 'economic' | 'teaching' | 'safety' | 'social';
/** Ordered labels must match the báo cáo UI and stay stable for parse/format round-trips. */
export const AUTHOR_EVALUATION_MERGE_SECTIONS: ReadonlyArray<{
key: 'novelty' | AuthorEvaluationMergeMetricKey;
label: string;
}> = [
{ key: 'novelty', label: 'Tính mới của sáng kiến' },
{ key: 'economic', label: 'Hiệu quả Kinh tế' },
{ key: 'teaching', label: 'Hiệu quả Công việc/Giảng dạy' },
{ key: 'safety', label: 'Môi trường & An toàn' },
{ key: 'social', label: 'Nhận thức & Xã hội' },
];
type EffectivenessPick = Pick<InitiativeFormState['effectiveness'], AuthorEvaluationMergeMetricKey>;
const EMPTY_METRICS: EffectivenessPick = {
economic: '',
teaching: '',
safety: '',
social: '',
};
/**
* Builds Đơn §5 text from báo cáo “Đánh Giá Hiệu Quả” (tính mới + bốn hiệu quả).
* Empty sections are omitted.
*/
export function formatMergedAuthorEvaluation(
report: Pick<InitiativeFormState, 'novelty' | 'effectiveness'>,
): string {
const blocks: string[] = [];
for (const { key, label } of AUTHOR_EVALUATION_MERGE_SECTIONS) {
const raw =
key === 'novelty' ? report.novelty : report.effectiveness[key];
const value = String(raw ?? '').trim();
if (!value) continue;
blocks.push(`${label}\n${value}`);
}
return blocks.join('\n\n');
}
/**
* Parses Đơn §5 text back into báo cáo fields. Paragraphs are split by blank lines;
* each paragraph must start with a known heading line, or the whole string is treated as `novelty` only.
*/
export function parseMergedAuthorEvaluation(merged: string): {
novelty: string;
effectiveness: EffectivenessPick;
} {
const normalized = merged.replace(/\r\n/g, '\n').trim();
if (!normalized) {
return { novelty: '', effectiveness: { ...EMPTY_METRICS } };
}
const chunks = normalized.split(/\n\n+/);
let matchedAny = false;
const out = {
novelty: '',
effectiveness: { ...EMPTY_METRICS },
};
for (const chunk of chunks) {
const lines = chunk.split('\n');
const heading = lines[0]?.trim() ?? '';
const body = lines.slice(1).join('\n').trim();
const spec = AUTHOR_EVALUATION_MERGE_SECTIONS.find((s) => s.label === heading);
if (!spec) continue;
matchedAny = true;
if (spec.key === 'novelty') out.novelty = body;
else out.effectiveness[spec.key] = body;
}
if (!matchedAny) {
return { novelty: normalized, effectiveness: { ...EMPTY_METRICS } };
}
return out;
}
@@ -0,0 +1,69 @@
import type { InitiativeFormState } from './initiativeFormTypes';
/** Same five keys as {@link ReportSection42Key} in `reportApplicationFieldMirror`. */
export type Section42MergeKey = keyof Pick<
InitiativeFormState,
'purpose' | 'implementationSteps' | 'firstAppliedUnit' | 'conditions' | 'achievedResult'
>;
/** Labels aligned with báo cáo §4.2 (InitiativeReportForm). */
export const CONTENT_SUMMARY_SECTION_42: ReadonlyArray<{
key: Section42MergeKey;
label: string;
}> = [
{ key: 'purpose', label: 'Mục đích (Vấn đề cần giải quyết)' },
{ key: 'implementationSteps', label: 'Các bước thực hiện giải pháp' },
{ key: 'firstAppliedUnit', label: 'Đơn vị áp dụng lần đầu' },
{ key: 'conditions', label: 'Điều kiện cần thiết để áp dụng' },
{ key: 'achievedResult', label: 'Kết quả thu được' },
];
/**
* Đơn §4 « Nội dung của sáng kiến » — single block built from báo cáo §4.2 fields.
*/
export function formatContentSummaryFromSection42(
report: Pick<
InitiativeFormState,
'purpose' | 'implementationSteps' | 'firstAppliedUnit' | 'conditions' | 'achievedResult'
>,
): string {
const blocks: string[] = [];
for (const { key, label } of CONTENT_SUMMARY_SECTION_42) {
const value = String(report[key] ?? '').trim();
if (!value) continue;
blocks.push(`${label}\n${value}`);
}
return blocks.join('\n\n');
}
/**
* Parses đơn `contentSummary` back into §4.2 scalars. Paragraphs must start with a known heading line.
* If no headings match, the full string is treated as `purpose`.
*/
export function parseContentSummaryToSection42(
merged: string,
): Partial<Record<Section42MergeKey, string>> {
const normalized = merged.replace(/\r\n/g, '\n').trim();
if (!normalized) {
return {};
}
const chunks = normalized.split(/\n\n+/);
const out: Partial<Record<Section42MergeKey, string>> = {};
let matchedAny = false;
for (const chunk of chunks) {
const lines = chunk.split('\n');
const heading = lines[0]?.trim() ?? '';
const body = lines.slice(1).join('\n').trim();
const spec = CONTENT_SUMMARY_SECTION_42.find((s) => s.label === heading);
if (!spec) continue;
matchedAny = true;
out[spec.key] = body;
}
if (!matchedAny) {
return { purpose: normalized };
}
return out;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* Persisted applicant draft tabs as consumed by the review/preview layer.
*
* Decoupled into the shared kernel from fe0's
* `components/admin/review/buildInitiativeDraftFromReviewTabs` so the applicant
* workspace (frontend_user) and the admin/council review (frontend_admin) share
* one definition. The `buildInitiativeDraftFromReviewTabs` *function* still lives
* in admin code for now; moving it here is the slice-5 de-coupling step.
*/
export type ReviewDraftTabs = {
report?: Record<string, unknown>;
application?: Record<string, unknown>;
contribution?: Record<string, unknown>;
};
+73
View File
@@ -0,0 +1,73 @@
import { getApplicationEvidence } from './applicationEvidenceApi';
import { fileStore } from './draftStorage';
import type { FileHandle, ServerEvidenceKind } from './types';
import { fetchWithTimeout, isAbortError, FETCH_EVIDENCE_DEADLINE_MS } from '../lib/networkTimeout';
export async function putFile(
draftId: string,
field: string,
file: File | null,
): Promise<FileHandle | null> {
const key = `${draftId}:${field}`;
if (!file) {
await fileStore.del(key);
return null;
}
try {
await fileStore.put(key, file);
} catch (e) {
console.error('IndexedDB put failed', e);
throw e;
}
return {
key,
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
};
}
export type GetFileOptions = {
caseId?: string | null;
/** When handle.serverKind is missing, supply the fields evidence kind (2.1 / 2.2). */
serverKind?: ServerEvidenceKind;
};
export async function getFile(
handle: FileHandle | null,
options?: GetFileOptions,
): Promise<File | null> {
if (!handle) return null;
const blob = await fileStore.get(handle.key);
if (blob) {
return new File([blob], handle.name, { type: handle.type, lastModified: handle.lastModified });
}
const cid = options?.caseId;
const sk = handle.serverKind ?? options?.serverKind;
if (cid && sk) {
try {
const ev = await getApplicationEvidence(cid);
const item =
sk === 'research' ? ev.research : sk === 'textbook' ? ev.textbook : sk === 'technical' ? ev.technical : null;
const url = item?.downloadUrl;
if (url) {
const r = await fetchWithTimeout(url, {}, FETCH_EVIDENCE_DEADLINE_MS);
if (!r.ok) return null;
const b = await r.blob();
return new File([b], handle.name || item?.originalName || 'evidence.pdf', {
type: handle.type || 'application/pdf',
lastModified: handle.lastModified || Date.now(),
});
}
} catch (e) {
if (isAbortError(e)) {
throw new Error(
`Tải file minh chứng quá lâu (hết thời gian chờ ${Math.round(FETCH_EVIDENCE_DEADLINE_MS / 1000)} giây). Kiểm tra mạng rồi thử gửi lại.`,
);
}
console.error('Evidence download (getFile) failed', e);
}
}
return null;
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Shared types for DOCX template filling (docxtemplater + docx-preview + html2pdf).
*/
export type DataRecord = Record<string, string | number | boolean | DataRecord[] | null | undefined>;
export interface TemplatePlaceholder {
kind: 'scalar' | 'loop';
name: string;
/** Inner field names for `{#block}…{/block}` */
innerTags?: string[];
}
export type FillProgressPhase =
| 'parsing-template'
| 'parsing-data'
| 'rendering'
| 'converting-to-pdf'
| 'done'
| 'error';
export interface FillProgressEvent {
phase: FillProgressPhase;
error?: Error;
page?: number;
totalPages?: number;
}
export interface ParsedData {
format: 'json' | 'csv' | 'xlsx';
records: DataRecord[];
}
export interface RenderErrorDetails {
tag?: string;
id?: string;
explanation?: string;
}
export class RenderError extends Error {
details?: RenderErrorDetails[];
constructor(message: string, details?: RenderErrorDetails[]) {
super(message);
this.name = 'RenderError';
this.details = details;
}
}

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