sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
E2E_BASE_URL=http://localhost:8080
|
||||
E2E_LOGIN_EMAIL=your_account@ump.edu.vn
|
||||
E2E_LOGIN_PASSWORD=your_password
|
||||
|
||||
# Full-stack backup E2E (fe0/e2e/backup-admin-download.spec.ts + be0/tests/test_backup_e2e.py)
|
||||
# Use the same email in be0 AUTH_ADMIN_EMAILS (e.g. docker-compose environment).
|
||||
E2E_BACKUP=1
|
||||
E2E_ADMIN_EMAIL=e2e-backup-admin@ump.edu.vn
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,141 @@
|
||||
# Chat Assistant - Backend Integration
|
||||
|
||||
## Overview
|
||||
|
||||
The ChatAssistant component has been successfully integrated with the backend Ollama service to enable real-time policy question answering and content verification.
|
||||
|
||||
## What Was Done
|
||||
|
||||
### 1. Created Chat Feature Module
|
||||
|
||||
Following the new frontend architecture, a complete chat feature module was created:
|
||||
|
||||
- **Types** (`features/chat/types/chat.types.ts`): TypeScript types for messages and API requests
|
||||
- **Service** (`features/chat/services/chatService.ts`): API service for chat operations
|
||||
- **Hooks** (`features/chat/hooks/useChat.ts`): React hooks for chat functionality
|
||||
|
||||
### 2. Updated ChatAssistant Component
|
||||
|
||||
The `ChatAssistant` component now:
|
||||
- ✅ Connects to backend Ollama API (`/test_ollama_1` endpoint)
|
||||
- ✅ Sends real questions to the AI model
|
||||
- ✅ Displays AI responses in real-time
|
||||
- ✅ Handles verification requests from forms
|
||||
- ✅ Shows loading states during API calls
|
||||
- ✅ Displays error messages if API calls fail
|
||||
- ✅ Maintains conversation history
|
||||
|
||||
## API Endpoint
|
||||
|
||||
The chat uses the backend endpoint:
|
||||
- **URL**: `POST /test_ollama_1`
|
||||
- **Request Body**: `{ "prompt": "user question" }`
|
||||
- **Response**: `{ "oss_json": "AI response text" }`
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Policy Question Answering
|
||||
|
||||
Users can ask questions about:
|
||||
- IT governance policies
|
||||
- Compliance requirements
|
||||
- Regulatory standards
|
||||
- Workflow processes
|
||||
|
||||
**Example questions:**
|
||||
- "What are the requirements for ISO 27001 compliance?"
|
||||
- "How do I submit a workflow approval?"
|
||||
- "What documents are needed for compliance verification?"
|
||||
|
||||
### 2. Content Verification
|
||||
|
||||
When users click "Verify" on form fields, the assistant:
|
||||
- Analyzes the content against compliance requirements
|
||||
- Provides feedback on whether it meets requirements
|
||||
- Suggests improvements
|
||||
- Identifies potential issues
|
||||
|
||||
### 3. Real-time Responses
|
||||
|
||||
- Loading indicators while waiting for AI response
|
||||
- Error handling with user-friendly messages
|
||||
- Conversation history maintained in the chat
|
||||
|
||||
## Usage
|
||||
|
||||
The ChatAssistant is already integrated in the Dashboard. Users can:
|
||||
|
||||
1. **Ask Questions**: Type questions in the chat input and press Enter
|
||||
2. **Verify Content**: Click "Verify" buttons on form fields to get AI feedback
|
||||
3. **View History**: Scroll through previous messages
|
||||
|
||||
## Configuration
|
||||
|
||||
The API URL is configured in:
|
||||
- **File**: `src/shared/config/env.ts`
|
||||
- **Environment Variable**: `VITE_API_URL`
|
||||
- **Default**: `http://localhost:4402`
|
||||
|
||||
To change the API URL, update the `.env` file:
|
||||
```env
|
||||
VITE_API_URL=http://your-backend-url:4402
|
||||
```
|
||||
|
||||
## Backend Requirements
|
||||
|
||||
Ensure the backend is running and accessible:
|
||||
1. Backend service should be running on port 4402
|
||||
2. Ollama service should be running and accessible
|
||||
3. The `/test_ollama_1` endpoint should be available
|
||||
|
||||
## Testing
|
||||
|
||||
To test the integration:
|
||||
|
||||
1. Start the backend:
|
||||
```bash
|
||||
cd be0
|
||||
docker-compose up be0
|
||||
```
|
||||
|
||||
2. Start the frontend:
|
||||
```bash
|
||||
cd fe0
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Open the Dashboard and try:
|
||||
- Asking a question about policies
|
||||
- Verifying form content
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: "Failed to get response from assistant"
|
||||
|
||||
**Solutions:**
|
||||
- Check if backend is running: `curl http://localhost:4402/health`
|
||||
- Check browser console for detailed error messages
|
||||
- Verify `VITE_API_URL` in `.env` file
|
||||
|
||||
### Issue: "Network error"
|
||||
|
||||
**Solutions:**
|
||||
- Ensure backend is accessible at the configured URL
|
||||
- Check CORS settings in backend
|
||||
- Verify network connectivity
|
||||
|
||||
### Issue: Slow responses
|
||||
|
||||
**Solutions:**
|
||||
- Ollama model might be loading (first request)
|
||||
- Check backend logs for errors
|
||||
- Verify Ollama service is running
|
||||
|
||||
## Next Steps
|
||||
|
||||
Potential improvements:
|
||||
1. Add streaming responses for real-time text generation
|
||||
2. Add conversation context/memory
|
||||
3. Add support for document uploads
|
||||
4. Add voice input/output
|
||||
5. Add response rating/feedback
|
||||
@@ -0,0 +1,102 @@
|
||||
# Debug Network Error in Chat Assistant
|
||||
|
||||
## Issue
|
||||
Frontend shows "Network error. Please check your connection." but backend logs show 200 OK.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
The backend is successfully processing requests (200 OK), but the frontend axios client is reporting a network error. This typically means:
|
||||
|
||||
1. **CORS Issue**: Response is blocked by browser CORS policy
|
||||
2. **Response Format**: Response doesn't match expected format
|
||||
3. **Timeout**: Request takes too long (>30s)
|
||||
4. **Network Path**: Browser can't reach the backend URL
|
||||
|
||||
## Debugging Steps
|
||||
|
||||
### 1. Check Browser Console
|
||||
Open browser DevTools (F12) and check:
|
||||
- Console tab for error messages
|
||||
- Network tab to see the actual request/response
|
||||
- Look for CORS errors (red text)
|
||||
|
||||
### 2. Check API URL
|
||||
The frontend should use:
|
||||
- **Local development**: `http://localhost:4402`
|
||||
- **Docker**: Should still use `http://localhost:4402` (browser makes request from host)
|
||||
|
||||
### 3. Test API Directly
|
||||
```bash
|
||||
# Test from your machine (not Docker)
|
||||
curl -X POST http://localhost:4402/api/v1/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message": "Hello"}'
|
||||
```
|
||||
|
||||
### 4. Check CORS Headers
|
||||
The backend should return CORS headers. Check in browser Network tab:
|
||||
- `Access-Control-Allow-Origin: *`
|
||||
- `Access-Control-Allow-Methods: *`
|
||||
- `Access-Control-Allow-Headers: *`
|
||||
|
||||
### 5. Check Response Format
|
||||
The backend returns:
|
||||
```json
|
||||
{
|
||||
"message": "AI response text",
|
||||
"model": "gemma3:270M",
|
||||
"tokens_used": 150
|
||||
}
|
||||
```
|
||||
|
||||
The frontend expects this format in `ChatResponse`.
|
||||
|
||||
## Quick Fixes
|
||||
|
||||
### Fix 1: Verify API URL
|
||||
Check what URL the frontend is using:
|
||||
1. Open browser console
|
||||
2. Look for: `API Client initialized with baseURL: ...`
|
||||
3. Should be: `http://localhost:4402`
|
||||
|
||||
### Fix 2: Check CORS
|
||||
Backend has `allow_origins=["*"]` which should work, but verify:
|
||||
1. Check browser Network tab
|
||||
2. Look for OPTIONS preflight request
|
||||
3. Should return 200 OK
|
||||
|
||||
### Fix 3: Test with curl
|
||||
```bash
|
||||
curl -v -X POST http://localhost:4402/api/v1/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: http://localhost:8081" \
|
||||
-d '{"message": "test"}'
|
||||
```
|
||||
|
||||
### Fix 4: Check Response Time
|
||||
The backend log shows 6.3 seconds. If axios timeout is shorter, it will fail. Current timeout is 30s, so should be fine.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
1. Browser makes POST to `http://localhost:4402/api/v1/chat`
|
||||
2. Backend processes request (6-7 seconds for Ollama)
|
||||
3. Backend returns 200 OK with JSON response
|
||||
4. Frontend receives response and displays it
|
||||
|
||||
## Current Status
|
||||
|
||||
✅ Backend: Working (200 OK)
|
||||
❌ Frontend: Network error
|
||||
🔍 Need to check: Browser console, Network tab, CORS headers
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Open browser DevTools
|
||||
2. Go to Network tab
|
||||
3. Make a chat request
|
||||
4. Check the request details:
|
||||
- URL
|
||||
- Status code
|
||||
- Response headers
|
||||
- Response body
|
||||
5. Check Console tab for errors
|
||||
@@ -0,0 +1,20 @@
|
||||
# Use Node.js 20 Alpine as the base image
|
||||
FROM node:22-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (if exists) to leverage Docker cache
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies in a single RUN command to reduce layers
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the application code
|
||||
COPY . .
|
||||
|
||||
# Expose Vite's default port
|
||||
EXPOSE 8080
|
||||
|
||||
# Install deps then dev server (needed when /app/node_modules is a Docker volume).
|
||||
CMD ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0 --port 8080"]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Production frontend: build static assets, serve via nginx (no Vite dev server).
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_ENV=production
|
||||
ENV VITE_ENV=production
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,658 @@
|
||||
# Frontend Architecture Redesign
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a production-ready frontend architecture for the ProfytAI Compliance Management Platform, addressing scalability, maintainability, and developer experience.
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Feature-Based Organization**: Group related code by feature/domain
|
||||
2. **Separation of Concerns**: Clear boundaries between layers
|
||||
3. **Type Safety**: Full TypeScript coverage
|
||||
4. **Reusability**: Shared components and utilities
|
||||
5. **Testability**: Easy to test components and logic
|
||||
6. **Performance**: Code splitting, lazy loading, optimization
|
||||
7. **Developer Experience**: Clear structure, good DX
|
||||
|
||||
---
|
||||
|
||||
## New Directory Structure
|
||||
|
||||
```
|
||||
fe0/
|
||||
├── src/
|
||||
│ ├── app/ # App-level configuration
|
||||
│ │ ├── providers/ # Global providers
|
||||
│ │ │ ├── QueryProvider.tsx
|
||||
│ │ │ ├── AuthProvider.tsx
|
||||
│ │ │ ├── ThemeProvider.tsx
|
||||
│ │ │ └── ErrorBoundary.tsx
|
||||
│ │ ├── router/ # Routing configuration
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ ├── routes.tsx
|
||||
│ │ │ └── guards.tsx
|
||||
│ │ └── store/ # Global state (if needed)
|
||||
│ │ └── index.ts
|
||||
│ │
|
||||
│ ├── features/ # Feature-based modules
|
||||
│ │ ├── auth/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── LoginForm.tsx
|
||||
│ │ │ │ └── SignUpForm.tsx
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ │ ├── useAuth.ts
|
||||
│ │ │ │ └── useLogin.ts
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ └── authService.ts
|
||||
│ │ │ ├── types/
|
||||
│ │ │ │ └── auth.types.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── workflows/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ │ ├── WorkflowList.tsx
|
||||
│ │ │ │ ├── WorkflowCard.tsx
|
||||
│ │ │ │ ├── WorkflowForm.tsx
|
||||
│ │ │ │ └── WorkflowItem.tsx
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ │ ├── useWorkflows.ts
|
||||
│ │ │ │ ├── useWorkflow.ts
|
||||
│ │ │ │ └── useCreateWorkflow.ts
|
||||
│ │ │ ├── services/
|
||||
│ │ │ │ └── workflowService.ts
|
||||
│ │ │ ├── types/
|
||||
│ │ │ │ └── workflow.types.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ ├── documents/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ └── types/
|
||||
│ │ │
|
||||
│ │ ├── compliance/
|
||||
│ │ │ ├── components/
|
||||
│ │ │ ├── hooks/
|
||||
│ │ │ ├── services/
|
||||
│ │ │ └── types/
|
||||
│ │ │
|
||||
│ │ └── dashboard/
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ └── types/
|
||||
│ │
|
||||
│ ├── shared/ # Shared code
|
||||
│ │ ├── api/ # API client
|
||||
│ │ │ ├── client.ts # Axios/Fetch client
|
||||
│ │ │ ├── interceptors.ts # Request/Response interceptors
|
||||
│ │ │ ├── endpoints.ts # API endpoint constants
|
||||
│ │ │ └── types.ts # API response types
|
||||
│ │ │
|
||||
│ │ ├── components/ # Shared UI components
|
||||
│ │ │ ├── ui/ # shadcn components (existing)
|
||||
│ │ │ ├── layout/
|
||||
│ │ │ │ ├── Header.tsx
|
||||
│ │ │ │ ├── Sidebar.tsx
|
||||
│ │ │ │ └── Footer.tsx
|
||||
│ │ │ ├── feedback/
|
||||
│ │ │ │ ├── LoadingSpinner.tsx
|
||||
│ │ │ │ ├── ErrorMessage.tsx
|
||||
│ │ │ │ └── EmptyState.tsx
|
||||
│ │ │ └── forms/
|
||||
│ │ │ └── FormField.tsx
|
||||
│ │ │
|
||||
│ │ ├── hooks/ # Shared hooks
|
||||
│ │ │ ├── useApi.ts
|
||||
│ │ │ ├── useDebounce.ts
|
||||
│ │ │ ├── useLocalStorage.ts
|
||||
│ │ │ └── useMediaQuery.ts
|
||||
│ │ │
|
||||
│ │ ├── utils/ # Utility functions
|
||||
│ │ │ ├── format.ts
|
||||
│ │ │ ├── validation.ts
|
||||
│ │ │ ├── date.ts
|
||||
│ │ │ └── constants.ts
|
||||
│ │ │
|
||||
│ │ ├── types/ # Shared TypeScript types
|
||||
│ │ │ ├── api.types.ts
|
||||
│ │ │ ├── common.types.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ │
|
||||
│ │ └── config/ # Configuration
|
||||
│ │ ├── env.ts # Environment variables
|
||||
│ │ ├── constants.ts # App constants
|
||||
│ │ └── routes.ts # Route constants
|
||||
│ │
|
||||
│ ├── pages/ # Page components
|
||||
│ │ ├── public/
|
||||
│ │ │ ├── HomePage.tsx
|
||||
│ │ │ ├── LoginPage.tsx
|
||||
│ │ │ └── NotFoundPage.tsx
|
||||
│ │ └── protected/
|
||||
│ │ ├── DashboardPage.tsx
|
||||
│ │ └── WorkflowsPage.tsx
|
||||
│ │
|
||||
│ ├── layouts/ # Layout components
|
||||
│ │ ├── PublicLayout.tsx
|
||||
│ │ ├── DashboardLayout.tsx
|
||||
│ │ └── AuthLayout.tsx
|
||||
│ │
|
||||
│ ├── assets/ # Static assets
|
||||
│ │ ├── images/
|
||||
│ │ ├── icons/
|
||||
│ │ └── fonts/
|
||||
│ │
|
||||
│ ├── styles/ # Global styles
|
||||
│ │ ├── globals.css
|
||||
│ │ └── themes.css
|
||||
│ │
|
||||
│ ├── App.tsx # Root component
|
||||
│ └── main.tsx # Entry point
|
||||
│
|
||||
├── public/ # Public assets
|
||||
├── tests/ # Test files
|
||||
│ ├── unit/
|
||||
│ ├── integration/
|
||||
│ ├── e2e/
|
||||
│ └── setup.ts
|
||||
│
|
||||
├── .env.example
|
||||
├── .env.development
|
||||
├── .env.production
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Architectural Components
|
||||
|
||||
### 1. API Client Layer
|
||||
|
||||
**Purpose**: Centralized HTTP client with interceptors, error handling, and type safety.
|
||||
|
||||
**Features**:
|
||||
- Request/Response interceptors
|
||||
- Automatic token refresh
|
||||
- Error handling
|
||||
- Type-safe endpoints
|
||||
- Request cancellation
|
||||
- Retry logic
|
||||
|
||||
### 2. Service Layer
|
||||
|
||||
**Purpose**: Business logic and API communication abstraction.
|
||||
|
||||
**Features**:
|
||||
- Type-safe API calls
|
||||
- Data transformation
|
||||
- Error handling
|
||||
- Caching strategies
|
||||
|
||||
### 3. Feature Modules
|
||||
|
||||
**Purpose**: Self-contained feature modules with all related code.
|
||||
|
||||
**Structure**:
|
||||
- Components (UI)
|
||||
- Hooks (React hooks)
|
||||
- Services (API calls)
|
||||
- Types (TypeScript types)
|
||||
- Utils (Feature-specific utilities)
|
||||
|
||||
### 4. Shared Layer
|
||||
|
||||
**Purpose**: Reusable code across features.
|
||||
|
||||
**Includes**:
|
||||
- UI components
|
||||
- Utilities
|
||||
- Hooks
|
||||
- Types
|
||||
- Configuration
|
||||
|
||||
### 5. Routing
|
||||
|
||||
**Purpose**: Centralized route configuration with guards.
|
||||
|
||||
**Features**:
|
||||
- Route guards (auth, permissions)
|
||||
- Lazy loading
|
||||
- Code splitting
|
||||
- Route constants
|
||||
|
||||
---
|
||||
|
||||
## Implementation Examples
|
||||
|
||||
### Example 1: API Client
|
||||
|
||||
```typescript
|
||||
// shared/api/client.ts
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { env } from '@/shared/config/env';
|
||||
import { getAccessToken, clearAccessToken, setAccessToken } from '@/shared/utils/token';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: env.API_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
// Request interceptor
|
||||
this.client.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = getAccessToken();
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle 401 - Unauthorized
|
||||
if (error.response?.status === 401 && !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();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
return Promise.reject(this.handleError(error));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async refreshToken(): Promise<string | null> {
|
||||
try {
|
||||
const response = await axios.post(`${env.API_URL}/auth/refresh`, {}, {
|
||||
withCredentials: true,
|
||||
});
|
||||
const { accessToken } = response.data;
|
||||
setAccessToken(accessToken);
|
||||
return accessToken;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleError(error: AxiosError): ApiError {
|
||||
if (error.response) {
|
||||
// Server responded with error
|
||||
return {
|
||||
message: error.response.data?.message || 'An error occurred',
|
||||
status: error.response.status,
|
||||
data: error.response.data,
|
||||
};
|
||||
} else if (error.request) {
|
||||
// Request made but no response
|
||||
return {
|
||||
message: 'Network error. Please check your connection.',
|
||||
status: 0,
|
||||
};
|
||||
} else {
|
||||
// Error setting up request
|
||||
return {
|
||||
message: error.message || 'An unexpected error occurred',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public get<T = any>(url: string, config?: any): Promise<T> {
|
||||
return this.client.get<T>(url, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
public post<T = any>(url: string, data?: any, config?: any): Promise<T> {
|
||||
return this.client.post<T>(url, data, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
public put<T = any>(url: string, data?: any, config?: any): Promise<T> {
|
||||
return this.client.put<T>(url, data, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
public delete<T = any>(url: string, config?: any): Promise<T> {
|
||||
return this.client.delete<T>(url, config).then((res) => res.data);
|
||||
}
|
||||
|
||||
public patch<T = any>(url: string, data?: any, config?: any): Promise<T> {
|
||||
return this.client.patch<T>(url, data, config).then((res) => res.data);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export type ApiError = {
|
||||
message: string;
|
||||
status: number;
|
||||
data?: any;
|
||||
};
|
||||
```
|
||||
|
||||
### Example 2: Service Layer
|
||||
|
||||
```typescript
|
||||
// features/workflows/services/workflowService.ts
|
||||
import { apiClient } from '@/shared/api/client';
|
||||
import { Workflow, CreateWorkflowDto, UpdateWorkflowItemDto } from '../types/workflow.types';
|
||||
import { ApiResponse, PaginatedResponse } from '@/shared/types/api.types';
|
||||
|
||||
export const workflowService = {
|
||||
async getAll(params?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
projectName?: string;
|
||||
}): Promise<PaginatedResponse<Workflow>> {
|
||||
return apiClient.get<PaginatedResponse<Workflow>>('/api/v1/workflows', { params });
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<Workflow> {
|
||||
return apiClient.get<Workflow>(`/api/v1/workflows/${id}`);
|
||||
},
|
||||
|
||||
async create(data: CreateWorkflowDto): Promise<Workflow> {
|
||||
return apiClient.post<Workflow>('/api/v1/workflows', data);
|
||||
},
|
||||
|
||||
async updateItem(
|
||||
workflowId: string,
|
||||
data: UpdateWorkflowItemDto
|
||||
): Promise<Workflow> {
|
||||
return apiClient.put<Workflow>(
|
||||
`/api/v1/workflows/${workflowId}/items`,
|
||||
data
|
||||
);
|
||||
},
|
||||
|
||||
async advancePhase(workflowId: string): Promise<Workflow> {
|
||||
return apiClient.post<Workflow>(
|
||||
`/api/v1/workflows/${workflowId}/advance`
|
||||
);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return apiClient.delete(`/api/v1/workflows/${id}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Example 3: Custom Hooks
|
||||
|
||||
```typescript
|
||||
// features/workflows/hooks/useWorkflows.ts
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { workflowService } from '../services/workflowService';
|
||||
import { CreateWorkflowDto, UpdateWorkflowItemDto } from '../types/workflow.types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const useWorkflows = (params?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
projectName?: string;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: ['workflows', params],
|
||||
queryFn: () => workflowService.getAll(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useWorkflow = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['workflow', id],
|
||||
queryFn: () => workflowService.getById(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateWorkflow = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateWorkflowDto) => workflowService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success('Workflow created successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Failed to create workflow');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateWorkflowItem = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ workflowId, data }: { workflowId: string; data: UpdateWorkflowItemDto }) =>
|
||||
workflowService.updateItem(workflowId, data),
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['workflow', variables.workflowId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success('Workflow item updated');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || 'Failed to update workflow item');
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Example 4: Environment Configuration
|
||||
|
||||
```typescript
|
||||
// shared/config/env.ts
|
||||
interface EnvConfig {
|
||||
API_URL: string;
|
||||
WS_URL?: string;
|
||||
ENVIRONMENT: 'development' | 'staging' | 'production';
|
||||
ENABLE_DEV_TOOLS: boolean;
|
||||
}
|
||||
|
||||
const getEnvVar = (key: string, defaultValue?: string): string => {
|
||||
const value = import.meta.env[key];
|
||||
if (!value && !defaultValue) {
|
||||
throw new Error(`Missing required environment variable: ${key}`);
|
||||
}
|
||||
return value || defaultValue || '';
|
||||
};
|
||||
|
||||
export const env: EnvConfig = {
|
||||
API_URL: getEnvVar('VITE_API_URL', 'http://localhost:4402'),
|
||||
WS_URL: getEnvVar('VITE_WS_URL'),
|
||||
ENVIRONMENT: (getEnvVar('VITE_ENV', 'development') as EnvConfig['ENVIRONMENT']),
|
||||
ENABLE_DEV_TOOLS: getEnvVar('VITE_ENABLE_DEV_TOOLS', 'false') === 'true',
|
||||
};
|
||||
|
||||
export const isDevelopment = env.ENVIRONMENT === 'development';
|
||||
export const isProduction = env.ENVIRONMENT === 'production';
|
||||
```
|
||||
|
||||
### Example 5: Route Configuration
|
||||
|
||||
```typescript
|
||||
// app/router/routes.tsx
|
||||
import { lazy } from 'react';
|
||||
import { RouteObject } from 'react-router-dom';
|
||||
import { PublicLayout } from '@/layouts/PublicLayout';
|
||||
import { DashboardLayout } from '@/layouts/DashboardLayout';
|
||||
import { ProtectedRoute } from '@/app/router/guards';
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const HomePage = lazy(() => import('@/pages/public/HomePage'));
|
||||
const LoginPage = lazy(() => import('@/pages/public/LoginPage'));
|
||||
const DashboardPage = lazy(() => import('@/pages/protected/DashboardPage'));
|
||||
const WorkflowsPage = lazy(() => import('@/pages/protected/WorkflowsPage'));
|
||||
const NotFoundPage = lazy(() => import('@/pages/public/NotFoundPage'));
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: '/',
|
||||
element: <PublicLayout />,
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ path: 'about', element: <div>About</div> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
element: (
|
||||
<ProtectedRoute requiredPermission="dashboard.access">
|
||||
<DashboardLayout />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{
|
||||
path: 'workflows',
|
||||
element: (
|
||||
<ProtectedRoute requiredPermission="workflows.view">
|
||||
<WorkflowsPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
];
|
||||
```
|
||||
|
||||
### Example 6: Error Boundary
|
||||
|
||||
```typescript
|
||||
// app/providers/ErrorBoundary.tsx
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Button } from '@/shared/components/ui/button';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
// Log to error reporting service
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-destructive" />
|
||||
<h1 className="mt-4 text-2xl font-bold">Something went wrong</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<Button onClick={this.handleReset} className="mt-4">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Scalability**: Easy to add new features
|
||||
2. **Maintainability**: Clear structure, easy to find code
|
||||
3. **Type Safety**: Full TypeScript coverage
|
||||
4. **Testability**: Easy to test components and logic
|
||||
5. **Performance**: Code splitting, lazy loading
|
||||
6. **Developer Experience**: Clear patterns, good DX
|
||||
7. **Reusability**: Shared components and utilities
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Foundation (Week 1)
|
||||
1. Set up new directory structure
|
||||
2. Create API client
|
||||
3. Set up environment configuration
|
||||
4. Create shared utilities
|
||||
|
||||
### Phase 2: Features (Week 2-3)
|
||||
1. Migrate auth feature
|
||||
2. Migrate workflows feature
|
||||
3. Migrate documents feature
|
||||
4. Migrate compliance feature
|
||||
|
||||
### Phase 3: Refinement (Week 4)
|
||||
1. Add error boundaries
|
||||
2. Improve error handling
|
||||
3. Add loading states
|
||||
4. Optimize performance
|
||||
|
||||
### Phase 4: Testing & Polish (Week 5)
|
||||
1. Write tests
|
||||
2. Add documentation
|
||||
3. Performance optimization
|
||||
4. Final review
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Review and approve architecture
|
||||
2. Create detailed implementation plan
|
||||
3. Set up project structure
|
||||
4. Begin Phase 1 implementation
|
||||
@@ -0,0 +1,124 @@
|
||||
# Frontend Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide helps you migrate from the current frontend structure to the new production-ready architecture.
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### Step 1: Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install axios
|
||||
```
|
||||
|
||||
### Step 2: Create New Directory Structure
|
||||
|
||||
Create the following directories:
|
||||
|
||||
```bash
|
||||
mkdir -p src/app/providers
|
||||
mkdir -p src/app/router
|
||||
mkdir -p src/features/workflows/{components,hooks,services,types}
|
||||
mkdir -p src/features/auth/{components,hooks,services,types}
|
||||
mkdir -p src/shared/api
|
||||
mkdir -p src/shared/components/feedback
|
||||
mkdir -p src/shared/config
|
||||
mkdir -p src/shared/utils
|
||||
mkdir -p src/shared/types
|
||||
```
|
||||
|
||||
### Step 3: Move Files
|
||||
|
||||
1. **Move API-related code**:
|
||||
- `lib/auth-service.ts` → `features/auth/services/authService.ts`
|
||||
- Create `shared/api/client.ts` (new API client)
|
||||
|
||||
2. **Move components by feature**:
|
||||
- Workflow-related components → `features/workflows/components/`
|
||||
- Auth components → `features/auth/components/`
|
||||
|
||||
3. **Move shared components**:
|
||||
- UI components stay in `components/ui/`
|
||||
- Layout components → `layouts/`
|
||||
- Shared components → `shared/components/`
|
||||
|
||||
### Step 4: Update Imports
|
||||
|
||||
Update all import paths to use the new structure:
|
||||
|
||||
```typescript
|
||||
// Old
|
||||
import { authService } from '@/lib/auth-service';
|
||||
|
||||
// New
|
||||
import { authService } from '@/features/auth/services/authService';
|
||||
```
|
||||
|
||||
### Step 5: Update App.tsx
|
||||
|
||||
Replace the current `App.tsx` with the new structure that includes:
|
||||
- Error boundaries
|
||||
- Query client setup
|
||||
- Route configuration
|
||||
- Suspense boundaries
|
||||
|
||||
### Step 6: Create Environment Files
|
||||
|
||||
1. Copy `.env.example` to `.env.development`
|
||||
2. Update environment variables
|
||||
3. Add `.env.production` for production
|
||||
|
||||
### Step 7: Update TypeScript Config
|
||||
|
||||
Ensure `tsconfig.json` has proper path aliases:
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing the Migration
|
||||
|
||||
1. **Start the development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **Test key features**:
|
||||
- Authentication flow
|
||||
- API calls
|
||||
- Error handling
|
||||
- Loading states
|
||||
|
||||
3. **Check for console errors**:
|
||||
- Fix any import errors
|
||||
- Fix any type errors
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Issue: Import errors
|
||||
|
||||
**Solution**: Update all import paths to match the new structure.
|
||||
|
||||
### Issue: Missing types
|
||||
|
||||
**Solution**: Create type definitions in `features/*/types/` or `shared/types/`.
|
||||
|
||||
### Issue: API calls failing
|
||||
|
||||
**Solution**: Ensure the API client is properly configured with the correct base URL.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Migrate remaining features
|
||||
2. Add error boundaries to specific routes
|
||||
3. Implement loading states
|
||||
4. Add tests
|
||||
5. Optimize bundle size
|
||||
@@ -0,0 +1,110 @@
|
||||
# Frontend Architecture Redesign - Summary
|
||||
|
||||
## Quick Overview
|
||||
|
||||
This document provides a quick reference for the frontend architectural improvements.
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### 1. **Feature-Based Organization**
|
||||
- Code organized by feature/domain
|
||||
- Each feature is self-contained
|
||||
- Easy to find and maintain code
|
||||
|
||||
### 2. **API Client Layer**
|
||||
- Centralized HTTP client
|
||||
- Automatic token refresh
|
||||
- Error handling
|
||||
- Type-safe API calls
|
||||
|
||||
### 3. **Service Layer**
|
||||
- Business logic abstraction
|
||||
- Type-safe API calls
|
||||
- Data transformation
|
||||
|
||||
### 4. **Custom Hooks**
|
||||
- React Query integration
|
||||
- Reusable data fetching logic
|
||||
- Automatic cache management
|
||||
|
||||
### 5. **Error Handling**
|
||||
- Error boundaries
|
||||
- Consistent error messages
|
||||
- User-friendly error UI
|
||||
|
||||
### 6. **Configuration Management**
|
||||
- Environment variables
|
||||
- Type-safe configuration
|
||||
- Centralized constants
|
||||
|
||||
### 7. **Route Organization**
|
||||
- Centralized route configuration
|
||||
- Lazy loading
|
||||
- Code splitting
|
||||
|
||||
## New Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # App-level config
|
||||
├── features/ # Feature modules
|
||||
├── shared/ # Shared code
|
||||
├── pages/ # Page components
|
||||
└── layouts/ # Layout components
|
||||
```
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **API Client** (`shared/api/client.ts`)
|
||||
- Centralized HTTP client
|
||||
- Request/Response interceptors
|
||||
- Error handling
|
||||
|
||||
2. **Environment Config** (`shared/config/env.ts`)
|
||||
- Type-safe environment variables
|
||||
- Validation
|
||||
|
||||
3. **Workflow Feature** (`features/workflows/`)
|
||||
- Types, services, hooks
|
||||
- Complete feature module
|
||||
|
||||
4. **Error Boundary** (`app/providers/ErrorBoundary.tsx`)
|
||||
- Global error handling
|
||||
- User-friendly error UI
|
||||
|
||||
5. **Route Configuration** (`app/router/routes.tsx`)
|
||||
- Centralized routes
|
||||
- Lazy loading
|
||||
|
||||
6. **Updated App.tsx** (`app/App.tsx`)
|
||||
- Providers setup
|
||||
- Error boundaries
|
||||
- Suspense
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Scalability**: Easy to add new features
|
||||
2. **Maintainability**: Clear structure
|
||||
3. **Type Safety**: Full TypeScript coverage
|
||||
4. **Performance**: Code splitting, lazy loading
|
||||
5. **Developer Experience**: Better DX
|
||||
6. **Testability**: Easy to test
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Install dependencies: `npm install axios`
|
||||
2. Review `FRONTEND_ARCHITECTURE.md` for details
|
||||
3. Follow `FRONTEND_MIGRATION_GUIDE.md` for migration
|
||||
4. Start migrating features one by one
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Install axios
|
||||
- [ ] Create directory structure
|
||||
- [ ] Move API client code
|
||||
- [ ] Migrate workflow feature
|
||||
- [ ] Migrate auth feature
|
||||
- [ ] Update imports
|
||||
- [ ] Test all features
|
||||
- [ ] Add error boundaries
|
||||
- [ ] Optimize bundle
|
||||
@@ -0,0 +1,66 @@
|
||||
# QA Test: Applicant Can Open Fresh Registration Forms
|
||||
|
||||
## Objective
|
||||
Verify that an applicant user can start a **new** registration flow and open all required blank forms:
|
||||
- Initiative report form
|
||||
- Initiative application form
|
||||
- Contribution confirmation form
|
||||
|
||||
## Scope
|
||||
- Frontend route: `/dashboard`
|
||||
- Fresh-mode trigger: `?fresh=1`
|
||||
- Session reset behavior for applicant registration
|
||||
|
||||
## Preconditions
|
||||
- App is running locally (`fe0` and required backend services).
|
||||
- Test account exists with applicant access.
|
||||
- Browser session is logged in as applicant.
|
||||
- At least one old draft may exist (to confirm fresh reset ignores previous session state).
|
||||
|
||||
## Test Data
|
||||
- Applicant account credentials
|
||||
- Optional: existing draft case id from prior session
|
||||
|
||||
## Test Case 1: Direct Fresh Link Opens Blank Forms
|
||||
1. Navigate to `/dashboard?fresh=1`.
|
||||
2. Wait for dashboard to load completely.
|
||||
3. Confirm the active tab is report form (first step).
|
||||
4. Confirm report form fields are blank/default (not prefilled from previous draft).
|
||||
5. Switch to application form tab.
|
||||
6. Confirm application form is visible and blank/default.
|
||||
7. Switch to contribution form tab.
|
||||
8. Confirm contribution form is visible and blank/default.
|
||||
|
||||
Expected result:
|
||||
- Dashboard opens in fresh mode.
|
||||
- Applicant can open all three forms.
|
||||
- No stale data from a previous draft appears.
|
||||
|
||||
## Test Case 2: "Create New" / Fresh Navigation from Applicant History
|
||||
1. Open applicant history panel/page.
|
||||
2. Click the action that starts a new application (fresh registration path).
|
||||
3. Confirm browser lands on dashboard fresh flow.
|
||||
4. Confirm report, application, and contribution forms are all accessible.
|
||||
5. Confirm each form starts empty.
|
||||
|
||||
Expected result:
|
||||
- Fresh navigation clears registration session state.
|
||||
- Applicant can open all required forms for a brand-new submission.
|
||||
|
||||
## Test Case 3: Fresh Flow Clears Previous Draft Context
|
||||
1. Start from a resumed draft (`/dashboard?caseId=<existing-id>`) and verify data is loaded.
|
||||
2. Then navigate to `/dashboard?fresh=1`.
|
||||
3. Confirm previously loaded draft values are no longer shown.
|
||||
4. Confirm a new save operation creates/uses a new case id (not the previous one).
|
||||
|
||||
Expected result:
|
||||
- Fresh mode always resets draft context and starts a new application flow.
|
||||
|
||||
## Regression Checks
|
||||
- Applicant remains authenticated after entering fresh flow.
|
||||
- No console errors while switching among tabs/forms.
|
||||
- Page remains responsive after opening all forms.
|
||||
|
||||
## Pass/Fail Criteria
|
||||
- **Pass**: Applicant can open all fresh forms and no stale draft data leaks into the new registration flow.
|
||||
- **Fail**: Any required form fails to open, opens with stale data, or fresh navigation does not reset prior draft context.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Welcome to your Lovable project
|
||||
|
||||
## Project info
|
||||
|
||||
**URL**: https://lovable.dev/projects/7410f81b-8218-4f2d-bb32-1ba1f84eabb2
|
||||
|
||||
## How can I edit this code?
|
||||
|
||||
There are several ways of editing your application.
|
||||
|
||||
**Use Lovable**
|
||||
|
||||
Simply visit the [Lovable Project](https://lovable.dev/projects/7410f81b-8218-4f2d-bb32-1ba1f84eabb2) and start prompting.
|
||||
|
||||
Changes made via Lovable will be committed automatically to this repo.
|
||||
|
||||
**Use your preferred IDE**
|
||||
|
||||
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
|
||||
|
||||
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
|
||||
|
||||
Follow these steps:
|
||||
|
||||
```sh
|
||||
# Step 1: Clone the repository using the project's Git URL.
|
||||
git clone <YOUR_GIT_URL>
|
||||
|
||||
# Step 2: Navigate to the project directory.
|
||||
cd <YOUR_PROJECT_NAME>
|
||||
|
||||
# Step 3: Install the necessary dependencies.
|
||||
npm i
|
||||
|
||||
# Step 4: Start the development server with auto-reloading and an instant preview.
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Edit a file directly in GitHub**
|
||||
|
||||
- Navigate to the desired file(s).
|
||||
- Click the "Edit" button (pencil icon) at the top right of the file view.
|
||||
- Make your changes and commit the changes.
|
||||
|
||||
**Use GitHub Codespaces**
|
||||
|
||||
- Navigate to the main page of your repository.
|
||||
- Click on the "Code" button (green button) near the top right.
|
||||
- Select the "Codespaces" tab.
|
||||
- Click on "New codespace" to launch a new Codespace environment.
|
||||
- Edit files directly within the Codespace and commit and push your changes once you're done.
|
||||
|
||||
## What technologies are used for this project?
|
||||
|
||||
This project is built with:
|
||||
|
||||
- Vite
|
||||
- TypeScript
|
||||
- React
|
||||
- shadcn-ui
|
||||
- Tailwind CSS
|
||||
|
||||
## How can I deploy this project?
|
||||
|
||||
Simply open [Lovable](https://lovable.dev/projects/7410f81b-8218-4f2d-bb32-1ba1f84eabb2) and click on Share -> Publish.
|
||||
|
||||
## Can I connect a custom domain to my Lovable project?
|
||||
|
||||
Yes, you can!
|
||||
|
||||
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
||||
|
||||
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)
|
||||
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Full-stack backup smoke: API seeds submit (Postgres + MinIO), browser downloads ZIP as admin.
|
||||
*
|
||||
* Prerequisites (same as be0/tests/test_backup_e2e.py):
|
||||
* - docker compose: postgres, minio, be0, fe0 (or host services on expected ports)
|
||||
* - DB migrated through 009; MinIO buckets exist
|
||||
* - DB migration 013 (email verification): Playwright cannot capture verify tokens; either
|
||||
* use API/integration path for full coverage or extend this spec with a token source.
|
||||
*
|
||||
* be0 must be started with AUTH_ADMIN_EMAILS containing the same address as E2E_ADMIN_EMAIL.
|
||||
*
|
||||
* Run:
|
||||
* export E2E_BACKUP=1
|
||||
* export E2E_BASE_URL=http://localhost:8081
|
||||
* export E2E_ADMIN_EMAIL=e2e-backup-admin@ump.edu.vn
|
||||
* # In docker-compose be0 environment: AUTH_ADMIN_EMAILS=e2e-backup-admin@ump.edu.vn
|
||||
* cd fe0 && npm install && npx playwright install chromium
|
||||
* npm run test:e2e -- e2e/backup-admin-download.spec.ts
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const PASSWORD = "Testpass1!";
|
||||
const MIN_PDF = Buffer.concat([
|
||||
Buffer.from("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n"),
|
||||
Buffer.alloc(120, 0x30),
|
||||
]);
|
||||
|
||||
function registerStaffFields(): Record<string, string> {
|
||||
const suffix = randomUUID().replace(/-/g, "").slice(0, 8).toUpperCase();
|
||||
return {
|
||||
employeeId: `CB-${suffix}`,
|
||||
academicTitleCode: "master",
|
||||
unitNameFreetext: "Khoa kiểm thử",
|
||||
jobTitle: "Cán bộ",
|
||||
};
|
||||
}
|
||||
|
||||
const stackEnabled = process.env.E2E_BACKUP === "1";
|
||||
const adminEmail = process.env.E2E_ADMIN_EMAIL?.trim() ?? "";
|
||||
|
||||
test.describe("Admin backup ZIP (frontend → API → DB → MinIO)", () => {
|
||||
test("admin can download backup after applicant submits PDF", async ({ page, request }) => {
|
||||
test.skip(!stackEnabled, "Set E2E_BACKUP=1");
|
||||
test.skip(!adminEmail, "Set E2E_ADMIN_EMAIL and list it in be0 AUTH_ADMIN_EMAILS");
|
||||
|
||||
const applicantEmail = `e2e-fe-app-${randomUUID().slice(0, 10)}@ump.edu.vn`;
|
||||
|
||||
const registerJson = (body: object) =>
|
||||
request.post("/api/v1/auth/register", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
data: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const loginJson = (email: string) =>
|
||||
request.post("/api/v1/auth/login", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
data: JSON.stringify({ email, password: PASSWORD }),
|
||||
});
|
||||
|
||||
let r = await registerJson({
|
||||
fullName: "E2E Applicant",
|
||||
email: applicantEmail,
|
||||
password: PASSWORD,
|
||||
passwordConfirm: PASSWORD,
|
||||
...registerStaffFields(),
|
||||
});
|
||||
expect(r.ok(), await r.text()).toBeTruthy();
|
||||
|
||||
r = await loginJson(applicantEmail);
|
||||
expect(r.ok(), await r.text()).toBeTruthy();
|
||||
const applicantToken = (await r.json()) as { accessToken: string };
|
||||
expect(applicantToken.accessToken).toBeTruthy();
|
||||
|
||||
r = await request.post("/api/applications/new", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${applicantToken.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: JSON.stringify({ name: "E2E FE backup" }),
|
||||
});
|
||||
expect(r.ok(), await r.text()).toBeTruthy();
|
||||
const created = (await r.json()) as { id?: string; application?: { draft_case_id?: string; id?: string } };
|
||||
const caseId = String(created.application?.draft_case_id ?? "").trim();
|
||||
let applicationId = String(created.id ?? created.application?.id ?? "").trim();
|
||||
expect(caseId, JSON.stringify(created)).toBeTruthy();
|
||||
expect(applicationId).toBeTruthy();
|
||||
|
||||
const meta = {
|
||||
initiativeCaseId: caseId,
|
||||
initiativeName: "E2E FE Backup",
|
||||
authorName: "Applicant",
|
||||
authorEmail: applicantEmail,
|
||||
subjectId: "s1",
|
||||
groupId: "g1",
|
||||
topicType: "Hồ sơ PDF",
|
||||
};
|
||||
r = await request.post("/api/applications/submit", {
|
||||
headers: { Authorization: `Bearer ${applicantToken.accessToken}` },
|
||||
multipart: {
|
||||
file: {
|
||||
name: "e2e.pdf",
|
||||
mimeType: "application/pdf",
|
||||
buffer: MIN_PDF,
|
||||
},
|
||||
metadata: JSON.stringify(meta),
|
||||
},
|
||||
});
|
||||
expect(r.ok(), await r.text()).toBeTruthy();
|
||||
const submitted = (await r.json()) as { id?: string };
|
||||
applicationId = String(submitted.id ?? applicationId);
|
||||
|
||||
r = await registerJson({
|
||||
fullName: "E2E Admin",
|
||||
email: adminEmail,
|
||||
password: PASSWORD,
|
||||
passwordConfirm: PASSWORD,
|
||||
...registerStaffFields(),
|
||||
});
|
||||
if (r.ok()) {
|
||||
const regBody = (await r.json()) as { user?: { roles?: string[] } };
|
||||
expect(regBody.user?.roles).toContain("admin");
|
||||
} else {
|
||||
expect([400, 409, 422]).toContain(r.status());
|
||||
}
|
||||
|
||||
r = await loginJson(adminEmail);
|
||||
expect(r.ok(), await r.text()).toBeTruthy();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.locator("#login-email").fill(adminEmail);
|
||||
await page.locator("#login-password").fill(PASSWORD);
|
||||
await page.getByRole("button", { name: "Đăng nhập" }).click();
|
||||
await page.waitForURL(/\/dashboard/u, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`/dashboard/admin/applications/review?applicationId=${encodeURIComponent(applicationId)}`);
|
||||
await expect(page.getByRole("heading", { name: /Xem hồ sơ đã nộp \(quản trị\)/u })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const backupBtn = page.getByRole("button", { name: /Tải bản sao lưu/u });
|
||||
await expect(backupBtn).toBeVisible();
|
||||
|
||||
const [download] = await Promise.all([page.waitForEvent("download"), backupBtn.click()]);
|
||||
expect(download.suggestedFilename()).toMatch(/\.zip$/i);
|
||||
const path = await download.path();
|
||||
expect(path).toBeTruthy();
|
||||
const fs = await import("node:fs/promises");
|
||||
const buf = await fs.readFile(path!);
|
||||
expect(buf.length).toBeGreaterThan(100);
|
||||
expect(buf[0]).toBe(0x50);
|
||||
expect(buf[1]).toBe(0x4b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Full-browser check (Playwright) for the same class of bug as
|
||||
* `src/components/InitiativeApplicationForm.draftTyping.integration.test.tsx`.
|
||||
*
|
||||
* Requires a **running** frontend (default E2E_BASE_URL) and an **authenticated** applicant
|
||||
* session with the « Đơn Đề nghị Công nhận » tab reachable (Báo cáo gate completed if applicable).
|
||||
*
|
||||
* Run (example):
|
||||
* cd fe0 && npm run dev
|
||||
* E2E_FORM_TYPING=1 npm run test:e2e -- e2e/initiative-application-form-typing.spec.ts
|
||||
*/
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Initiative Đơn form — keystroke regressions", () => {
|
||||
test.skip(
|
||||
process.env.E2E_FORM_TYPING !== "1",
|
||||
"Opt-in only: export E2E_FORM_TYPING=1 with a logged-in dev server.",
|
||||
);
|
||||
|
||||
test("browser typing does not surface React Maximum update depth in console", async ({ page }) => {
|
||||
const panics: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() !== "error" && msg.type() !== "warning") return;
|
||||
const t = msg.text();
|
||||
if (/Maximum update depth exceeded|Too many re-renders/u.test(t)) panics.push(t);
|
||||
});
|
||||
|
||||
await page.goto("/dashboard", { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
const appTab = page.getByRole("tab", { name: /Đơn Đề nghị Công nhận/u });
|
||||
await expect(appTab).toBeVisible({ timeout: 20_000 });
|
||||
await appTab.click();
|
||||
|
||||
const summary = page.locator("textarea.min-h-32").first();
|
||||
await expect(summary).toBeVisible({ timeout: 25_000 });
|
||||
await page.getByPlaceholder("Nhập tên sáng kiến...").focus();
|
||||
await page.keyboard.insertText("Đánh máy ");
|
||||
await summary.click();
|
||||
await page.keyboard.insertText("Mô tả từng ký tự ");
|
||||
await page.waitForTimeout(500);
|
||||
expect(panics).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@/app/App",
|
||||
message:
|
||||
"Removed duplicate app shell; use src/App.tsx (see main.tsx). Do not reintroduce a second QueryClient entrypoint.",
|
||||
},
|
||||
{
|
||||
name: "@/src/app/App",
|
||||
message:
|
||||
"Removed duplicate app shell; use src/App.tsx (see main.tsx). Do not reintroduce a second QueryClient entrypoint.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Guard: JSX is not allowed in `.ts` files (rename to `.tsx`).
|
||||
* The typescript-eslint parser natively refuses to parse JSX in `.ts` files,
|
||||
* so this `no-restricted-syntax` rule acts as an extra safety net with a
|
||||
* friendlier message when another config/plugin re-enables JSX parsing.
|
||||
*/
|
||||
{
|
||||
files: ["**/*.ts"],
|
||||
ignores: ["**/*.tsx"],
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: "JSXElement",
|
||||
message: "JSX is not allowed in .ts files. Rename the file to .tsx.",
|
||||
},
|
||||
{
|
||||
selector: "JSXFragment",
|
||||
message: "JSX is not allowed in .ts files. Rename the file to .tsx.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SKI-Quản lý Sáng kiến</title>
|
||||
<meta name="description" content="A space for exploring ideas, finding inspiration, and discovering new ways of seeing the world through lifestyle, wellness, travel, and personal growth." />
|
||||
<meta name="author" content="Perspective Blog" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Merriweather:wght@400;700&display=swap" rel="stylesheet">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@lovable_dev" />
|
||||
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,45 @@
|
||||
# Production SPA + API reverse proxy (fe0 container, port 8080).
|
||||
# TLS terminates on the host reverse proxy (Caddy/nginx); this listens HTTP inside Docker.
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://be0:4402;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
proxy_read_timeout 300;
|
||||
}
|
||||
|
||||
location /submitted-initiatives/ {
|
||||
proxy_pass http://be0:4402;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
expires 7d;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+9612
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"name": "vite_react_shadcn_ts",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"report:save-local": "node scripts/save-report-to-public.mjs",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-menubar": "^1.1.15",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.13",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@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-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"axios": "^1.13.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"docx-preview": "^0.3.7",
|
||||
"docxtemplater": "^3.68.5",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^2.5.2",
|
||||
"lucide-react": "^0.462.0",
|
||||
"microdiff": "^1.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
"ollama": "^0.6.3",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pizzip": "^3.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^15.15.0",
|
||||
"jsdom": "^27.0.1",
|
||||
"lovable-tagger": "^1.1.10",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
timeout: 60_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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/2025–15/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/2025–15/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,152 @@
|
||||
{
|
||||
"trang_bia": {
|
||||
"ten_sang_kien": "",
|
||||
"tac_gia": "",
|
||||
"don_vi": "",
|
||||
"thong_tin_lien_he": "",
|
||||
"nam": ""
|
||||
},
|
||||
"mau_01": {
|
||||
"mo_dau": "",
|
||||
"ten_sang_kien": "",
|
||||
"linh_vuc_ap_dung": "",
|
||||
"tinh_trang_da_biet": "",
|
||||
"muc_dich": "",
|
||||
"cac_buoc_thuc_hien": "",
|
||||
"dieu_kien_ap_dung": "",
|
||||
"linh_vuc_ap_dung_2": "",
|
||||
"ket_qua_thu_duoc": "",
|
||||
"danh_sach_ap_dung": [
|
||||
{ "tt": "1", "ten_to_chuc": "", "dia_chi": "", "linh_vuc": "" }
|
||||
],
|
||||
"tinh_moi": "",
|
||||
"tinh_hieu_qua": {
|
||||
"loi_ich_kinh_te": "",
|
||||
"hieu_qua_giang_day": "",
|
||||
"tang_nang_suat": "",
|
||||
"nang_cao_hieu_qua": "",
|
||||
"nang_cao_chat_luong": "",
|
||||
"giam_chi_phi": "",
|
||||
"cai_thien_moi_truong": "",
|
||||
"bao_ve_suc_khoe": "",
|
||||
"an_toan_lao_dong": "",
|
||||
"nang_cao_nhan_thuc": ""
|
||||
},
|
||||
"thong_tin_bao_mat": "",
|
||||
"ngay_ky": { "ngay": "", "thang": "", "nam": "" },
|
||||
"lanh_dao_don_vi": "",
|
||||
"tac_gia_sang_kien": ""
|
||||
},
|
||||
"mau_02": {
|
||||
"don_vi": "",
|
||||
"danh_sach_tac_gia": [
|
||||
{
|
||||
"stt": "1",
|
||||
"ho_ten": "",
|
||||
"ngay_sinh": "",
|
||||
"noi_cong_tac": "",
|
||||
"chuc_danh": "",
|
||||
"trinh_do": "",
|
||||
"ty_le": ""
|
||||
}
|
||||
],
|
||||
"ten_sang_kien": "",
|
||||
"chu_dau_tu": "",
|
||||
"linh_vuc_ap_dung": "",
|
||||
"ngay_ap_dung": "",
|
||||
"noi_dung": "",
|
||||
"phan_loai": {
|
||||
"giai_phap_ky_thuat": false,
|
||||
"sang_kien_tu_nckh": false,
|
||||
"sang_kien_tu_sach": false
|
||||
},
|
||||
"thong_tin_bao_mat": "",
|
||||
"dieu_kien_ap_dung": "",
|
||||
"danh_gia_tac_gia": "",
|
||||
"danh_gia_to_chuc": "",
|
||||
"danh_sach_tham_gia": [
|
||||
{
|
||||
"stt": "1",
|
||||
"ho_ten": "",
|
||||
"ngay_sinh": "",
|
||||
"noi_cong_tac": "",
|
||||
"chuc_danh": "",
|
||||
"trinh_do": "",
|
||||
"noi_dung_ho_tro": ""
|
||||
}
|
||||
],
|
||||
"ngay_ky": { "ngay": "", "thang": "", "nam": "" },
|
||||
"lanh_dao_don_vi": "",
|
||||
"tac_gia_sang_kien": ""
|
||||
},
|
||||
"mau_03": {
|
||||
"ngay_ky": { "ngay": "", "thang": "", "nam": "" },
|
||||
"ten_sang_kien": "",
|
||||
"tac_gia_chinh": "",
|
||||
"chuc_vu_don_vi": "",
|
||||
"ty_le_dong_gop": [
|
||||
{ "stt": "1", "ho_ten": "", "don_vi": "", "phan_tram": "", "chu_ky": "" }
|
||||
],
|
||||
"tac_gia_chinh_ky": ""
|
||||
},
|
||||
"mau_04": {
|
||||
"ten_sang_kien": "",
|
||||
"tac_gia": "",
|
||||
"chuc_vu_don_vi": "",
|
||||
"tinh_moi": { "nhan_xet": "", "diem": "" },
|
||||
"tinh_hieu_qua": { "nhan_xet": "", "diem": "" },
|
||||
"tong_cong": "",
|
||||
"ket_luan": "",
|
||||
"ngay_ky": { "ngay": "", "thang": "", "nam": "" },
|
||||
"thanh_vien_hoi_dong": ""
|
||||
},
|
||||
"ban_cam_ket": {
|
||||
"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": ""
|
||||
},
|
||||
"reference_material_honesty": {
|
||||
"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": ""
|
||||
},
|
||||
"research_domestic_honesty": {
|
||||
"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": ""
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,627 @@
|
||||
# Building the Sáng Kiến Application Form — TypeScript Component Guide
|
||||
|
||||
This guide explains how to turn the three artifacts in this folder into a working web form that:
|
||||
|
||||
1. Renders the full UMP "Sáng kiến – Cải tiến kỹ thuật" application (cover + Mẫu 01–04 + Bản Cam Kết).
|
||||
2. Captures user input into a `FormData` object whose shape matches `data_blank.json` exactly.
|
||||
3. POSTs that object to a backend endpoint that uses **`docxtpl`** (or `docx-templates`) to render `template_application_form.docx` and return a downloadable `.docx`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The Three Artifacts and Their Roles
|
||||
|
||||
| File | Role | Used by |
|
||||
|---|---|---|
|
||||
| `template_application_form.docx` | The Word document with `{{ jinja2 }}` placeholders. **Do not edit by hand**; round-trips through Word will preserve placeholders only if they live in single text runs. | Backend renderer |
|
||||
| `data_blank.json` | The canonical, flat, snake_case shape. **Every key here matches a placeholder in the docx 1:1.** This is the contract between the frontend and the renderer. | Frontend `FormData` type, backend context object |
|
||||
| `bieu_mau_sang_kien_template.json` | Vietnamese-labeled mirror of the same structure. Use it as the source of human labels when you want to avoid hard-coding Vietnamese strings inside components. | Frontend label lookups, i18n |
|
||||
|
||||
The form has six top-level sections corresponding to the six top-level keys of `data_blank.json`:
|
||||
|
||||
```
|
||||
trang_bia → Cover page
|
||||
mau_01 → Mẫu số 01: Báo cáo mô tả sáng kiến
|
||||
mau_02 → Mẫu số 02: Đơn đề nghị công nhận sáng kiến
|
||||
mau_03 → Mẫu số 03: Bản xác nhận tỷ lệ (%) đóng góp
|
||||
mau_04 → Mẫu số 04: Phiếu đánh giá sáng kiến (đánh giá của hội đồng)
|
||||
ban_cam_ket → Bản cam kết (tác giả bài báo khoa học)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. TypeScript Types (derive these from `data_blank.json`)
|
||||
|
||||
Place this file at `src/types/sangKien.ts`. The shape mirrors `data_blank.json` exactly — if you add or rename a field in the docx, update this file, the JSON, and the placeholder in the same commit.
|
||||
|
||||
```ts
|
||||
// src/types/sangKien.ts
|
||||
|
||||
export interface NgayKy {
|
||||
ngay: string; // "DD" — keep as string so leading zeros survive
|
||||
thang: string; // "MM"
|
||||
nam: string; // "YYYY"
|
||||
}
|
||||
|
||||
export interface TrangBia {
|
||||
ten_sang_kien: string;
|
||||
tac_gia: string;
|
||||
don_vi: string;
|
||||
thong_tin_lien_he: string;
|
||||
nam: string;
|
||||
}
|
||||
|
||||
export interface DanhSachApDungRow {
|
||||
tt: string;
|
||||
ten_to_chuc: string;
|
||||
dia_chi: string;
|
||||
linh_vuc: string;
|
||||
}
|
||||
|
||||
export interface TinhHieuQua {
|
||||
loi_ich_kinh_te: string;
|
||||
hieu_qua_giang_day: string;
|
||||
tang_nang_suat: string;
|
||||
nang_cao_hieu_qua: string;
|
||||
nang_cao_chat_luong: string;
|
||||
giam_chi_phi: string;
|
||||
cai_thien_moi_truong: string;
|
||||
bao_ve_suc_khoe: string;
|
||||
an_toan_lao_dong: string;
|
||||
nang_cao_nhan_thuc: string;
|
||||
}
|
||||
|
||||
export interface Mau01 {
|
||||
mo_dau: string;
|
||||
ten_sang_kien: string;
|
||||
linh_vuc_ap_dung: string;
|
||||
tinh_trang_da_biet: string;
|
||||
muc_dich: string;
|
||||
cac_buoc_thuc_hien: string;
|
||||
dieu_kien_ap_dung: string;
|
||||
linh_vuc_ap_dung_2: string; // distinct from linh_vuc_ap_dung above — see PDF §4.2
|
||||
ket_qua_thu_duoc: string;
|
||||
danh_sach_ap_dung: DanhSachApDungRow[];
|
||||
tinh_moi: string;
|
||||
tinh_hieu_qua: TinhHieuQua;
|
||||
thong_tin_bao_mat: string;
|
||||
ngay_ky: NgayKy;
|
||||
lanh_dao_don_vi: string;
|
||||
tac_gia_sang_kien: string;
|
||||
}
|
||||
|
||||
export interface TacGiaRow {
|
||||
stt: string;
|
||||
ho_ten: string;
|
||||
ngay_sinh: string;
|
||||
noi_cong_tac: string;
|
||||
chuc_danh: string;
|
||||
trinh_do: string;
|
||||
ty_le: string; // percentage as string, e.g. "60"
|
||||
}
|
||||
|
||||
export interface ThamGiaRow {
|
||||
stt: string;
|
||||
ho_ten: string;
|
||||
ngay_sinh: string;
|
||||
noi_cong_tac: string;
|
||||
chuc_danh: string;
|
||||
trinh_do: string;
|
||||
noi_dung_ho_tro: string;
|
||||
}
|
||||
|
||||
export interface PhanLoai {
|
||||
giai_phap_ky_thuat: boolean;
|
||||
sang_kien_tu_nckh: boolean;
|
||||
sang_kien_tu_sach: boolean;
|
||||
}
|
||||
|
||||
export interface Mau02 {
|
||||
don_vi: string;
|
||||
danh_sach_tac_gia: TacGiaRow[];
|
||||
ten_sang_kien: string;
|
||||
chu_dau_tu: string;
|
||||
linh_vuc_ap_dung: string;
|
||||
ngay_ap_dung: string;
|
||||
noi_dung: string;
|
||||
phan_loai: PhanLoai; // mutually exclusive in practice — radio-style group
|
||||
thong_tin_bao_mat: string;
|
||||
dieu_kien_ap_dung: string;
|
||||
danh_gia_tac_gia: string;
|
||||
danh_gia_to_chuc: string;
|
||||
danh_sach_tham_gia: ThamGiaRow[];
|
||||
ngay_ky: NgayKy;
|
||||
lanh_dao_don_vi: string;
|
||||
tac_gia_sang_kien: string;
|
||||
}
|
||||
|
||||
export interface TyLeDongGopRow {
|
||||
stt: string;
|
||||
ho_ten: string;
|
||||
don_vi: string;
|
||||
phan_tram: string;
|
||||
chu_ky: string;
|
||||
}
|
||||
|
||||
export interface Mau03 {
|
||||
ngay_ky: NgayKy;
|
||||
ten_sang_kien: string;
|
||||
tac_gia_chinh: string;
|
||||
chuc_vu_don_vi: string;
|
||||
ty_le_dong_gop: TyLeDongGopRow[]; // sum of phan_tram MUST equal 100
|
||||
tac_gia_chinh_ky: string;
|
||||
}
|
||||
|
||||
export interface DiemNhanXet {
|
||||
nhan_xet: string;
|
||||
diem: string; // numeric in a string, e.g. "35"
|
||||
}
|
||||
|
||||
export interface Mau04 {
|
||||
ten_sang_kien: string;
|
||||
tac_gia: string;
|
||||
chuc_vu_don_vi: string;
|
||||
tinh_moi: DiemNhanXet; // max 40
|
||||
tinh_hieu_qua: DiemNhanXet; // max 60
|
||||
tong_cong: string; // = tinh_moi.diem + tinh_hieu_qua.diem (compute, do not free-edit)
|
||||
ket_luan: string;
|
||||
ngay_ky: NgayKy;
|
||||
thanh_vien_hoi_dong: string;
|
||||
}
|
||||
|
||||
export interface VaiTroBaiBao {
|
||||
tac_gia_chinh: boolean; // mutually exclusive with dong_tac_gia
|
||||
dong_tac_gia: boolean;
|
||||
}
|
||||
|
||||
export interface CamKetChecks {
|
||||
quyen_so_huu_1: boolean;
|
||||
quyen_so_huu_2: boolean;
|
||||
dong_thuan: boolean;
|
||||
bai_bao_uy_tin: boolean;
|
||||
tuan_thu_phap_luat: boolean;
|
||||
}
|
||||
|
||||
export interface BanCamKet {
|
||||
ngay_ky: NgayKy;
|
||||
tac_gia_dang_ky: string;
|
||||
cccd: string;
|
||||
don_vi: string;
|
||||
ten_bai_bao: string;
|
||||
nam_xet: string; // year, e.g. "2026"
|
||||
vai_tro: VaiTroBaiBao;
|
||||
cam_ket: CamKetChecks;
|
||||
nguoi_cam_ket: string;
|
||||
}
|
||||
|
||||
export interface SangKienFormData {
|
||||
trang_bia: TrangBia;
|
||||
mau_01: Mau01;
|
||||
mau_02: Mau02;
|
||||
mau_03: Mau03;
|
||||
mau_04: Mau04;
|
||||
ban_cam_ket: BanCamKet;
|
||||
}
|
||||
```
|
||||
|
||||
The blank initial value is just the JSON file imported and cast:
|
||||
|
||||
```ts
|
||||
import blank from '../../assets/data_blank.json';
|
||||
import type { SangKienFormData } from '../types/sangKien';
|
||||
|
||||
export const emptyForm: SangKienFormData = blank as SangKienFormData;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Field-by-Field Rendering Reference
|
||||
|
||||
The table below is the **single source of truth** for which UI control to render. Sections that aren't filled by the applicant (Mẫu 04 is filled by the council; signatures are filled at print time) should still be present in the schema so the template can render placeholders.
|
||||
|
||||
### 3.1 `trang_bia` — Cover
|
||||
|
||||
| Path | Control | Notes |
|
||||
|---|---|---|
|
||||
| `ten_sang_kien` | `textarea` (2 rows) | Required, max ~300 chars |
|
||||
| `tac_gia` | `text` | Required |
|
||||
| `don_vi` | `text` | Required |
|
||||
| `thong_tin_lien_he` | `text` | "phone, email" — accept free format |
|
||||
| `nam` | `number` (year picker) | Default = current year |
|
||||
|
||||
### 3.2 `mau_01` — Báo cáo mô tả sáng kiến
|
||||
|
||||
| Path | Control | Notes |
|
||||
|---|---|---|
|
||||
| `mo_dau` | `textarea` (rich, 8+ rows) | Required |
|
||||
| `ten_sang_kien` | `text` | Auto-mirror from `trang_bia.ten_sang_kien` (allow override) |
|
||||
| `linh_vuc_ap_dung` | `text` | E.g. "quản lý giáo dục" |
|
||||
| `tinh_trang_da_biet` | `textarea` | Required |
|
||||
| `muc_dich` | `textarea` | Required |
|
||||
| `cac_buoc_thuc_hien` | `textarea` (rich) | Required |
|
||||
| `dieu_kien_ap_dung` | `textarea` | |
|
||||
| `linh_vuc_ap_dung_2` | `text` | **Different** from `linh_vuc_ap_dung` — this is the field inside §4.2; keep both even if they often duplicate |
|
||||
| `ket_qua_thu_duoc` | `textarea` | Required |
|
||||
| `danh_sach_ap_dung[]` | dynamic table | columns: TT, Tên tổ chức/cá nhân, Địa chỉ, Lĩnh vực. Allow add/remove rows |
|
||||
| `tinh_moi` | `textarea` | Required |
|
||||
| `tinh_hieu_qua.*` (10 fields) | `textarea` (3–4 rows each) | All optional individually but at least one should be filled |
|
||||
| `thong_tin_bao_mat` | `textarea` | Optional |
|
||||
| `ngay_ky.{ngay,thang,nam}` | three `number` inputs OR a single date picker that decomposes on submit | |
|
||||
| `lanh_dao_don_vi` | `text` | Name of unit leader (signature label) |
|
||||
| `tac_gia_sang_kien` | `text` | Auto-mirror from `trang_bia.tac_gia` |
|
||||
|
||||
### 3.3 `mau_02` — Đơn đề nghị công nhận sáng kiến
|
||||
|
||||
| Path | Control | Notes |
|
||||
|---|---|---|
|
||||
| `don_vi` | `text` | Auto-mirror from `trang_bia.don_vi` |
|
||||
| `danh_sach_tac_gia[]` | dynamic table | 7 columns. **Validation: sum of `ty_le` should equal 100.** |
|
||||
| `ten_sang_kien` | `text` | Auto-mirror |
|
||||
| `chu_dau_tu` | `text` | Often the unit name |
|
||||
| `linh_vuc_ap_dung` | `text` | Auto-mirror from `mau_01.linh_vuc_ap_dung` |
|
||||
| `ngay_ap_dung` | `date` | The day the innovation began being used |
|
||||
| `noi_dung` | `textarea` (rich) | Required |
|
||||
| `phan_loai.*` (3 booleans) | `radio` group rendered as 3 checkboxes | Spec says "đánh dấu ☑" — applicant typically picks ONE. Enforce single-selection in UI but keep all three booleans in the data shape because the docx renders three independent ☑/☐. |
|
||||
| `thong_tin_bao_mat` | `textarea` | Optional |
|
||||
| `dieu_kien_ap_dung` | `textarea` | |
|
||||
| `danh_gia_tac_gia` | `textarea` | Required |
|
||||
| `danh_gia_to_chuc` | `textarea` | If §danh_sach_ap_dung has rows, this should be filled |
|
||||
| `danh_sach_tham_gia[]` | dynamic table | 7 columns; people who tested the innovation |
|
||||
| `ngay_ky` | as above | |
|
||||
| `lanh_dao_don_vi`, `tac_gia_sang_kien` | `text` | |
|
||||
|
||||
### 3.4 `mau_03` — Bản xác nhận tỷ lệ đóng góp
|
||||
|
||||
| Path | Control | Notes |
|
||||
|---|---|---|
|
||||
| `ngay_ky` | as above | |
|
||||
| `ten_sang_kien`, `tac_gia_chinh`, `chuc_vu_don_vi` | `text` | Auto-mirror where possible |
|
||||
| `ty_le_dong_gop[]` | dynamic table | columns: STT, Họ và tên, Đơn vị, % đóng góp, Chữ ký. **Validation: sum of `phan_tram` MUST equal 100** (block submit otherwise). The signature column is filled by hand on the printed copy — leave a blank in the rendered docx. |
|
||||
| `tac_gia_chinh_ky` | `text` | Name of main author at signature |
|
||||
|
||||
### 3.5 `mau_04` — Phiếu đánh giá (council use)
|
||||
|
||||
This page is filled by the evaluation council, not the applicant. In the applicant-facing UI, **either hide this section behind a role check or render it read-only** so the data round-trips when the docx is regenerated.
|
||||
|
||||
| Path | Control | Notes |
|
||||
|---|---|---|
|
||||
| `ten_sang_kien`, `tac_gia`, `chuc_vu_don_vi` | `text` | Pre-filled |
|
||||
| `tinh_moi.{nhan_xet,diem}` | textarea + number (0–40) | |
|
||||
| `tinh_hieu_qua.{nhan_xet,diem}` | textarea + number (0–60) | |
|
||||
| `tong_cong` | computed | = `Number(tinh_moi.diem) + Number(tinh_hieu_qua.diem)`; render as readonly |
|
||||
| `ket_luan` | `textarea` | |
|
||||
| `ngay_ky`, `thanh_vien_hoi_dong` | as above | |
|
||||
|
||||
### 3.6 `ban_cam_ket` — Bản cam kết (article-author commitment)
|
||||
|
||||
This is the section described by the PDF you provided. It only applies when the innovation is based on a published article.
|
||||
|
||||
| Path | Control | Notes |
|
||||
|---|---|---|
|
||||
| `ngay_ky` | as above | |
|
||||
| `tac_gia_dang_ky` | `text` | Required, ~120 chars |
|
||||
| `cccd` | `text` | National ID (12 digits) or passport — accept either format, validate length 8–12 |
|
||||
| `don_vi` | `text` | Auto-mirror from `trang_bia.don_vi` |
|
||||
| `ten_bai_bao` | `textarea` (2 rows) | Article title (VN or international) |
|
||||
| `nam_xet` | `number` (year) | The year of submission. Defaults to `trang_bia.nam`. **This year also appears in the form's subtitle** ("...năm {nam_xet}..."). |
|
||||
| `vai_tro.tac_gia_chinh` / `vai_tro.dong_tac_gia` | radio-style pair of checkboxes | Mutually exclusive — selecting one clears the other. |
|
||||
| `cam_ket.quyen_so_huu_1` | checkbox | "Tôi là chủ sở hữu hợp pháp..." |
|
||||
| `cam_ket.quyen_so_huu_2` | checkbox | "Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH..." — at least ONE of `quyen_so_huu_1` / `quyen_so_huu_2` must be checked |
|
||||
| `cam_ket.dong_thuan` | checkbox (required) | "Tất cả đồng tác giả đã biết, đồng ý..." |
|
||||
| `cam_ket.bai_bao_uy_tin` | checkbox (required) | "Cam kết bài báo không thuộc 'Tạp chí săn mồi'" |
|
||||
| `cam_ket.tuan_thu_phap_luat` | checkbox (required) | "Tuân thủ pháp luật sở hữu trí tuệ" |
|
||||
| `nguoi_cam_ket` | `text` | Same person as `tac_gia_dang_ky`, used as signature label |
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended File Layout
|
||||
|
||||
```
|
||||
src/
|
||||
types/
|
||||
sangKien.ts # types from §2
|
||||
assets/
|
||||
data_blank.json # imported as initial state
|
||||
bieu_mau_sang_kien_template.json # imported for VN labels (optional)
|
||||
hooks/
|
||||
useSangKienForm.ts # wraps react-hook-form, handles auto-mirror + validation
|
||||
components/
|
||||
sang-kien/
|
||||
SangKienForm.tsx # top-level form, tabs/accordions for the 6 sections
|
||||
sections/
|
||||
TrangBiaSection.tsx
|
||||
Mau01Section.tsx
|
||||
Mau02Section.tsx
|
||||
Mau03Section.tsx
|
||||
Mau04Section.tsx # gated behind role
|
||||
BanCamKetSection.tsx
|
||||
controls/
|
||||
DateTriple.tsx # renders ngay/thang/nam as one row
|
||||
DynamicTable.tsx # generic add/remove rows for arrays
|
||||
ExclusiveCheckGroup.tsx # for vai_tro and phan_loai
|
||||
TextArea.tsx
|
||||
api/
|
||||
renderDocx.ts # POST FormData → returns Blob (docx)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Recommended Stack
|
||||
|
||||
- **React 18+** with **TypeScript**
|
||||
- **react-hook-form** for state, with **zod** resolvers for validation
|
||||
- **shadcn/ui** or your existing component library for inputs
|
||||
- **Backend**: any service that runs `python-docx-template` — a 30-line FastAPI/Flask endpoint is enough
|
||||
|
||||
A minimal zod schema for one section (sketch):
|
||||
|
||||
```ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ngayKySchema = z.object({
|
||||
ngay: z.string().regex(/^\d{1,2}$/),
|
||||
thang: z.string().regex(/^\d{1,2}$/),
|
||||
nam: z.string().regex(/^\d{4}$/),
|
||||
});
|
||||
|
||||
export const banCamKetSchema = z.object({
|
||||
ngay_ky: ngayKySchema,
|
||||
tac_gia_dang_ky: z.string().min(1, 'Bắt buộc'),
|
||||
cccd: z.string().min(8).max(12),
|
||||
don_vi: z.string().min(1),
|
||||
ten_bai_bao: z.string().min(1),
|
||||
nam_xet: z.string().regex(/^\d{4}$/),
|
||||
vai_tro: z
|
||||
.object({
|
||||
tac_gia_chinh: z.boolean(),
|
||||
dong_tac_gia: z.boolean(),
|
||||
})
|
||||
.refine(
|
||||
v => Number(v.tac_gia_chinh) + Number(v.dong_tac_gia) === 1,
|
||||
{ message: 'Phải chọn đúng một vai trò' }
|
||||
),
|
||||
cam_ket: z
|
||||
.object({
|
||||
quyen_so_huu_1: z.boolean(),
|
||||
quyen_so_huu_2: z.boolean(),
|
||||
dong_thuan: z.boolean(),
|
||||
bai_bao_uy_tin: z.boolean(),
|
||||
tuan_thu_phap_luat: z.boolean(),
|
||||
})
|
||||
.refine(v => v.quyen_so_huu_1 || v.quyen_so_huu_2, {
|
||||
path: ['quyen_so_huu_1'],
|
||||
message: 'Phải chọn ít nhất một mục về quyền sở hữu',
|
||||
})
|
||||
.refine(v => v.dong_thuan && v.bai_bao_uy_tin && v.tuan_thu_phap_luat, {
|
||||
message: 'Phải xác nhận tất cả các cam kết bắt buộc',
|
||||
}),
|
||||
nguoi_cam_ket: z.string().min(1),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Auto-Mirroring Fields
|
||||
|
||||
Several values logically duplicate. Wire these one-way bindings inside `useSangKienForm`:
|
||||
|
||||
```
|
||||
trang_bia.ten_sang_kien → mau_01.ten_sang_kien
|
||||
→ mau_02.ten_sang_kien
|
||||
→ mau_03.ten_sang_kien
|
||||
→ mau_04.ten_sang_kien
|
||||
|
||||
trang_bia.tac_gia → mau_01.tac_gia_sang_kien
|
||||
→ mau_02.tac_gia_sang_kien
|
||||
→ mau_04.tac_gia
|
||||
→ ban_cam_ket.tac_gia_dang_ky
|
||||
→ ban_cam_ket.nguoi_cam_ket
|
||||
→ mau_03.tac_gia_chinh
|
||||
→ mau_03.tac_gia_chinh_ky
|
||||
|
||||
trang_bia.don_vi → mau_02.don_vi
|
||||
→ ban_cam_ket.don_vi
|
||||
|
||||
trang_bia.nam → ban_cam_ket.nam_xet (default only)
|
||||
```
|
||||
|
||||
Use `useWatch` to populate these on first focus of the destination field; allow the user to override (some authors sign Mẫu 03 with a fuller title than they put on the cover).
|
||||
|
||||
---
|
||||
|
||||
## 7. Submitting and Rendering
|
||||
|
||||
The frontend never builds the docx — it only POSTs the form data. Two options:
|
||||
|
||||
### 7a. Python backend (recommended — matches the template author's environment)
|
||||
|
||||
```python
|
||||
# server/render.py
|
||||
from io import BytesIO
|
||||
from docxtpl import DocxTemplate
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
app = FastAPI()
|
||||
TEMPLATE_PATH = "template_application_form.docx"
|
||||
|
||||
@app.post("/render")
|
||||
def render(payload: dict): # accept full FormData object
|
||||
doc = DocxTemplate(TEMPLATE_PATH)
|
||||
doc.render(payload)
|
||||
buf = BytesIO()
|
||||
doc.save(buf)
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={"Content-Disposition": 'attachment; filename="sang_kien.docx"'},
|
||||
)
|
||||
```
|
||||
|
||||
### 7b. Node backend
|
||||
|
||||
Use `docxtemplater` with the same template — it understands `{{ }}` and `{% %}` tags via the `nunjucks-docxtemplater-module` or manual angular-parser equivalent. This is more setup; pick 7a if you have the choice.
|
||||
|
||||
### Frontend call
|
||||
|
||||
```ts
|
||||
// src/api/renderDocx.ts
|
||||
import type { SangKienFormData } from '../types/sangKien';
|
||||
|
||||
export async function renderDocx(data: SangKienFormData): Promise<Blob> {
|
||||
const res = await fetch('/api/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Render failed: ${res.status}`);
|
||||
return await res.blob();
|
||||
}
|
||||
|
||||
// usage inside component:
|
||||
const onSubmit = async (data: SangKienFormData) => {
|
||||
const blob = await renderDocx(data);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement('a'), {
|
||||
href: url, download: 'sang_kien.docx',
|
||||
});
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Validation Rules to Enforce Before Submit
|
||||
|
||||
1. **`trang_bia`**: all five fields non-empty.
|
||||
2. **`mau_01.danh_sach_ap_dung`**: every row has all four columns filled OR remove the row.
|
||||
3. **`mau_02.danh_sach_tac_gia`**: same; **plus** sum of `ty_le` (parsed as int) === 100.
|
||||
4. **`mau_02.phan_loai`**: at least one of the three booleans is `true` (typical case: exactly one).
|
||||
5. **`mau_03.ty_le_dong_gop`**: sum of `phan_tram` === 100.
|
||||
6. **`mau_04.tinh_moi.diem`** ≤ 40, **`mau_04.tinh_hieu_qua.diem`** ≤ 60, `tong_cong = sum`.
|
||||
7. **`ban_cam_ket`**:
|
||||
- exactly one of `vai_tro.tac_gia_chinh` / `vai_tro.dong_tac_gia` is true;
|
||||
- at least one of `cam_ket.quyen_so_huu_1` / `quyen_so_huu_2` is true;
|
||||
- `cam_ket.dong_thuan` AND `cam_ket.bai_bao_uy_tin` AND `cam_ket.tuan_thu_phap_luat` are all true.
|
||||
8. **`ngay_ky.nam`** is a 4-digit year ≥ current year.
|
||||
|
||||
---
|
||||
|
||||
## 9. Common Pitfalls
|
||||
|
||||
- **Don't change keys in `data_blank.json` without updating the docx.** Open the docx, search for `{{ key }}`, and replace there too. The docx uses Jinja2-style `{{ ... }}` tags inside Word text runs; if Word splits the placeholder across runs (which it can do silently), `docxtpl` will fail. Use the included unpack/pack scripts when editing.
|
||||
- **Booleans must stay booleans, not "true"/"false" strings.** The `{% if %}` checks treat strings as truthy.
|
||||
- **Empty arrays render as one empty row** because the table loops `{% tr for item in ... %}` over the array. If you want to suppress empty tables, filter out fully-empty rows on the client before submit.
|
||||
- **Vietnamese diacritics**: send `Content-Type: application/json; charset=utf-8`. Most fetch defaults are correct, but check the backend echoes `nguyễn` not `nguyá»…n`.
|
||||
- **Dates as `string`**: keep them as strings throughout. JavaScript `Date` objects round-trip lossy through JSON and break leading zeros.
|
||||
|
||||
---
|
||||
|
||||
## 10. Quick Component Sketch (for one section)
|
||||
|
||||
A minimal `BanCamKetSection.tsx` to anchor the rest of the implementation:
|
||||
|
||||
```tsx
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import type { SangKienFormData } from '../../../types/sangKien';
|
||||
|
||||
export function BanCamKetSection() {
|
||||
const { register, watch, setValue } = useFormContext<SangKienFormData>();
|
||||
const vaiTro = watch('ban_cam_ket.vai_tro');
|
||||
|
||||
// mutually-exclusive helper
|
||||
const pickRole = (role: 'tac_gia_chinh' | 'dong_tac_gia') => {
|
||||
setValue('ban_cam_ket.vai_tro.tac_gia_chinh', role === 'tac_gia_chinh');
|
||||
setValue('ban_cam_ket.vai_tro.dong_tac_gia', role === 'dong_tac_gia');
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Bản Cam Kết</h2>
|
||||
|
||||
<label>Tác giả đăng ký sáng kiến
|
||||
<input {...register('ban_cam_ket.tac_gia_dang_ky')} />
|
||||
</label>
|
||||
|
||||
<label>CCCD/Hộ chiếu số
|
||||
<input {...register('ban_cam_ket.cccd')} />
|
||||
</label>
|
||||
|
||||
<label>Đơn vị
|
||||
<input {...register('ban_cam_ket.don_vi')} />
|
||||
</label>
|
||||
|
||||
<label>Tên bài báo
|
||||
<textarea rows={2} {...register('ban_cam_ket.ten_bai_bao')} />
|
||||
</label>
|
||||
|
||||
<label>Năm xét
|
||||
<input type="number" {...register('ban_cam_ket.nam_xet')} />
|
||||
</label>
|
||||
|
||||
<fieldset>
|
||||
<legend>Vai trò đối với bài báo</legend>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
checked={vaiTro?.tac_gia_chinh}
|
||||
onChange={() => pickRole('tac_gia_chinh')} />
|
||||
Tác giả chính
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox"
|
||||
checked={vaiTro?.dong_tac_gia}
|
||||
onChange={() => pickRole('dong_tac_gia')} />
|
||||
Đồng tác giả
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Cam kết nội dung</legend>
|
||||
<label><input type="checkbox" {...register('ban_cam_ket.cam_ket.quyen_so_huu_1')} />
|
||||
Tôi là chủ sở hữu hợp pháp của bài báo…
|
||||
</label>
|
||||
<label><input type="checkbox" {...register('ban_cam_ket.cam_ket.quyen_so_huu_2')} />
|
||||
Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH…
|
||||
</label>
|
||||
<label><input type="checkbox" {...register('ban_cam_ket.cam_ket.dong_thuan')} />
|
||||
Tất cả đồng tác giả đã biết, đồng ý…
|
||||
</label>
|
||||
<label><input type="checkbox" {...register('ban_cam_ket.cam_ket.bai_bao_uy_tin')} />
|
||||
Cam kết bài báo không thuộc "Tạp chí săn mồi"
|
||||
</label>
|
||||
<label><input type="checkbox" {...register('ban_cam_ket.cam_ket.tuan_thu_phap_luat')} />
|
||||
Tuân thủ pháp luật sở hữu trí tuệ
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<label>Người cam kết (ký tên)
|
||||
<input {...register('ban_cam_ket.nguoi_cam_ket')} />
|
||||
</label>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Replicate the same pattern for the other five sections, using `useFieldArray` from react-hook-form for the dynamic tables (`danh_sach_ap_dung`, `danh_sach_tac_gia`, `danh_sach_tham_gia`, `ty_le_dong_gop`).
|
||||
|
||||
---
|
||||
|
||||
## 11. End-to-End Sanity Check
|
||||
|
||||
Before shipping, run this contract test:
|
||||
|
||||
```ts
|
||||
import blank from './assets/data_blank.json';
|
||||
import { sangKienSchema } from './types/sangKien.zod';
|
||||
|
||||
// 1) The blank file must satisfy the zod schema *with optional fields permitted*.
|
||||
sangKienSchema.partial().parse(blank);
|
||||
|
||||
// 2) A round-trip via the backend must return a non-empty Blob.
|
||||
const out = await renderDocx(blank as any);
|
||||
console.assert(out.size > 5000, 'docx render produced suspiciously small output');
|
||||
```
|
||||
|
||||
If both assertions pass, the frontend, the schema, and the docx renderer are in agreement — and any bug from there onward is a UI bug, not a contract drift.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 406 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="SKI">
|
||||
<rect width="64" height="64" rx="10" fill="#1e3a8a"/>
|
||||
<text x="32" y="40" text-anchor="middle" fill="#ffffff" font-size="18" font-family="system-ui,sans-serif" font-weight="600">SKI</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { input: "", caseId: "" };
|
||||
for (let i = 2; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (token === "--input") args.input = argv[++i] ?? "";
|
||||
else if (token === "--case-id") args.caseId = argv[++i] ?? "";
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function usageAndExit() {
|
||||
console.error("Usage: node scripts/save-report-to-public.mjs --input <json-file> [--case-id CASE-123]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function normalizeCaseId(raw) {
|
||||
const base = raw && raw.trim() ? raw : `CASE-${Date.now()}`;
|
||||
return base.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function toExcelRow(data, caseId) {
|
||||
return {
|
||||
caseId,
|
||||
savedAt: new Date().toISOString(),
|
||||
submissionDate: data.submissionDate ?? "",
|
||||
authorName: data.authorName ?? "",
|
||||
initiativeName: data.initiativeName ?? "",
|
||||
representativeAuthor: data.representativeAuthor ?? "",
|
||||
representativePhone: data.representativePhone ?? "",
|
||||
representativeEmail: data.representativeEmail ?? "",
|
||||
applicationField: data.applicationField ?? "",
|
||||
introduction: data.introduction ?? "",
|
||||
currentStatus: data.currentStatus ?? "",
|
||||
purpose: data.purpose ?? "",
|
||||
solutionContent: data.solutionContent ?? "",
|
||||
implementationSteps: data.implementationSteps ?? "",
|
||||
firstAppliedUnit: data.firstAppliedUnit ?? "",
|
||||
conditions: data.conditions ?? "",
|
||||
achievedResult: data.achievedResult ?? "",
|
||||
novelty: data.novelty ?? "",
|
||||
effectivenessEconomic: data.effectiveness?.economic ?? "",
|
||||
effectivenessSocial: data.effectiveness?.social ?? "",
|
||||
effectivenessTeaching: data.effectiveness?.teaching ?? "",
|
||||
effectivenessProductivity: data.effectiveness?.productivity ?? "",
|
||||
effectivenessQuality: data.effectiveness?.quality ?? "",
|
||||
effectivenessEnvironment: data.effectiveness?.environment ?? "",
|
||||
effectivenessSafety: data.effectiveness?.safety ?? "",
|
||||
confidentialInfo: data.confidentialInfo ?? "",
|
||||
trialUnits: Array.isArray(data.trialUnits)
|
||||
? data.trialUnits.map((u, i) => `${i + 1}. ${u.name ?? ""} | ${u.address ?? ""} | ${u.field ?? ""}`).join("\n")
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
function updateIndexFile(indexPath, entry) {
|
||||
let rows = [];
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
rows = JSON.parse(fs.readFileSync(indexPath, "utf-8"));
|
||||
if (!Array.isArray(rows)) rows = [];
|
||||
} catch {
|
||||
rows = [];
|
||||
}
|
||||
}
|
||||
rows = [entry, ...rows.filter((r) => r.fileName !== entry.fileName)];
|
||||
fs.writeFileSync(indexPath, JSON.stringify(rows, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.input) usageAndExit();
|
||||
|
||||
const inputPath = path.resolve(process.cwd(), args.input);
|
||||
if (!fs.existsSync(inputPath)) {
|
||||
console.error(`Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(inputPath, "utf-8"));
|
||||
const caseId = normalizeCaseId(args.caseId);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const publicDir = path.resolve(projectRoot, "public");
|
||||
ensureDir(publicDir);
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const worksheet = XLSX.utils.json_to_sheet([toExcelRow(data, caseId)]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "InitiativeReport");
|
||||
|
||||
const fileName = `${caseId}.xlsx`;
|
||||
const outputPath = path.join(publicDir, fileName);
|
||||
XLSX.writeFile(workbook, outputPath);
|
||||
|
||||
const indexPath = path.join(publicDir, "application-report-index.json");
|
||||
const stats = fs.statSync(outputPath);
|
||||
updateIndexFile(indexPath, {
|
||||
caseId,
|
||||
fileName,
|
||||
publicUrl: `/${fileName}`,
|
||||
sizeBytes: stats.size,
|
||||
updatedAt: new Date(stats.mtimeMs).toISOString(),
|
||||
});
|
||||
|
||||
console.log(`Saved: ${outputPath}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,51 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { isAuthQueryError } from "@/shared/api/client";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
import Index from "./pages/Index";
|
||||
import Article from "./pages/Article";
|
||||
import Travel from "./pages/Travel";
|
||||
|
||||
import Growth from "./pages/Growth";
|
||||
import About from "./pages/About";
|
||||
import Authors from "./pages/Authors";
|
||||
import Contact from "./pages/Contact";
|
||||
import StyleGuide from "./pages/StyleGuide";
|
||||
import Privacy from "./pages/Privacy";
|
||||
import Terms from "./pages/Terms";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Login from "./pages/Login";
|
||||
import ApplicantRegistrationPage from "./applicant/auth/RegistrationPage";
|
||||
import AdminRegistrationPage from "./admin/auth/RegistrationPage";
|
||||
import ForgotPasswordPage from "./pages/ForgotPasswordPage";
|
||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||
import VerifyEmailPage from "./pages/VerifyEmailPage";
|
||||
import Unauthorized from "./pages/Unauthorized";
|
||||
import { DashboardLayout } from "./layouts/DashboardLayout";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import ApplicationReviewPage from "./pages/ApplicationReviewPage";
|
||||
import AdminApplicationReviewPage from "./pages/AdminApplicationReviewPage";
|
||||
import CouncilApplicationReviewPage from "./pages/CouncilApplicationReviewPage";
|
||||
import AdminPanel from "./pages/AdminPanel";
|
||||
import AdminProjectsPage from "./pages/AdminProjectsPage";
|
||||
import { AuditLogManagerPage } from "./admin/audit/AuditLogManagerPage";
|
||||
import NotificationsPage from "./pages/NotificationsPage";
|
||||
import ConsideredInitiativesList from "./components/admin/result/ConsideredInitiativesList";
|
||||
import RenewFormPanel from "./pages/RenewFormPanel";
|
||||
import ApplicantProfilePage from "./pages/ApplicantProfilePage";
|
||||
import AdminUserProfilesPage from "./pages/AdminUserProfilesPage";
|
||||
import { ApplicationEvidenceLayout } from "./components/evidence/ApplicationEvidenceLayout";
|
||||
import { ApplicationEvidenceManagePage } from "./components/evidence/ApplicationEvidenceManagePage";
|
||||
import { ApplicationEvidencePreviewPage } from "./components/evidence/ApplicationEvidencePreviewPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
staleTime: 30_000,
|
||||
gcTime: 5 * 60_000,
|
||||
retry: (failureCount, err) => {
|
||||
if (isAuthQueryError(err)) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
|
||||
},
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const App = () => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<Index />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<ApplicantRegistrationPage />} />
|
||||
<Route path="/admin/register" element={<AdminRegistrationPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
<Route path="/article/:id" element={<Article />} />
|
||||
<Route path="/travel" element={<Travel />} />
|
||||
<Route path="/growth" element={<Growth />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/authors" element={<Authors />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
<Route path="/style-guide" element={<StyleGuide />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/terms" element={<Terms />} />
|
||||
|
||||
{/* Protected Dashboard routes */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission="dashboard.access">
|
||||
<DashboardLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="analytics" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="result" element={<ConsideredInitiativesList />} />
|
||||
<Route path="documents/review" element={<ApplicationReviewPage />} />
|
||||
<Route path="documents/:caseId/evidence" element={<ApplicationEvidenceLayout />}>
|
||||
<Route index element={<ApplicationEvidenceManagePage />} />
|
||||
<Route path=":slot" element={<ApplicationEvidencePreviewPage />} />
|
||||
</Route>
|
||||
<Route path="documents" element={<ApplicationReviewPage />} />
|
||||
<Route path="admin/applications/review" element={<AdminApplicationReviewPage />} />
|
||||
<Route path="council/applications/review" element={<CouncilApplicationReviewPage />} />
|
||||
<Route path="admin/audit" element={<AuditLogManagerPage />} />
|
||||
<Route path="users" element={<AdminUserProfilesPage />} />
|
||||
<Route path="projects" element={<AdminProjectsPage />} />
|
||||
<Route path="admin" element={<AdminPanel />} />
|
||||
<Route path="renew-form" element={<RenewFormPanel />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/applicant"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission="dashboard.access">
|
||||
<DashboardLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="profile" element={<ApplicantProfilePage />} />
|
||||
</Route>
|
||||
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { AuditListItem } from '@/audit/types';
|
||||
import { fetchAuditEventDetail } from '@/audit/adminAuditApi';
|
||||
import { formatAuditLocal } from '@/audit/formatAuditTime';
|
||||
import { describeJsonMicrodiff } from '@/audit/jsonMicrodiffLines';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { auditActionLabel } from '@/applicant/audit/actionLabels';
|
||||
|
||||
interface AuditEventDetailSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
summary: AuditListItem | null;
|
||||
}
|
||||
|
||||
export function AuditEventDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
summary,
|
||||
}: AuditEventDetailSheetProps) {
|
||||
const q = useQuery({
|
||||
queryKey: ['admin-audit-detail', summary?.id],
|
||||
queryFn: () => fetchAuditEventDetail(summary!.id),
|
||||
enabled: open && !!summary?.id,
|
||||
});
|
||||
|
||||
const diffLines =
|
||||
q.data?.before !== undefined || q.data?.after !== undefined
|
||||
? describeJsonMicrodiff(q.data?.before ?? null, q.data?.after ?? null)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-4 sm:max-w-2xl md:max-w-4xl overflow-hidden">
|
||||
<SheetHeader className="pr-12">
|
||||
<SheetTitle>Chi tiết sự kiện #{summary?.id ?? '…'}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{summary
|
||||
? `${formatAuditLocal(summary.occurred_at)} · ${auditActionLabel(summary.action)} · ${summary.entity_type}${summary.entity_id ? ` (${summary.entity_id})` : ''}`
|
||||
: 'Chọn một dòng trong bảng'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{q.isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Đang tải payload…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{q.data && (
|
||||
<ScrollArea className="flex-1 -mx-4 px-4">
|
||||
<div className="space-y-4 pb-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs mb-1">Actor</div>
|
||||
<div className="font-medium">{q.data.actor_email}</div>
|
||||
<div className="text-muted-foreground text-xs">{q.data.actor_role}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs mb-1">Request ID</div>
|
||||
<div className="font-mono text-xs break-all">{q.data.request_id ?? '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs mb-1">Metadata</div>
|
||||
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(q.data.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Chênh lệch (client-side microdiff)</div>
|
||||
{diffLines.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Không có before/after hoặc không đổi.</p>
|
||||
) : (
|
||||
<ul className="text-xs font-mono space-y-1 list-disc pl-4 max-h-40 overflow-y-auto">
|
||||
{diffLines.map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">before</div>
|
||||
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-72">
|
||||
{q.data.before != null ? JSON.stringify(q.data.before, null, 2) : '—'}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">after</div>
|
||||
<pre className="text-xs bg-muted rounded-md p-3 overflow-x-auto max-h-72">
|
||||
{q.data.after != null ? JSON.stringify(q.data.after, null, 2) : '—'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export interface AuditFilterFormState {
|
||||
fromLocal: string;
|
||||
toLocal: string;
|
||||
actorEmail: string;
|
||||
actorUserId: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface AuditLogFiltersProps {
|
||||
value: AuditFilterFormState;
|
||||
onChange: (patch: Partial<AuditFilterFormState>) => void;
|
||||
onApply: () => void;
|
||||
onPreset24h: () => void;
|
||||
onPreset7d: () => void;
|
||||
onPreset30d: () => void;
|
||||
}
|
||||
|
||||
export function AuditLogFilters({
|
||||
value,
|
||||
onChange,
|
||||
onApply,
|
||||
onPreset24h,
|
||||
onPreset7d,
|
||||
onPreset30d,
|
||||
}: AuditLogFiltersProps) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-muted-foreground mr-2 self-center">Khoảng thời gian:</span>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onPreset24h}>
|
||||
24 giờ
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onPreset7d}>
|
||||
7 ngày
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onPreset30d}>
|
||||
30 ngày
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-from">Từ (local)</Label>
|
||||
<Input
|
||||
id="audit-from"
|
||||
type="datetime-local"
|
||||
value={value.fromLocal}
|
||||
onChange={(e) => onChange({ fromLocal: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-to">Đến (local)</Label>
|
||||
<Input
|
||||
id="audit-to"
|
||||
type="datetime-local"
|
||||
value={value.toLocal}
|
||||
onChange={(e) => onChange({ toLocal: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-email">Actor email</Label>
|
||||
<Input
|
||||
id="audit-email"
|
||||
placeholder="name@ump.edu.vn"
|
||||
autoComplete="off"
|
||||
value={value.actorEmail}
|
||||
onChange={(e) => onChange({ actorEmail: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-user-id">Actor user UUID</Label>
|
||||
<Input
|
||||
id="audit-user-id"
|
||||
placeholder="optional"
|
||||
autoComplete="off"
|
||||
value={value.actorUserId}
|
||||
onChange={(e) => onChange({ actorUserId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-etype">Loại entity</Label>
|
||||
<Input
|
||||
id="audit-etype"
|
||||
placeholder="application_evidence …"
|
||||
value={value.entityType}
|
||||
onChange={(e) => onChange({ entityType: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-eid">ID entity</Label>
|
||||
<Input
|
||||
id="audit-eid"
|
||||
placeholder="case-id:role hoặc user id …"
|
||||
value={value.entityId}
|
||||
onChange={(e) => onChange({ entityId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5 md:col-span-2">
|
||||
<Label htmlFor="audit-action">Hành động (CSV)</Label>
|
||||
<Input
|
||||
id="audit-action"
|
||||
placeholder="create,update,login,…"
|
||||
value={value.action}
|
||||
onChange={(e) => onChange({ action: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onApply}>
|
||||
Áp dụng bộ lọc
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Audit Log Manager — admin-only forensic view (`/dashboard/admin/audit`).
|
||||
*/
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { fetchAuditEvents } from '@/audit/adminAuditApi';
|
||||
import type { AuditListItem, AuditListQuery } from '@/audit/types';
|
||||
import { localDateTimeInputToUtcRfc3339 } from '@/audit/formatAuditTime';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
import {
|
||||
AuditLogFilters,
|
||||
type AuditFilterFormState,
|
||||
} from '@/admin/audit/AuditLogFilters';
|
||||
import { AuditLogTable } from '@/admin/audit/AuditLogTable';
|
||||
import { AuditEventDetailSheet } from '@/admin/audit/AuditEventDetailSheet';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function localInputSlice(d: Date): string {
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function emptyForm(now: Date, from: Date): AuditFilterFormState {
|
||||
return {
|
||||
fromLocal: localInputSlice(from),
|
||||
toLocal: localInputSlice(now),
|
||||
actorEmail: '',
|
||||
actorUserId: '',
|
||||
entityType: '',
|
||||
entityId: '',
|
||||
action: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function AuditLogManagerPage() {
|
||||
const { hasPermission, loading } = useAuth();
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [draft, setDraft] = useState<AuditFilterFormState>(() =>
|
||||
emptyForm(new Date(), new Date(Date.now() - 7 * 86_400_000)),
|
||||
);
|
||||
|
||||
/** Last-applied snapshot for query key (Áp dụng bộ lọc commits draft → applied). */
|
||||
const [applied, setApplied] = useState<AuditFilterFormState>(() =>
|
||||
emptyForm(new Date(), new Date(Date.now() - 7 * 86_400_000)),
|
||||
);
|
||||
|
||||
const appliedQueryKey = useMemo(() => ({ ...applied, page }), [applied, page]);
|
||||
|
||||
const listQueryBody = useCallback((): AuditListQuery => {
|
||||
const q: AuditListQuery = {
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
sort: 'occurred_at:desc',
|
||||
};
|
||||
const fromIso = localDateTimeInputToUtcRfc3339(applied.fromLocal);
|
||||
const toIso = localDateTimeInputToUtcRfc3339(applied.toLocal);
|
||||
if (fromIso) q.from = fromIso;
|
||||
if (toIso) q.to = toIso;
|
||||
if (applied.actorEmail.trim()) q.actor_email = applied.actorEmail.trim().toLowerCase();
|
||||
if (applied.actorUserId.trim()) q.actor_user_id = applied.actorUserId.trim();
|
||||
if (applied.entityType.trim()) q.entity_type = applied.entityType.trim();
|
||||
if (applied.entityId.trim()) q.entity_id = applied.entityId.trim();
|
||||
if (applied.action.trim()) q.action = applied.action.trim();
|
||||
return q;
|
||||
}, [applied, page]);
|
||||
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ['admin-audit-events', appliedQueryKey],
|
||||
queryFn: () => fetchAuditEvents(listQueryBody()),
|
||||
});
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<AuditListItem | null>(null);
|
||||
|
||||
const presetRange = useCallback((ms: number) => {
|
||||
const now = new Date();
|
||||
const from = new Date(now.getTime() - ms);
|
||||
setDraft(emptyForm(now, from));
|
||||
setApplied(emptyForm(now, from));
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const onApplyFilters = () => {
|
||||
setApplied(draft);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
if (!loading && !hasPermission('admin.access')) {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
|
||||
const items = auditQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6 animate-fade-in">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Nhật ký thao tác (Audit)</h1>
|
||||
<p className="text-muted-foreground text-sm max-w-3xl">
|
||||
Theo dõi CRUD và đăng nhập của người nộp đơn và quản trị trên các thực thể đã được gắn
|
||||
instrumentation (PostgreSQL{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">audit_events</code>, MinIO thông qua{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">application_evidence</code> payload).
|
||||
Chênh lệch JSON được tính trên trình duyệt.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<AuditLogFilters
|
||||
value={draft}
|
||||
onChange={(patch) => setDraft((prev) => ({ ...prev, ...patch }))}
|
||||
onApply={onApplyFilters}
|
||||
onPreset24h={() => presetRange(86_400_000)}
|
||||
onPreset7d={() => presetRange(7 * 86_400_000)}
|
||||
onPreset30d={() => presetRange(30 * 86_400_000)}
|
||||
/>
|
||||
|
||||
<AuditLogTable
|
||||
items={items}
|
||||
onSelect={(row) => {
|
||||
setSelected(row);
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
page={page}
|
||||
total={auditQuery.data?.total ?? 0}
|
||||
pageSize={PAGE_SIZE}
|
||||
loading={auditQuery.isLoading}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => p + 1)}
|
||||
/>
|
||||
|
||||
{auditQuery.error && (
|
||||
<p className="text-sm text-destructive">
|
||||
Không tải được audit: {String((auditQuery.error as Error)?.message ?? auditQuery.error)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<AuditEventDetailSheet open={sheetOpen} onOpenChange={setSheetOpen} summary={selected} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { AuditListItem } from '@/audit/types';
|
||||
import { formatAuditLocal } from '@/audit/formatAuditTime';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { auditActionLabel } from '@/applicant/audit/actionLabels';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface AuditLogTableProps {
|
||||
items: AuditListItem[];
|
||||
onSelect: (row: AuditListItem) => void;
|
||||
page: number;
|
||||
total: number;
|
||||
pageSize: number;
|
||||
loading: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function AuditLogTable({
|
||||
items,
|
||||
onSelect,
|
||||
page,
|
||||
total,
|
||||
pageSize,
|
||||
loading,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: AuditLogTableProps) {
|
||||
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const end = Math.min(page * pageSize, total);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[168px]">Thời gian</TableHead>
|
||||
<TableHead>Tài khoản</TableHead>
|
||||
<TableHead className="w-[100px]">Hành động</TableHead>
|
||||
<TableHead>Đơn sáng kiến</TableHead>
|
||||
<TableHead className="hidden lg:table-cell w-[240px]">Tóm tắt meta</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
Đang tải…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading &&
|
||||
items.map((row) => {
|
||||
let metaBrief = '';
|
||||
try {
|
||||
const m = row.metadata ?? {};
|
||||
if (typeof m.path === 'string') metaBrief += m.path as string;
|
||||
if (typeof m.caseId === 'string')
|
||||
metaBrief += (metaBrief ? ' · ' : '') + (m.caseId as string);
|
||||
if (typeof m.source === 'string')
|
||||
metaBrief += (metaBrief ? ' · ' : '') + (m.source as string);
|
||||
if (!metaBrief)
|
||||
metaBrief = Object.keys(row.metadata || {}).length
|
||||
? JSON.stringify(row.metadata)
|
||||
: '';
|
||||
} catch {
|
||||
metaBrief = '';
|
||||
}
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="cursor-pointer hover:bg-muted/60"
|
||||
onClick={() => onSelect(row)}
|
||||
>
|
||||
<TableCell className="align-top whitespace-nowrap text-xs">
|
||||
{formatAuditLocal(row.occurred_at)}
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-sm">
|
||||
<div className="font-medium truncate max-w-[200px]" title={row.actor_email}>
|
||||
{row.actor_email}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{row.actor_role}</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-sm">{auditActionLabel(row.action)}</TableCell>
|
||||
<TableCell className="align-top text-sm">
|
||||
<div className="font-mono text-xs">{row.entity_type}</div>
|
||||
{row.entity_id && (
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[320px]" title={row.entity_id}>
|
||||
{row.entity_id}
|
||||
</div>
|
||||
)}
|
||||
{(row.has_before || row.has_after) && (
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
{row.has_before ? 'before' : ''}{row.has_before && row.has_after ? ' · ' : ''}{row.has_after ? 'after' : ''}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell align-top text-xs font-mono break-all">
|
||||
<span title={metaBrief}>{metaBrief.slice(0, 120)}{metaBrief.length > 120 ? '…' : ''}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{!loading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
Không có sự kiện trong khoảng thời gian đã chọn.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
{total > 0 ? (
|
||||
<>
|
||||
Hiển thị {start}–{end} / {total}
|
||||
</>
|
||||
) : (
|
||||
'Không có bản ghi'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onPrev} disabled={page <= 1 || loading}>
|
||||
Trước
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={loading || page * pageSize >= total}
|
||||
>
|
||||
Sau
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RegistrationWithOtp } from '@/auth/registration/RegistrationWithOtp';
|
||||
|
||||
/**
|
||||
* Admin-oriented framing only — backend still derives `admin` vs `viewer` from email policy.
|
||||
*/
|
||||
export default function AdminRegistrationPage() {
|
||||
return <RegistrationWithOtp variant="admin" loginPath="/login" />;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Shared institutional login/register; use for `/admin/...` entry routes when split from the public `/login` page. */
|
||||
export { LoginRegisterCard } from '@/auth/LoginRegisterCard';
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { AuditActionType } from '@/audit/types';
|
||||
|
||||
/** Labels for audit actions (VN) — reusable if applicant-facing tooling is added later. */
|
||||
export const AUDIT_ACTION_LABEL_VI: Record<AuditActionType, string> = {
|
||||
create: 'Tạo',
|
||||
read: 'Đọc',
|
||||
update: 'Cập nhật',
|
||||
delete: 'Xóa',
|
||||
login: 'Đăng nhập',
|
||||
logout: 'Đăng xuất',
|
||||
login_failed: 'Đăng nhập thất bại',
|
||||
};
|
||||
|
||||
export function auditActionLabel(action: string): string {
|
||||
return AUDIT_ACTION_LABEL_VI[action as AuditActionType] ?? action;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { RegistrationWithOtp } from '@/auth/registration/RegistrationWithOtp';
|
||||
|
||||
/**
|
||||
* Applicant-facing registration (OTP verification after submit).
|
||||
* Same API as admin route; role is server-derived from email policy.
|
||||
*/
|
||||
export default function ApplicantRegistrationPage() {
|
||||
return <RegistrationWithOtp variant="applicant" loginPath="/login" />;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Shared institutional login/register; use for applicant-specific auth routes when split from the public `/login` page. */
|
||||
export { LoginRegisterCard } from '@/auth/LoginRegisterCard';
|
||||
@@ -0,0 +1,23 @@
|
||||
import { apiClient } from '@/shared/api/client';
|
||||
import type { AuditEventDetail, AuditListQuery, AuditListResponse } from '@/audit/types';
|
||||
|
||||
export async function fetchAuditEvents(query: AuditListQuery): Promise<AuditListResponse> {
|
||||
const params: Record<string, string | number> = {};
|
||||
if (query.from) params.from = query.from;
|
||||
if (query.to) params.to = query.to;
|
||||
if (query.actor_user_id) params.actor_user_id = query.actor_user_id;
|
||||
if (query.actor_email) params.actor_email = query.actor_email;
|
||||
if (query.entity_type) params.entity_type = query.entity_type;
|
||||
if (query.entity_id) params.entity_id = query.entity_id;
|
||||
if (query.action) params.action = query.action;
|
||||
if (query.request_id) params.request_id = query.request_id;
|
||||
if (query.page != null) params.page = query.page;
|
||||
if (query.page_size != null) params.page_size = query.page_size;
|
||||
if (query.sort) params.sort = query.sort;
|
||||
|
||||
return apiClient.get<AuditListResponse>('/api/v1/admin/audit', { params });
|
||||
}
|
||||
|
||||
export async function fetchAuditEventDetail(id: number): Promise<AuditEventDetail> {
|
||||
return apiClient.get<AuditEventDetail>(`/api/v1/admin/audit/${id}`);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { format, formatISO, parseISO } from 'date-fns';
|
||||
|
||||
/** Display local time as dd/MM/yyyy HH:mm; RFC3339 in tooltips. */
|
||||
export function formatAuditLocal(isoUtc: string): string {
|
||||
try {
|
||||
const d = parseISO(isoUtc);
|
||||
return format(d, 'dd/MM/yyyy HH:mm');
|
||||
} catch {
|
||||
return isoUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build RFC3339 in UTC from `datetime-local` value (YYYY-MM-DDTHH:mm interpreted as local). */
|
||||
export function localDateTimeInputToUtcRfc3339(localValue: string): string | undefined {
|
||||
if (!localValue.trim()) return undefined;
|
||||
const d = new Date(localValue);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return formatISO(d, { representation: 'complete' });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import microdiff from 'microdiff';
|
||||
|
||||
/** Human-readable microdiff paths for viewer (purely client-side, per policy). */
|
||||
export function describeJsonMicrodiff(before: unknown, after: unknown): string[] {
|
||||
const lhs =
|
||||
before !== null && typeof before === 'object' && !Array.isArray(before)
|
||||
? (before as Record<string, unknown>)
|
||||
: before === null || before === undefined
|
||||
? {}
|
||||
: { value: before };
|
||||
const rhs =
|
||||
after !== null && typeof after === 'object' && !Array.isArray(after)
|
||||
? (after as Record<string, unknown>)
|
||||
: after === null || after === undefined
|
||||
? {}
|
||||
: { value: after };
|
||||
|
||||
const changes = microdiff(lhs, rhs);
|
||||
return changes.map((c) => {
|
||||
const path = c.path.join('.');
|
||||
switch (c.type) {
|
||||
case 'add':
|
||||
return `+ ${path}: ${JSON.stringify(c.value)}`;
|
||||
case 'remove':
|
||||
return `- ${path}`;
|
||||
case 'change':
|
||||
return `~ ${path}: ${JSON.stringify(c.oldValue)} → ${JSON.stringify(c.value)}`;
|
||||
default:
|
||||
return String(path);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** Shared audit types (mirror `GET /api/v1/admin/audit` contract). */
|
||||
|
||||
export type AuditActionType =
|
||||
| 'create'
|
||||
| 'read'
|
||||
| 'update'
|
||||
| 'delete'
|
||||
| 'login'
|
||||
| 'logout'
|
||||
| 'login_failed';
|
||||
|
||||
export interface AuditListItem {
|
||||
id: number;
|
||||
occurred_at: string;
|
||||
actor_user_id: string | null;
|
||||
actor_email: string;
|
||||
actor_role: string;
|
||||
action: AuditActionType;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
request_id: string | null;
|
||||
has_before: boolean;
|
||||
has_after: boolean;
|
||||
}
|
||||
|
||||
export interface AuditListResponse {
|
||||
items: AuditListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export interface AuditEventDetail {
|
||||
id: number;
|
||||
occurred_at: string;
|
||||
actor_user_id: string | null;
|
||||
actor_email: string;
|
||||
actor_role: string;
|
||||
action: AuditActionType;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
before: Record<string, unknown> | null;
|
||||
after: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown>;
|
||||
request_id: string | null;
|
||||
}
|
||||
|
||||
export interface AuditListQuery {
|
||||
from?: string;
|
||||
to?: string;
|
||||
actor_user_id?: string;
|
||||
actor_email?: string;
|
||||
entity_type?: string;
|
||||
entity_id?: string;
|
||||
action?: string;
|
||||
request_id?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
sort?: string;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useLocation, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { resolvePostLoginPath } from '@/lib/dashboardNavigation';
|
||||
import {
|
||||
INSTITUTIONAL_EMAIL_HINT,
|
||||
validateInstitutionalEmail,
|
||||
} from '@/auth/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-only entry; registration with OTP lives under `/register` and `/admin/register`.
|
||||
*/
|
||||
export function LoginRegisterCard() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('mode') === 'register') {
|
||||
navigate('/register', { replace: true });
|
||||
}
|
||||
}, [searchParams, navigate]);
|
||||
|
||||
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;
|
||||
}
|
||||
const result = await login(email, loginPassword);
|
||||
if (result.success && result.activeRole) {
|
||||
navigate(resolvePostLoginPath(result.activeRole, fromPathname), { replace: true });
|
||||
return;
|
||||
}
|
||||
setError(result.error || 'Đă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 có tài khoản?</span>
|
||||
<div className="flex flex-col sm:flex-row gap-2 justify-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link to="/admin/register">Đăng ký - Quản trị</Link>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { LoginRegisterCard } from '@/auth/LoginRegisterCard';
|
||||
export {
|
||||
INSTITUTIONAL_EMAIL_HINT,
|
||||
INSTITUTIONAL_EMAIL_RE,
|
||||
validateInstitutionalEmail,
|
||||
} from '@/auth/institutionalEmail';
|
||||
export { getPrimaryRole } from '@/auth/primaryRole';
|
||||
export { buildUserFromAuthPayload, type AuthSessionUser } from '@/auth/sessionUser';
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Client-side UX validation only; server is authoritative (UMP + UMC domains).
|
||||
*/
|
||||
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,12 @@
|
||||
import type { Role } from '@/lib/permissions';
|
||||
|
||||
/** Highest privilege wins for shell session (admin > editor > viewer). */
|
||||
const ROLE_PRIORITY: Record<Role, number> = { admin: 3, editor: 2, viewer: 1 };
|
||||
|
||||
export function getPrimaryRole(roles: Role[]): Role {
|
||||
const list = roles?.length ? roles : (['viewer'] as Role[]);
|
||||
return list.reduce(
|
||||
(best, r) => (ROLE_PRIORITY[r] > ROLE_PRIORITY[best] ? r : best),
|
||||
list[0],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { REGISTRATION_OTP_LENGTH } from '@/auth/registration/constants';
|
||||
|
||||
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,738 @@
|
||||
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 { authService } from '@/lib/auth-service';
|
||||
import {
|
||||
StaffProfileFormFields,
|
||||
getSubmitReadinessIssues,
|
||||
normalizeEmployeeIdInput,
|
||||
type StaffFieldState,
|
||||
} from '@/components/profile';
|
||||
import { toast } from 'sonner';
|
||||
import { validateInstitutionalEmail, INSTITUTIONAL_EMAIL_HINT } from '@/auth/institutionalEmail';
|
||||
import { registrationPasswordIssue } from '@/auth/registration/passwordPolicy';
|
||||
import {
|
||||
REGISTRATION_OTP_LENGTH,
|
||||
REGISTRATION_OTP_RESEND_COOLDOWN_SECONDS,
|
||||
REGISTRATION_OTP_VALID_SECONDS,
|
||||
} from '@/auth/registration/constants';
|
||||
import { OtpSixInputs, emptyOtpDigits } from '@/auth/registration/OtpSixInputs';
|
||||
|
||||
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 authService.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(),
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
const reasons =
|
||||
res.rejectionReasons.length > 0
|
||||
? res.rejectionReasons
|
||||
: [res.error || 'Đăng ký thất bại. Vui lòng thử lại.'];
|
||||
setRegisterRejection({
|
||||
title: registrationRejectionHeading(res.httpStatus),
|
||||
reasons,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!('emailVerificationRequired' in res) || !res.emailVerificationRequired) {
|
||||
setRegisterRejection(null);
|
||||
setError('Phản hồi máy chủ không mong đợi.');
|
||||
return;
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
const res = await authService.verifyRegistrationOtp({
|
||||
email: registeredEmail,
|
||||
otp: code,
|
||||
});
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.error || 'Mã OTP không đúng hoặc đã hết hạn.');
|
||||
setOtp(emptyOtpDigits());
|
||||
otpInputRefs.current[0]?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
setStep('success');
|
||||
} catch {
|
||||
setError('Lỗi kết nối. Vui lòng thử lại.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
if (resendCountdown > 0 || !registeredEmail || loading) return;
|
||||
|
||||
setError('');
|
||||
setInfo('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await authService.resendRegistrationOtp(registeredEmail);
|
||||
if (!res.success) {
|
||||
setError(res.error || 'Không thể gửi lại mã. Vui lòng thử lại sau.');
|
||||
return;
|
||||
}
|
||||
setInfo(res.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 {
|
||||
setError('Lỗi kết nối. Vui lòng thử lại.');
|
||||
} 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 ký thành công</CardTitle>
|
||||
<CardDescription>
|
||||
Tài khoản <strong>{registeredEmail}</strong> đã được xác minh và 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 mã xác minh</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{otpDeliveryChannel === 'none' ? (
|
||||
<>
|
||||
Đăng ký đã ghi nhận. <strong>Email OTP chưa được gửi</strong> vì máy chủ chưa cấu hình
|
||||
SMTP.
|
||||
</>
|
||||
) : otpDeliveryChannel === 'smtp_failed' ? (
|
||||
<>
|
||||
Đăng ký đã ghi nhận. <strong>Gửi email OTP thất bại</strong> — SMTP có 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 ký be0).
|
||||
</>
|
||||
) : otpDeliveryChannel === 'log_only' ? (
|
||||
<>
|
||||
Mã OTP được ghi trong <strong>nhật ký dịch vụ be0</strong> (chế độ AUTH_MAIL_LOG_ONLY), không
|
||||
gửi qua hộp thư.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Mã {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">Mã đã hết hạn — dùng «Gửi lại mã 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 (ví 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 ký lại hoặc dùng «Gửi lại mã OTP» sau khi đã cấu hình.
|
||||
</p>
|
||||
<p>
|
||||
Trên môi trường dev, có thể đặt{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">AUTH_MAIL_LOG_ONLY=1</code> — mã
|
||||
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 là <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>{' '}
|
||||
và{' '}
|
||||
<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 có 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ó 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 mã OTP».
|
||||
</p>
|
||||
<p>
|
||||
Dev nhanh:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">AUTH_MAIL_LOG_ONLY=1</code> — mã
|
||||
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 mã OTP trong log</AlertTitle>
|
||||
<AlertDescription className="text-sm">
|
||||
Ví 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 mã?{' '}
|
||||
{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 mã 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ọ và 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 ký tự; gồm chữ hoa, chữ thường, số và ký 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ồ sơ 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ị là «Khác», ghi rõ
|
||||
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 mã OTP
|
||||
</Button>
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Đã có 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 ký người nộp đơn?{' '}
|
||||
<Link to="/register" className="text-primary hover:underline font-medium">
|
||||
Trang đăng ký 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 ký (ghi chú vai trò)
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,21 @@
|
||||
/**
|
||||
* Mirrors backend `_assert_password_policy` (auth_api.py) for immediate UX feedback.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AuthUser } from '@/lib/auth-service';
|
||||
import type { Permission, Role } from '@/lib/permissions';
|
||||
import { getPermissionsForRoles } from '@/lib/permissions';
|
||||
import { getPrimaryRole } from '@/auth/primaryRole';
|
||||
|
||||
/** Mirrors AuthContext `User` — defined here to avoid circular imports. */
|
||||
export type AuthSessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
phone?: string | null;
|
||||
roles: Role[];
|
||||
availableRoles: Role[];
|
||||
permissions: Permission[];
|
||||
};
|
||||
|
||||
export function buildUserFromAuthPayload(authUser: AuthUser): AuthSessionUser | null {
|
||||
const availableRoles = (authUser.roles?.length ? authUser.roles : ['viewer']) as Role[];
|
||||
const activeRole = getPrimaryRole(availableRoles);
|
||||
|
||||
return {
|
||||
id: authUser.id,
|
||||
email: authUser.email,
|
||||
name: authUser.name,
|
||||
phone: authUser.phone ?? null,
|
||||
roles: [activeRole],
|
||||
availableRoles,
|
||||
permissions: getPermissionsForRoles([activeRole]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
interface ArticleCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
date: string;
|
||||
image: string;
|
||||
size?: "small" | "large";
|
||||
}
|
||||
|
||||
const ArticleCard = ({ id, title, category, date, image, size = "small" }: ArticleCardProps) => {
|
||||
const getCategoryClass = (cat: string) => {
|
||||
const normalized = cat.toLowerCase();
|
||||
if (normalized.includes("financ")) return "tag-financing";
|
||||
if (normalized.includes("lifestyle")) return "tag-lifestyle";
|
||||
if (normalized.includes("community")) return "tag-community";
|
||||
if (normalized.includes("wellness")) return "tag-wellness";
|
||||
if (normalized.includes("travel")) return "tag-travel";
|
||||
if (normalized.includes("creativ")) return "tag-creativity";
|
||||
if (normalized.includes("growth")) return "tag-growth";
|
||||
return "tag-lifestyle";
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/article/${id}`}
|
||||
className={`group relative block rounded-[2.5rem] overflow-hidden card-hover ${
|
||||
size === "large" ? "col-span-1 md:col-span-2 row-span-2" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Image */}
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-muted rounded-[2.5rem]">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
|
||||
{/* Overlay gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent" />
|
||||
|
||||
{/* Content overlay */}
|
||||
<div className="absolute inset-0 p-8 flex flex-col justify-between">
|
||||
{/* Top section - Category and Date */}
|
||||
<div className="flex items-start justify-between">
|
||||
<span className={`px-4 py-1.5 rounded-full text-xs font-medium backdrop-blur-md ${getCategoryClass(category)} bg-opacity-80`}>
|
||||
{category}
|
||||
</span>
|
||||
<span className="px-4 py-1.5 rounded-full bg-white/20 backdrop-blur-md text-xs font-medium text-white border border-white/30">
|
||||
{date}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Bottom section - Title and Arrow */}
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<span className="text-white/50 text-xs font-medium tracking-wider block mb-3">{id}</span>
|
||||
<h3 className="text-white text-xl md:text-2xl lg:text-3xl font-bold leading-tight tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating circular arrow button - positioned outside content overlay */}
|
||||
<div className="absolute bottom-6 right-6 floating-button">
|
||||
<ArrowUpRight className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleCard;
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Send, Bot, User, CheckCircle2, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useChat } from "@/features/chat/hooks/useChat";
|
||||
import { Message } from "@/features/chat/types/chat.types";
|
||||
|
||||
interface ChatAssistantProps {
|
||||
verificationRequest?: { fieldName: string; content: string } | null;
|
||||
onVerificationHandled?: () => void;
|
||||
readOnly?: boolean; // If true, user can only view messages (for Editor role)
|
||||
}
|
||||
|
||||
export default function ChatAssistant({ verificationRequest, onVerificationHandled, readOnly = false }: ChatAssistantProps) {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
id: 1,
|
||||
role: "assistant",
|
||||
content: "Xin chào! Tôi là Trợ lý Sáng kiến. Tôi có thể giúp bạn kiểm tra nội dung các mục trong đơn đăng ký sáng kiến hoặc giải đáp thắc mắc về quy trình và chính sách. Bạn cần hỗ trợ gì?",
|
||||
},
|
||||
]);
|
||||
const [input, setInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { sendMessage, verifyContent, isLoading } = useChat();
|
||||
|
||||
// Handle verification requests from the form
|
||||
useEffect(() => {
|
||||
if (verificationRequest) {
|
||||
const verifyMessage: Message = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: `[Kiểm tra nội dung: ${verificationRequest.fieldName}]\n\n"${verificationRequest.content}"`,
|
||||
isVerification: true,
|
||||
fieldName: verificationRequest.fieldName,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, verifyMessage]);
|
||||
|
||||
// Add loading message
|
||||
const loadingMessage: Message = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: "Đang kiểm tra nội dung...",
|
||||
isLoading: true,
|
||||
};
|
||||
setMessages((prev) => [...prev, loadingMessage]);
|
||||
|
||||
// Call real API for verification
|
||||
verifyContent(verificationRequest.fieldName, verificationRequest.content)
|
||||
.then((response) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === loadingMessage.id
|
||||
? {
|
||||
...msg,
|
||||
content: response,
|
||||
isLoading: false,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
onVerificationHandled?.();
|
||||
})
|
||||
.catch((error) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === loadingMessage.id
|
||||
? {
|
||||
...msg,
|
||||
content: `Xin lỗi, đã xảy ra lỗi khi kiểm tra nội dung: ${error.message || "Lỗi không xác định"}`,
|
||||
isLoading: false,
|
||||
error: error.message,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
onVerificationHandled?.();
|
||||
});
|
||||
}
|
||||
}, [verificationRequest, onVerificationHandled, verifyContent]);
|
||||
|
||||
// Auto scroll to bottom
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now(),
|
||||
role: "user",
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const userInput = input;
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput("");
|
||||
|
||||
// Add loading message
|
||||
const loadingMessage: Message = {
|
||||
id: Date.now() + 1,
|
||||
role: "assistant",
|
||||
content: "Đang suy nghĩ...",
|
||||
isLoading: true,
|
||||
};
|
||||
setMessages((prev) => [...prev, loadingMessage]);
|
||||
|
||||
try {
|
||||
// Build conversation history (last 10 messages for context)
|
||||
const conversationHistory = messages
|
||||
.filter((msg) => !msg.isLoading && !msg.error)
|
||||
.slice(-10)
|
||||
.map((msg) => ({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
}));
|
||||
|
||||
// Call real API with conversation history
|
||||
const response = await sendMessage(userInput, conversationHistory);
|
||||
|
||||
// Update loading message with response
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === loadingMessage.id
|
||||
? {
|
||||
...msg,
|
||||
content: response,
|
||||
isLoading: false,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Update loading message with error
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === loadingMessage.id
|
||||
? {
|
||||
...msg,
|
||||
content: `Xin lỗi, đã xảy ra lỗi: ${error.message || "Không thể kết nối với máy chủ"}`,
|
||||
isLoading: false,
|
||||
error: error.message,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
: msg
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-card rounded-lg border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 p-4 border-b bg-primary/5">
|
||||
<div className="p-2 rounded-full bg-primary/10">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Trợ lý Sáng kiến</h3>
|
||||
<p className="text-xs text-muted-foreground">Hỗ trợ kiểm tra & tư vấn</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-4" ref={scrollRef}>
|
||||
<div className="space-y-4">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
message.role === "user" ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
{message.role === "assistant" && (
|
||||
<div className="p-1.5 rounded-full bg-primary/10 h-fit">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap",
|
||||
message.role === "user"
|
||||
? message.isVerification
|
||||
? "bg-secondary text-secondary-foreground border border-primary/30"
|
||||
: "bg-primary text-primary-foreground"
|
||||
: message.error
|
||||
? "bg-destructive/10 text-destructive border border-destructive/20"
|
||||
: "bg-muted"
|
||||
)}
|
||||
>
|
||||
{message.isVerification && (
|
||||
<div className="flex items-center gap-1 text-xs text-primary mb-1">
|
||||
<CheckCircle2 size={12} />
|
||||
<span>Yêu cầu kiểm tra</span>
|
||||
</div>
|
||||
)}
|
||||
{message.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{message.content}</span>
|
||||
</div>
|
||||
) : message.error ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{message.content}</span>
|
||||
</div>
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
</div>
|
||||
{message.role === "user" && (
|
||||
<div className="p-1.5 rounded-full bg-secondary h-fit">
|
||||
<User className="h-4 w-4 text-secondary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input - Hidden for read-only mode (Editor) */}
|
||||
{!readOnly ? (
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Nhập câu hỏi của bạn..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="icon" onClick={handleSend} disabled={!input.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 border-t bg-muted/50">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Chế độ chỉ xem - Không thể gửi tin nhắn
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,984 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Plus, Trash2, Save, FileText, Pencil, Check, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
collectContributionDigitalSignaturePrerequisiteGaps,
|
||||
formatApplicantPrerequisiteToastDescription,
|
||||
} from '@/lib/applicantHonestyPrerequisites';
|
||||
import type { ApplicantPrefill } from '@/types/applicantPrefill';
|
||||
import { useOptionalDraft } from '@/components/applicant/initiative-draft/useDraft';
|
||||
import { exportDraftPdf } from '@/components/applicant/initiative-draft/pdfExport';
|
||||
import {
|
||||
canonicalMainAuthorFromDraft,
|
||||
pushMainAuthorToDraft,
|
||||
reconcileRepresentativeAuthorSlices,
|
||||
} from '@/components/applicant/initiative-draft/contributionRepresentativeAuthorSync';
|
||||
import type { Author } from '@/components/applicant/initiativeFormTypes';
|
||||
import {
|
||||
authorToContributionRow,
|
||||
contributionColumnToAuthorField,
|
||||
createEmptyAuthorRow,
|
||||
normalizeContributionPercent,
|
||||
} from '@/components/applicant/initiative-draft/applicationAuthorsContributionTable';
|
||||
import { SHOW_APPLICANT_FORM_AUTOFILL } from '@/test/applicantFormTestFlags';
|
||||
import { ApplicantFormAutofillButton } from '@/test/ApplicantFormAutofillButton';
|
||||
import {
|
||||
authorsForContributionTab,
|
||||
buildContributionTestSlice,
|
||||
} from '@/test/applicantFormTestFixtures';
|
||||
|
||||
interface Participant {
|
||||
id: number;
|
||||
fullName: string;
|
||||
workUnit: string;
|
||||
contributionPercent: number;
|
||||
isEditing: boolean;
|
||||
}
|
||||
|
||||
interface ContributionFormState {
|
||||
initiativeName: string;
|
||||
mainAuthor: string;
|
||||
position: string;
|
||||
representativePercent: number;
|
||||
submissionDate: Date;
|
||||
participants: Participant[];
|
||||
digitalSignatureConfirmed: boolean;
|
||||
}
|
||||
|
||||
interface ContributionConfirmationFormProps {
|
||||
onVerify?: (fieldName: string, content: string) => void;
|
||||
/** When true, all fields are non-interactive (e.g. staff review). */
|
||||
readOnly?: boolean;
|
||||
showVerifyButton?: boolean; // Only Admin can see verify button
|
||||
applicantPrefill?: ApplicantPrefill;
|
||||
initialDraft?: Partial<ContributionFormState>;
|
||||
onSaveDraft?: (data: ContributionFormState) => Promise<void>;
|
||||
autoSaveDebounceMs?: number;
|
||||
/** After a successful save (e.g. Dashboard switches to « Xem lại & tải PDF »). */
|
||||
onNext?: () => void;
|
||||
/**
|
||||
* When there is no DraftProvider (e.g. admin case review), pass the Đơn tab `authors` array
|
||||
* so this table matches « Đơn Đề nghị Công nhận ». Ignored whenever a draft context exists.
|
||||
*/
|
||||
mirrorApplicationAuthors?: Author[];
|
||||
/**
|
||||
* When there is no DraftProvider, pass `application.initiativeName` from the Đơn tab JSON
|
||||
* so this field matches tab « Đơn Đề nghị ». Omit in applicant flow. Ignored when a draft context exists.
|
||||
*/
|
||||
mirrorInitiativeNameFromApplication?: string;
|
||||
}
|
||||
|
||||
const initialFormState: ContributionFormState = {
|
||||
initiativeName: '',
|
||||
mainAuthor: '',
|
||||
position: '',
|
||||
representativePercent: 0,
|
||||
submissionDate: new Date(),
|
||||
participants: [],
|
||||
digitalSignatureConfirmed: false,
|
||||
};
|
||||
|
||||
export default function ContributionConfirmationForm({
|
||||
onVerify,
|
||||
readOnly = false,
|
||||
showVerifyButton = false,
|
||||
applicantPrefill,
|
||||
initialDraft,
|
||||
onSaveDraft,
|
||||
autoSaveDebounceMs,
|
||||
onNext,
|
||||
mirrorApplicationAuthors: mirrorApplicationAuthorsProp,
|
||||
mirrorInitiativeNameFromApplication: mirrorInitiativeNameFromApplicationProp,
|
||||
}: ContributionConfirmationFormProps) {
|
||||
const draftCtx = useOptionalDraft();
|
||||
const draftCtxRef = useRef(draftCtx);
|
||||
draftCtxRef.current = draftCtx;
|
||||
|
||||
const draft = draftCtx?.draft;
|
||||
const draftId = draft?.draftId;
|
||||
const updateReport = draftCtx?.updateReport;
|
||||
const hasDraftContext = draftCtx != null;
|
||||
|
||||
const [formData, setFormData] = useState<ContributionFormState>(initialFormState);
|
||||
const autoSaveSkipRef = useRef(true);
|
||||
const [editingRow, setEditingRow] = useState<Participant | null>(null);
|
||||
/** Đơn §1 authors — edit row / cancel backup when table is driven by draft */
|
||||
const [editingAuthorId, setEditingAuthorId] = useState<number | null>(null);
|
||||
const [authorEditBackup, setAuthorEditBackup] = useState<Author | null>(null);
|
||||
const [isSavingDraft, setIsSavingDraft] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasDraftContext) return;
|
||||
if (!applicantPrefill) return;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...(applicantPrefill.initiativeName ? { initiativeName: applicantPrefill.initiativeName } : {}),
|
||||
...(applicantPrefill.authorName ? { mainAuthor: applicantPrefill.authorName } : {}),
|
||||
}));
|
||||
}, [hasDraftContext, applicantPrefill?.initiativeName, applicantPrefill?.authorName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasDraftContext) return;
|
||||
if (!initialDraft) return;
|
||||
setFormData((prev) => ({ ...prev, ...initialDraft }));
|
||||
}, [hasDraftContext, initialDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSaveDraft || !autoSaveDebounceMs || readOnly) return;
|
||||
if (autoSaveSkipRef.current) {
|
||||
autoSaveSkipRef.current = false;
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await onSaveDraft(formData);
|
||||
} catch (e) {
|
||||
console.error("Đóng góp — tự động lưu bản nháp thất bại:", e);
|
||||
}
|
||||
})();
|
||||
}, autoSaveDebounceMs);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [formData, onSaveDraft, autoSaveDebounceMs, readOnly]);
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (isSavingDraft) return;
|
||||
setIsSavingDraft(true);
|
||||
try {
|
||||
if (onSaveDraft) {
|
||||
await onSaveDraft(formData);
|
||||
}
|
||||
toast.success('Đã lưu bản nháp xác nhận đóng góp.');
|
||||
onNext?.();
|
||||
} finally {
|
||||
setIsSavingDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportCurrentPdf = async () => {
|
||||
if (!draftCtx) {
|
||||
toast.error('Không tìm thấy dữ liệu bản nháp để xuất PDF.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await exportDraftPdf(draftCtx.draft, draftCtx.caseId);
|
||||
toast.success('Đã xuất PDF hồ sơ hiện tại.');
|
||||
} catch {
|
||||
toast.error('Không thể xuất PDF hồ sơ hiện tại.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const applicationAuthors = draft?.application.authors;
|
||||
|
||||
/** Same idea as `applicationAuthors` from DraftProvider: admin review passes Đơn `authors` explicitly. */
|
||||
const mirrorsFromApplication =
|
||||
!draftCtx &&
|
||||
Array.isArray(mirrorApplicationAuthorsProp) &&
|
||||
mirrorApplicationAuthorsProp.length > 0;
|
||||
const mirrorApplicationAuthors = mirrorsFromApplication
|
||||
? mirrorApplicationAuthorsProp
|
||||
: undefined;
|
||||
|
||||
const mirrorsInitiativeNameFromApplication =
|
||||
!draftCtx && mirrorInitiativeNameFromApplicationProp !== undefined;
|
||||
|
||||
/** Rows for § “Tỷ Lệ Đóng Góp”: mirror Đơn §1 `authors` when a draft exists (or when `mirrorApplicationAuthors` is set). */
|
||||
const tableParticipants = useMemo((): Participant[] => {
|
||||
if (hasDraftContext && applicationAuthors) {
|
||||
if (applicationAuthors.length === 0) return [];
|
||||
return applicationAuthors.map((a) =>
|
||||
authorToContributionRow(a, editingAuthorId === a.id),
|
||||
);
|
||||
}
|
||||
if (mirrorsFromApplication && mirrorApplicationAuthors) {
|
||||
return mirrorApplicationAuthors.map((a) => authorToContributionRow(a, false));
|
||||
}
|
||||
return formData.participants;
|
||||
}, [
|
||||
hasDraftContext,
|
||||
applicationAuthors,
|
||||
formData.participants,
|
||||
editingAuthorId,
|
||||
mirrorsFromApplication,
|
||||
mirrorApplicationAuthors,
|
||||
]);
|
||||
|
||||
const totalPercent = useMemo(() => {
|
||||
if (hasDraftContext && draft) {
|
||||
return draft.application.authors.reduce(
|
||||
(sum, a) => sum + Number(a.contributionPercent || 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (mirrorsFromApplication && mirrorApplicationAuthors) {
|
||||
return mirrorApplicationAuthors.reduce(
|
||||
(sum, a) => sum + Number(a.contributionPercent || 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
return formData.participants.reduce((sum, p) => sum + (p.contributionPercent || 0), 0);
|
||||
}, [
|
||||
hasDraftContext,
|
||||
draft?.application.authors,
|
||||
formData.participants,
|
||||
mirrorsFromApplication,
|
||||
mirrorApplicationAuthors,
|
||||
]);
|
||||
|
||||
const isValid = totalPercent === 100;
|
||||
|
||||
const contributionSignaturePrerequisiteGaps = useMemo(() => {
|
||||
if (!draftCtx || readOnly) return [];
|
||||
return collectContributionDigitalSignaturePrerequisiteGaps(
|
||||
draftCtx.draft.report,
|
||||
draftCtx.draft.application,
|
||||
);
|
||||
}, [draftCtx, readOnly, draft?.report, draft?.application]);
|
||||
|
||||
/** Đồng bộ block tác giả chính với `authors[0]` khi bảng lấy từ tab Đơn (admin — không DraftProvider). */
|
||||
useEffect(() => {
|
||||
if (hasDraftContext) return;
|
||||
if (!mirrorsFromApplication || !mirrorApplicationAuthors) return;
|
||||
const a0 = mirrorApplicationAuthors[0];
|
||||
if (!a0) return;
|
||||
setFormData((prev) => {
|
||||
const mainAuthor = a0.name ?? '';
|
||||
const position = a0.workplace ?? '';
|
||||
const representativePercent = Number(a0.contributionPercent) || 0;
|
||||
if (
|
||||
prev.mainAuthor === mainAuthor &&
|
||||
prev.position === position &&
|
||||
prev.representativePercent === representativePercent
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, mainAuthor, position, representativePercent };
|
||||
});
|
||||
}, [hasDraftContext, mirrorsFromApplication, mirrorApplicationAuthors]);
|
||||
|
||||
/** Tên sáng kiến từ tab Đơn (admin — không DraftProvider). */
|
||||
useEffect(() => {
|
||||
if (hasDraftContext) return;
|
||||
if (mirrorInitiativeNameFromApplicationProp === undefined) return;
|
||||
setFormData((prev) => {
|
||||
if (prev.initiativeName === mirrorInitiativeNameFromApplicationProp) return prev;
|
||||
return { ...prev, initiativeName: mirrorInitiativeNameFromApplicationProp };
|
||||
});
|
||||
}, [hasDraftContext, mirrorInitiativeNameFromApplicationProp]);
|
||||
|
||||
// Sync main author as first participant (standalone / no draft only; skip when bảng lấy từ tab Đơn)
|
||||
useEffect(() => {
|
||||
if (hasDraftContext) return;
|
||||
if (mirrorsFromApplication) return;
|
||||
if (formData.mainAuthor && formData.participants.length === 0) {
|
||||
const newParticipant: Participant = {
|
||||
id: Date.now(),
|
||||
fullName: formData.mainAuthor,
|
||||
workUnit: formData.position,
|
||||
contributionPercent: formData.representativePercent,
|
||||
isEditing: false,
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participants: [newParticipant],
|
||||
}));
|
||||
} else if (formData.participants.length > 0) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participants: prev.participants.map((p, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...p,
|
||||
fullName: formData.mainAuthor,
|
||||
workUnit: formData.position,
|
||||
contributionPercent: formData.representativePercent,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
}));
|
||||
}
|
||||
}, [
|
||||
hasDraftContext,
|
||||
mirrorsFromApplication,
|
||||
formData.mainAuthor,
|
||||
formData.position,
|
||||
formData.representativePercent,
|
||||
formData.participants.length,
|
||||
]);
|
||||
|
||||
/** Keep header “tác giả chính” block aligned with Đơn `authors[0]` when the đơn table changes elsewhere. */
|
||||
useEffect(() => {
|
||||
if (!draft || readOnly) return;
|
||||
const a0 = draft.application.authors[0];
|
||||
if (!a0) return;
|
||||
setFormData((prev) => {
|
||||
const mainAuthor = a0.name ?? '';
|
||||
const position = a0.workplace ?? '';
|
||||
const representativePercent = Number(a0.contributionPercent) || 0;
|
||||
if (
|
||||
prev.mainAuthor === mainAuthor &&
|
||||
prev.position === position &&
|
||||
prev.representativePercent === representativePercent
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, mainAuthor, position, representativePercent };
|
||||
});
|
||||
}, [readOnly, draftId, applicationAuthors]);
|
||||
|
||||
/**
|
||||
* “Tên sáng kiến” is the same field as báo cáo §2.1 and Đơn (`report.initiativeName` / `application.initiativeName`).
|
||||
* @see InitiativeReportForm initiative name input
|
||||
*/
|
||||
const handleInitiativeNameChange = useCallback(
|
||||
(value: string) => {
|
||||
if (readOnly) return;
|
||||
if (mirrorsInitiativeNameFromApplication) return;
|
||||
setFormData((prev) => ({ ...prev, initiativeName: value }));
|
||||
const ctx = draftCtxRef.current;
|
||||
if (ctx && !readOnly) {
|
||||
ctx.updateReport({ initiativeName: value });
|
||||
void ctx.updateApplication({ initiativeName: value });
|
||||
}
|
||||
},
|
||||
[readOnly, mirrorsInitiativeNameFromApplication],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft || readOnly) return;
|
||||
const appName = draft.application.initiativeName ?? '';
|
||||
setFormData((prev) => {
|
||||
if (prev.initiativeName === appName) return prev;
|
||||
return { ...prev, initiativeName: appName };
|
||||
});
|
||||
const reportName = draft.report.initiativeName ?? '';
|
||||
if (reportName !== appName && updateReport) {
|
||||
updateReport({ initiativeName: appName });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- use draft primitives + stable updateReport; omit unstable context
|
||||
}, [readOnly, draftId, draft?.application.initiativeName, draft?.report.initiativeName, updateReport]);
|
||||
|
||||
/**
|
||||
* Trùng với Mẫu 03 §2 và báo cáo §2.2 (`report.representativeAuthor`), Đơn tác giả thứ nhất.
|
||||
* @see InitiativeReportForm §2.2
|
||||
*/
|
||||
const handleMainAuthorChange = useCallback(
|
||||
(value: string) => {
|
||||
if (readOnly) return;
|
||||
if (mirrorsFromApplication) return;
|
||||
setFormData((prev) => ({ ...prev, mainAuthor: value }));
|
||||
const ctx = draftCtxRef.current;
|
||||
if (ctx && !readOnly) {
|
||||
pushMainAuthorToDraft(ctx, value);
|
||||
}
|
||||
},
|
||||
[readOnly, mirrorsFromApplication],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (readOnly) return;
|
||||
const ctx = draftCtxRef.current;
|
||||
if (!ctx) return;
|
||||
const canonical = canonicalMainAuthorFromDraft(ctx.draft);
|
||||
setFormData((prev) => {
|
||||
if (prev.mainAuthor === canonical) return prev;
|
||||
return { ...prev, mainAuthor: canonical };
|
||||
});
|
||||
reconcileRepresentativeAuthorSlices(ctx, readOnly);
|
||||
}, [
|
||||
readOnly,
|
||||
draftId,
|
||||
draft?.application.authors[0]?.name,
|
||||
draft?.report.representativeAuthor,
|
||||
]);
|
||||
|
||||
const patchFirstAuthorInDraft = useCallback(
|
||||
(patch: Partial<Pick<Author, 'name' | 'workplace' | 'contributionPercent'>>) => {
|
||||
const ctx = draftCtxRef.current;
|
||||
if (!ctx || readOnly) return;
|
||||
const authors = ctx.draft.application.authors;
|
||||
const first = authors[0];
|
||||
if (!first) return;
|
||||
void ctx.updateApplication({
|
||||
authors: [{ ...first, ...patch }, ...authors.slice(1)],
|
||||
});
|
||||
},
|
||||
[readOnly],
|
||||
);
|
||||
|
||||
const handlePositionChange = useCallback(
|
||||
(value: string) => {
|
||||
if (readOnly) return;
|
||||
if (mirrorsFromApplication) return;
|
||||
setFormData((prev) => ({ ...prev, position: value }));
|
||||
patchFirstAuthorInDraft({ workplace: value });
|
||||
},
|
||||
[patchFirstAuthorInDraft, mirrorsFromApplication],
|
||||
);
|
||||
|
||||
const handleRepresentativePercentChange = useCallback(
|
||||
(value: number) => {
|
||||
if (readOnly) return;
|
||||
if (mirrorsFromApplication) return;
|
||||
setFormData((prev) => ({ ...prev, representativePercent: value }));
|
||||
patchFirstAuthorInDraft({ contributionPercent: value });
|
||||
},
|
||||
[patchFirstAuthorInDraft, mirrorsFromApplication],
|
||||
);
|
||||
|
||||
const handleInputChange = (field: keyof ContributionFormState, value: string | number | Date | boolean) => {
|
||||
if (readOnly) return;
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addParticipant = () => {
|
||||
if (readOnly) return;
|
||||
if (mirrorsFromApplication) {
|
||||
toast.info('Thêm tác giả ở tab « Đơn Đề nghị Công nhận » (bảng đang đồng bộ với Đơn).');
|
||||
return;
|
||||
}
|
||||
if (draftCtx) {
|
||||
const newAuthor = createEmptyAuthorRow();
|
||||
const authors = [...draftCtx.draft.application.authors, newAuthor];
|
||||
void draftCtx.updateApplication({ authors });
|
||||
setEditingAuthorId(newAuthor.id);
|
||||
setAuthorEditBackup(null);
|
||||
return;
|
||||
}
|
||||
const newParticipant: Participant = {
|
||||
id: Date.now(),
|
||||
fullName: '',
|
||||
workUnit: '',
|
||||
contributionPercent: 0,
|
||||
isEditing: true,
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participants: [...prev.participants, newParticipant],
|
||||
}));
|
||||
setEditingRow(newParticipant);
|
||||
};
|
||||
|
||||
const updateParticipant = (id: number, field: keyof Participant, value: string | number | boolean) => {
|
||||
if (mirrorsFromApplication && !draftCtx) return;
|
||||
if (draftCtx && !readOnly) {
|
||||
const col =
|
||||
field === 'fullName' || field === 'workUnit' || field === 'contributionPercent' ? field : null;
|
||||
if (!col) return;
|
||||
const authorKey = contributionColumnToAuthorField(col);
|
||||
const authors = draftCtx.draft.application.authors.map((a) => {
|
||||
if (a.id !== id) return a;
|
||||
const v =
|
||||
authorKey === 'contributionPercent'
|
||||
? normalizeContributionPercent(value)
|
||||
: value;
|
||||
return { ...a, [authorKey]: v };
|
||||
});
|
||||
void draftCtx.updateApplication({ authors });
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participants: prev.participants.map((p) => (p.id === id ? { ...p, [field]: value } : p)),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeParticipant = (id: number) => {
|
||||
if (mirrorsFromApplication) {
|
||||
toast.info('Xóa hoặc chỉnh tác giả ở tab « Đơn Đề nghị Công nhận » (bảng đang đồng bộ với Đơn).');
|
||||
return;
|
||||
}
|
||||
if (tableParticipants[0]?.id === id) {
|
||||
toast.error('Không thể xóa tác giả chính');
|
||||
return;
|
||||
}
|
||||
if (draftCtx && !readOnly) {
|
||||
const authors = draftCtx.draft.application.authors.filter((a) => a.id !== id);
|
||||
void draftCtx.updateApplication({ authors });
|
||||
if (editingAuthorId === id) {
|
||||
setEditingAuthorId(null);
|
||||
setAuthorEditBackup(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participants: prev.participants.filter((p) => p.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
const startEditing = (participant: Participant) => {
|
||||
if (readOnly) return;
|
||||
if (mirrorsFromApplication) {
|
||||
toast.info('Chỉnh tác giả ở tab « Đơn Đề nghị Công nhận » (bảng đang đồng bộ với Đơn).');
|
||||
return;
|
||||
}
|
||||
if (draftCtx) {
|
||||
const author = draftCtx.draft.application.authors.find((a) => a.id === participant.id);
|
||||
setAuthorEditBackup(author ? { ...author } : null);
|
||||
setEditingAuthorId(participant.id);
|
||||
return;
|
||||
}
|
||||
setEditingRow({ ...participant });
|
||||
updateParticipant(participant.id, 'isEditing', true);
|
||||
};
|
||||
|
||||
const cancelEditing = (id: number) => {
|
||||
if (draftCtx && !readOnly) {
|
||||
if (authorEditBackup?.id === id) {
|
||||
void draftCtx.updateApplication({
|
||||
authors: draftCtx.draft.application.authors.map((a) => (a.id === id ? authorEditBackup : a)),
|
||||
});
|
||||
}
|
||||
if (editingAuthorId === id) {
|
||||
setEditingAuthorId(null);
|
||||
setAuthorEditBackup(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editingRow) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
participants: prev.participants.map((p) =>
|
||||
p.id === id ? { ...editingRow, isEditing: false } : p,
|
||||
),
|
||||
}));
|
||||
}
|
||||
setEditingRow(null);
|
||||
};
|
||||
|
||||
const saveEditing = (id: number) => {
|
||||
if (readOnly) return;
|
||||
if (draftCtx) {
|
||||
setEditingAuthorId(null);
|
||||
setAuthorEditBackup(null);
|
||||
return;
|
||||
}
|
||||
updateParticipant(id, 'isEditing', false);
|
||||
setEditingRow(null);
|
||||
};
|
||||
|
||||
const handleAutofillTestData = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
if (mirrorsFromApplication) return;
|
||||
if (mirrorsInitiativeNameFromApplication) return;
|
||||
const slice = buildContributionTestSlice();
|
||||
const baseAuthors = authorsForContributionTab();
|
||||
const authors: Author[] = baseAuthors.map((a, i) =>
|
||||
i === 0 ? { ...a, workplace: slice.position } : a,
|
||||
);
|
||||
const ctx = draftCtxRef.current;
|
||||
if (ctx && !readOnly) {
|
||||
void ctx.updateApplication({ authors, initiativeName: slice.initiativeName });
|
||||
ctx.updateReport({
|
||||
initiativeName: slice.initiativeName,
|
||||
representativeAuthor: slice.mainAuthor,
|
||||
authorName: slice.mainAuthor,
|
||||
});
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
initiativeName: slice.initiativeName,
|
||||
mainAuthor: slice.mainAuthor,
|
||||
position: slice.position,
|
||||
representativePercent: slice.representativePercent,
|
||||
submissionDate: slice.submissionDate,
|
||||
digitalSignatureConfirmed: true,
|
||||
}));
|
||||
toast.message('Đã điền dữ liệu thử nghiệm (TEST) theo cấu trúc data_blank.json (Mẫu 03).');
|
||||
}, [readOnly, mirrorsFromApplication, mirrorsInitiativeNameFromApplication]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid) {
|
||||
toast.error('Tổng tỷ lệ đóng góp phải bằng 100%');
|
||||
return;
|
||||
}
|
||||
if (!formData.digitalSignatureConfirmed) {
|
||||
toast.error('Vui lòng xác nhận chữ ký số');
|
||||
return;
|
||||
}
|
||||
if (!formData.initiativeName.trim()) {
|
||||
toast.error('Vui lòng nhập tên sáng kiến');
|
||||
return;
|
||||
}
|
||||
if (!formData.mainAuthor.trim()) {
|
||||
toast.error('Vui lòng nhập tên tác giả chính');
|
||||
return;
|
||||
}
|
||||
toast.success('Đã lưu bản xác nhận thành công!');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-border shadow-lg">
|
||||
<div className="relative">
|
||||
{SHOW_APPLICANT_FORM_AUTOFILL && !readOnly ? (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<ApplicantFormAutofillButton onClick={handleAutofillTestData} />
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-center mt-6 space-y-1">
|
||||
<div className="flex justify-center gap-8 text-xs text-muted-foreground mb-4">
|
||||
<div>
|
||||
<p className="font-semibold">BỘ Y TẾ</p>
|
||||
<p className="font-bold text-foreground">ĐẠI HỌC Y DƯỢC</p>
|
||||
<p className="font-bold text-foreground">THÀNH PHỐ HỒ CHÍ MINH</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold">CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM</p>
|
||||
<p className="font-bold text-foreground">Độc lập – Tự do – Hạnh phúc</p>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold uppercase">
|
||||
Bản Xác Nhận
|
||||
</CardTitle>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
Tỷ Lệ (%) Đóng Góp Vào Việc Tạo Ra Sáng Kiến
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-6 md:p-8 space-y-6">
|
||||
{/* Section 1: General Information */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-lg font-bold text-foreground border-b border-border pb-2">
|
||||
Thông Tin Chung
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="initiativeName" className="text-sm font-medium">
|
||||
1. Tên sáng kiến <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="initiativeName"
|
||||
placeholder="Nhập tên sáng kiến..."
|
||||
className="min-h-20 mt-1"
|
||||
value={formData.initiativeName}
|
||||
onChange={(e) => handleInitiativeNameChange(e.target.value)}
|
||||
disabled={readOnly || mirrorsInitiativeNameFromApplication}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="mainAuthor" className="text-sm font-medium">
|
||||
2. Tác giả chính / Đại diện nhóm tác giả sáng kiến <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="mainAuthor"
|
||||
placeholder="Nhập họ và tên..."
|
||||
className="mt-1"
|
||||
value={formData.mainAuthor}
|
||||
onChange={(e) => handleMainAuthorChange(e.target.value)}
|
||||
disabled={readOnly || mirrorsFromApplication}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="position" className="text-sm font-medium">
|
||||
Chức vụ, đơn vị công tác
|
||||
</Label>
|
||||
<Input
|
||||
id="position"
|
||||
placeholder="Nhập chức vụ và đơn vị..."
|
||||
className="mt-1"
|
||||
value={formData.position}
|
||||
onChange={(e) => handlePositionChange(e.target.value)}
|
||||
disabled={readOnly || mirrorsFromApplication}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="representativePercent" className="text-sm font-medium">
|
||||
Tỷ lệ đóng góp của đại diện (%)
|
||||
</Label>
|
||||
<Input
|
||||
id="representativePercent"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
placeholder="0"
|
||||
className="mt-1"
|
||||
value={formData.representativePercent || ''}
|
||||
onChange={(e) => handleRepresentativePercentChange(Number(e.target.value))}
|
||||
disabled={readOnly || mirrorsFromApplication}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Ngày nộp</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={readOnly}
|
||||
className={cn(
|
||||
"w-full md:w-[280px] justify-start text-left font-normal mt-1",
|
||||
!formData.submissionDate && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formData.submissionDate ? (
|
||||
format(formData.submissionDate, 'dd/MM/yyyy')
|
||||
) : (
|
||||
<span>Chọn ngày</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.submissionDate}
|
||||
onSelect={(date) => date && handleInputChange('submissionDate', date)}
|
||||
initialFocus
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2: Participant Table */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-foreground border-b border-border pb-2 flex-1">
|
||||
Tỷ Lệ Đóng Góp
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addParticipant}
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
disabled={readOnly || mirrorsFromApplication}
|
||||
>
|
||||
<Plus size={16} /> Thêm thành viên
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16 text-center">STT</TableHead>
|
||||
<TableHead>Họ và tên</TableHead>
|
||||
<TableHead>Đơn vị công tác</TableHead>
|
||||
<TableHead className="w-32 text-center">% đóng góp</TableHead>
|
||||
<TableHead className="w-24 text-center">Thao tác</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tableParticipants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground italic py-8">
|
||||
Nhập thông tin tác giả chính để bắt đầu
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tableParticipants.map((participant, index) => (
|
||||
<TableRow key={participant.id}>
|
||||
<TableCell className="text-center font-medium">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
{participant.isEditing ? (
|
||||
<Input
|
||||
className="border-input"
|
||||
value={participant.fullName}
|
||||
onChange={(e) => updateParticipant(participant.id, 'fullName', e.target.value)}
|
||||
disabled={index === 0 || readOnly || mirrorsFromApplication}
|
||||
/>
|
||||
) : (
|
||||
<span>{participant.fullName || '-'}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{participant.isEditing ? (
|
||||
<Input
|
||||
className="border-input"
|
||||
value={participant.workUnit}
|
||||
onChange={(e) => updateParticipant(participant.id, 'workUnit', e.target.value)}
|
||||
disabled={index === 0 || readOnly || mirrorsFromApplication}
|
||||
/>
|
||||
) : (
|
||||
<span>{participant.workUnit || '-'}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{participant.isEditing ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
className="border-input text-center w-20 mx-auto"
|
||||
value={participant.contributionPercent || ''}
|
||||
onChange={(e) => updateParticipant(participant.id, 'contributionPercent', Number(e.target.value))}
|
||||
disabled={index === 0 || readOnly || mirrorsFromApplication}
|
||||
/>
|
||||
) : (
|
||||
<span>{participant.contributionPercent}%</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex justify-center gap-1">
|
||||
{index === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic">Tác giả chính</span>
|
||||
) : readOnly || mirrorsFromApplication ? (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
) : participant.isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => saveEditing(participant.id)}
|
||||
className="h-8 w-8 text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => cancelEditing(participant.id)}
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startEditing(participant)}
|
||||
className="h-8 w-8 text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeParticipant(participant.id)}
|
||||
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-right font-bold">
|
||||
TỔNG CỘNG
|
||||
</TableCell>
|
||||
<TableCell className={cn(
|
||||
"text-center font-bold text-lg",
|
||||
isValid ? "text-green-600" : "text-destructive"
|
||||
)}>
|
||||
{totalPercent}%
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isValid ? (
|
||||
<span className="text-green-600 text-xs">✓ Hợp lệ</span>
|
||||
) : (
|
||||
<span className="text-destructive text-xs">Cần = 100%</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!isValid && tableParticipants.length > 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
⚠️ Tổng tỷ lệ đóng góp phải bằng đúng 100% để có thể gửi biểu mẫu.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section 3: Signature */}
|
||||
<section className="border-t border-border pt-6 space-y-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="digitalSignature"
|
||||
checked={formData.digitalSignatureConfirmed}
|
||||
disabled={readOnly}
|
||||
onCheckedChange={(checked) => {
|
||||
if (readOnly) return;
|
||||
const v = !!checked;
|
||||
if (v && draftCtx && contributionSignaturePrerequisiteGaps.length > 0) {
|
||||
toast.error('Chưa thể xác nhận cam kết', {
|
||||
description: formatApplicantPrerequisiteToastDescription(
|
||||
contributionSignaturePrerequisiteGaps,
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleInputChange('digitalSignatureConfirmed', v);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="digitalSignature" className="text-sm font-medium cursor-pointer">
|
||||
Tôi xin cam đoan mọi thông tin nêu trong đơn là trung thực, đúng sự thật và hoàn toàn chịu
|
||||
trách nhiệm trước pháp luật.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row justify-between items-start gap-8">
|
||||
<div className="text-center w-full md:w-1/2">
|
||||
<p className="italic text-muted-foreground mb-2">
|
||||
TP. Hồ Chí Minh, {format(formData.submissionDate, 'dd/MM/yyyy')}
|
||||
</p>
|
||||
<p className="font-bold uppercase mb-16 text-foreground">
|
||||
Tác giả chính / Đại diện nhóm tác giả sáng kiến
|
||||
</p>
|
||||
<div className="border-t border-border w-48 mx-auto pt-2">
|
||||
<p className="text-sm text-muted-foreground">(chữ ký và ghi rõ họ tên)</p>
|
||||
{formData.digitalSignatureConfirmed && formData.mainAuthor && (
|
||||
<p className="font-semibold text-primary mt-2">{formData.mainAuthor}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-4 border-t border-border pt-6">
|
||||
|
||||
<Button
|
||||
className="gap-2 shadow-lg"
|
||||
type="button"
|
||||
onClick={() => void handleSaveDraft()}
|
||||
disabled={readOnly || !isValid || !formData.digitalSignatureConfirmed || isSavingDraft}
|
||||
>
|
||||
<Save size={18} /> {isSavingDraft ? 'Đang lưu...' : 'Tiếp theo'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { UserMenu } from "@/components/UserMenu";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
|
||||
export function DashboardHeader() {
|
||||
return (
|
||||
<header className="flex h-16 absolute top-0 left-0 right-0 items-center gap-4 border-b border-border bg-card px-4">
|
||||
<SidebarTrigger />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Tìm kiếm..."
|
||||
className="pl-9 bg-background"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Users,
|
||||
Settings,
|
||||
BarChart3,
|
||||
FolderOpen,
|
||||
Bell,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
BookOpen,
|
||||
Shield,
|
||||
ScrollText
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PermissionGate } from "@/components/auth/PermissionGate";
|
||||
const LOGO_SRC = "/logo.svg";
|
||||
|
||||
const mainMenuItems = [
|
||||
{ title: "Quản trị duyệt hồ sơ", url: "/dashboard", icon: LayoutDashboard },
|
||||
// { title: "Curriculum", url: "/dashboard/curriculum", icon: BookOpen },
|
||||
// { title: "Analytics", url: "/dashboard/analytics", icon: BarChart3 },
|
||||
{ title: "Kết quả đăng ký", url: "/dashboard/documents", icon: FileText },
|
||||
{ title: "Tổng quan", url: "/dashboard/analytics", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const managementItems = [
|
||||
// { title: "Admin Panel", url: "/dashboard/admin", icon: Shield },
|
||||
{ title: "Nhật ký", url: "/dashboard/admin/audit", icon: ScrollText },
|
||||
{ title: "Users", url: "/dashboard/users", icon: Users },
|
||||
{ title: "Projects", url: "/dashboard/projects", icon: FolderOpen },
|
||||
];
|
||||
|
||||
const systemItems = [
|
||||
{ title: "Notifications", url: "/dashboard/notifications", icon: Bell },
|
||||
{ title: "Settings", url: "/dashboard/settings", icon: Settings },
|
||||
{ title: "Help", url: "/dashboard/help", icon: HelpCircle },
|
||||
];
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { state } = useSidebar();
|
||||
const { logout, hasPermission } = useAuth();
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
const isActive = (url: string) => location.pathname === url;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const MenuItem = ({ item }: { item: { title: string; url: string; icon: React.ComponentType<{ className?: string }> } }) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive(item.url)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="border-b border-sidebar-border p-4">
|
||||
<Link to="/dashboard" className="flex items-center gap-2">
|
||||
<img
|
||||
src={LOGO_SRC}
|
||||
alt="UMP Logo"
|
||||
className="h-8 w-8 object-contain flex-shrink-0"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span className="font-serif text-lg font-semibold text-sidebar-foreground">
|
||||
UMP
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Main</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mainMenuItems.map((item) => (
|
||||
<MenuItem key={item.title} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<PermissionGate permission="admin.access">
|
||||
<SidebarGroup>
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Management
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{managementItems.map((item) => (
|
||||
<MenuItem key={item.title} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
</PermissionGate>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{systemItems.map((item) => (
|
||||
<MenuItem key={item.title} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border p-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={handleLogout}
|
||||
tooltip="Sign Out"
|
||||
className="text-destructive hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { getAuthenticatedDashboardPath } from "@/lib/dashboardNavigation";
|
||||
import { UserMenu } from "@/components/UserMenu";
|
||||
const LOGO_SRC = "/logo.svg";
|
||||
|
||||
const Header = () => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
const { isAuthenticated, hasRole } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const shouldBeDark = savedTheme === "dark" || (!savedTheme && prefersDark);
|
||||
|
||||
setIsDark(shouldBeDark);
|
||||
if (shouldBeDark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const newTheme = !isDark;
|
||||
setIsDark(newTheme);
|
||||
|
||||
if (newTheme) {
|
||||
document.documentElement.classList.add("dark");
|
||||
localStorage.setItem("theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
localStorage.setItem("theme", "light");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 py-2 sm:py-4">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-14 sm:h-16 pill-nav px-4 sm:px-6">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center min-w-0">
|
||||
<a href="/" className="flex items-center gap-1.5 sm:gap-2">
|
||||
<img
|
||||
src={LOGO_SRC}
|
||||
alt="UMP Logo"
|
||||
className="w-8 h-8 sm:w-10 sm:h-10 object-contain flex-shrink-0"
|
||||
/>
|
||||
<span className="text-base sm:text-xl font-bold font-serif truncate">Đại Học Y Dược TP. Hồ Chí Minh</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-2">
|
||||
<a href="/" className="text-sm font-medium hover:bg-muted/60 rounded-full px-4 py-2 transition-all">
|
||||
Nghiên cứu
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:bg-muted/60 rounded-full px-4 py-2 transition-all">
|
||||
Hợp Tác
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:bg-muted/60 rounded-full px-4 py-2 transition-all">
|
||||
Sáng kiến
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:bg-muted/60 rounded-full px-4 py-2 transition-all">
|
||||
Y Đức
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:bg-muted/60 rounded-full px-4 py-2 transition-all">
|
||||
Thành viên
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
|
||||
{/* <button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 sm:p-2 rounded-full hover:bg-muted/60 transition-all"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<UserMenu />
|
||||
) : (
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/login')}
|
||||
className="rounded-full px-5 text-foreground hover:bg-muted/80"
|
||||
>
|
||||
Đăng nhập
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => navigate('/register')}
|
||||
className="rounded-full px-6 bg-primary hover:bg-primary/90 text-primary-foreground hover:scale-105 transition-all"
|
||||
>
|
||||
Đăng ký
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-1.5 sm:p-2"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isMenuOpen ? <X className="h-5 w-5 sm:h-6 sm:w-6" /> : <Menu className="h-5 w-5 sm:h-6 sm:w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-border animate-fade-in">
|
||||
<nav className="flex flex-col gap-4">
|
||||
<a href="/" className="text-sm font-medium hover:text-accent transition-colors">
|
||||
Nghiên cứu
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:text-accent transition-colors">
|
||||
Hợp Tác
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:text-accent transition-colors">
|
||||
Sáng kiến
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:text-accent transition-colors">
|
||||
Y Đức
|
||||
</a>
|
||||
<a href="/" className="text-sm font-medium hover:text-accent transition-colors">
|
||||
Thành viên
|
||||
</a>
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
onClick={() => navigate(getAuthenticatedDashboardPath(hasRole))}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full w-full"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 w-full pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
navigate('/login');
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="rounded-full w-full"
|
||||
>
|
||||
Đăng nhập
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate('/register');
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground rounded-full w-full"
|
||||
>
|
||||
Đăng ký
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Instagram, Facebook, Linkedin } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { getAuthenticatedDashboardPath } from "@/lib/dashboardNavigation";
|
||||
|
||||
const HeroSection = () => {
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, hasRole } = useAuth();
|
||||
return (
|
||||
<section className="relative rounded-[2.5rem] overflow-hidden bg-muted my-12 animate-fade-in">
|
||||
<div className="grid md:grid-cols-2 gap-6 md:gap-12 p-6 md:p-12 lg:p-16">
|
||||
{/* Left side - Image */}
|
||||
<div className="relative aspect-[4/3] md:aspect-auto rounded-[2rem] overflow-hidden animate-scale-in">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1497032628192-86f99bcd76bc?w=1920&q=80"
|
||||
alt="Hero"
|
||||
className="w-full h-full object-cover transition-transform duration-700 hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right side - Content */}
|
||||
<div className="flex flex-col justify-center space-y-6 md:space-y-8">
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<h1 className="text-4xl md:text-5xl lg:text-7xl font-bold leading-[1.1] tracking-tight animate-slide-down">
|
||||
Hệ Thống <br />
|
||||
Quản lý <br />
|
||||
Sáng Kiến
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg md:text-xl leading-relaxed max-w-xl animate-slide-up stagger-1">
|
||||
Vì mục tiêu quản lý hiệu quả nguồn tài nguyên quý giá,
|
||||
tạo điều kiện cho các đơn vị, cá nhân tra cứu, học hỏi và khai thác.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3 sm:gap-4 md:gap-6 pt-4 animate-slide-up stagger-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<a
|
||||
href="#instagram"
|
||||
className="w-12 h-12 rounded-full border-2 border-border hover:border-primary hover:bg-muted transition-all flex items-center justify-center hover:scale-110"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<Instagram className="w-5 h-5" />
|
||||
</a>
|
||||
<a
|
||||
href="#facebook"
|
||||
className="w-12 h-12 rounded-full border-2 border-border hover:border-primary hover:bg-muted transition-all flex items-center justify-center hover:scale-110"
|
||||
aria-label="Facebook"
|
||||
>
|
||||
<Facebook className="w-5 h-5" />
|
||||
</a>
|
||||
<a
|
||||
href="#linkedin"
|
||||
className="w-12 h-12 rounded-full border-2 border-border hover:border-primary hover:bg-muted transition-all flex items-center justify-center hover:scale-110"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<Linkedin className="w-5 h-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Component-level E2E: real DraftProvider + per-keystroke `updateApplication` must not trigger
|
||||
* React "Maximum update depth" or other console.error spam (regression guard for autosave/sync effects).
|
||||
*
|
||||
* Paste and one-shot fills differ because they apply fewer intermediate draft snapshots than typing.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import InitiativeApplicationForm from "@/components/InitiativeApplicationForm";
|
||||
import { DraftProvider } from "@/components/applicant/initiative-draft/DraftContext";
|
||||
import { LS_KEY } from "@/components/applicant/initiative-draft/types";
|
||||
|
||||
describe("InitiativeApplicationForm + DraftProvider (typing hygiene)", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.removeItem(LS_KEY);
|
||||
});
|
||||
|
||||
it("keyboard typing into section-4 textarea and initiative name yields no Maximum update depth / error spam", async () => {
|
||||
const user = userEvent.setup({ delay: 4 });
|
||||
const errorSpy = vi.spyOn(console, "error");
|
||||
|
||||
render(
|
||||
<DraftProvider preferLocalSnapshot={false}>
|
||||
<InitiativeApplicationForm readOnly={false} autoSaveDebounceMs={undefined} />
|
||||
</DraftProvider>,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Nhập tên sáng kiến...");
|
||||
const summary = document.querySelector("textarea.min-h-32");
|
||||
expect(summary).not.toBeNull();
|
||||
|
||||
await user.type(nameInput, "Đánh máy ");
|
||||
await user.type(summary!, "Mô tả từng ký tự ");
|
||||
|
||||
function isReactDepthPanic(args: unknown[]) {
|
||||
return args.some(
|
||||
(a) =>
|
||||
typeof a === "string" &&
|
||||
(/Maximum update depth exceeded/i.test(a) || /Too many re-renders/i.test(a)),
|
||||
);
|
||||
}
|
||||
|
||||
expect(errorSpy.mock.calls.some(isReactDepthPanic)).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
/** Barrel: implementation lives under `council/evaluation` (Hội đồng Phiếu đánh giá). */
|
||||
export { default } from "./council/evaluation/InitiativeEvaluationForm";
|
||||
export type {
|
||||
AdminEvaluationReviewContext,
|
||||
CouncilEvaluationReviewContext,
|
||||
} from "./council/evaluation/InitiativeEvaluationForm";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
const IntroSection = () => {
|
||||
return (
|
||||
<section className="max-w-4xl mx-auto py-12 md:py-16 px-4 animate-fade-in">
|
||||
<div className="text-center space-y-6">
|
||||
<h2 className="text-3xl md:text-4xl font-bold leading-tight animate-slide-up">
|
||||
Đây là nơi bạn tìm kiếm cảm hứng và khám phá những cách nhìn mới về thế giới.
|
||||
</h2>
|
||||
<p className="text-lg text-muted-foreground leading-relaxed max-w-3xl mx-auto animate-slide-up stagger-1">
|
||||
Trong kỷ nguyên Trí Tuệ Nhân Tạo, các giải pháp sáng tạo và đổi mới là yếu tố chính để đạt được sự thành công và tạo ra giá trị.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntroSection;
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { X, Mail, Lock, User, Eye, EyeOff } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
interface SignUpModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const signUpSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required").max(100, "Name must be less than 100 characters"),
|
||||
email: z.string().trim().email("Please enter a valid email").max(255, "Email must be less than 255 characters"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters").max(128, "Password must be less than 128 characters"),
|
||||
});
|
||||
|
||||
const SignUpModal = ({ isOpen, onClose }: SignUpModalProps) => {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<{ name?: string; email?: string; password?: string }>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErrors({});
|
||||
|
||||
const result = signUpSchema.safeParse({ name, email, password });
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors: { name?: string; email?: string; password?: string } = {};
|
||||
result.error.errors.forEach((err) => {
|
||||
const field = err.path[0] as string;
|
||||
fieldErrors[field as keyof typeof fieldErrors] = err.message;
|
||||
});
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate signup - replace with actual auth when backend is connected
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
toast({
|
||||
title: "Welcome to Perspective!",
|
||||
description: "Your account has been created successfully.",
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
setName("");
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
onClose();
|
||||
navigate("/dashboard");
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm animate-fade-in"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="relative w-full max-w-md mx-4 bg-card border border-border rounded-3xl shadow-2xl animate-scale-in">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 rounded-full hover:bg-muted/60 transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div className="p-8 pt-12">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-primary-foreground font-bold text-xl">P</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold font-serif mb-2">Join Perspective</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create an account to save articles and get personalized recommendations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-sm font-medium">
|
||||
Full Name
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Enter your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={`pl-11 h-12 rounded-xl border-border bg-background ${errors.name ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.name && <p className="text-destructive text-xs">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">
|
||||
Email Address
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`pl-11 h-12 rounded-xl border-border bg-background ${errors.email ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && <p className="text-destructive text-xs">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Create a password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className={`pl-11 pr-11 h-12 rounded-xl border-border bg-background ${errors.password ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-destructive text-xs">{errors.password}</p>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-medium transition-all hover:scale-[1.02]"
|
||||
>
|
||||
{isLoading ? "Creating account..." : "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-sm text-muted-foreground mt-6">
|
||||
Already have an account?{" "}
|
||||
<button className="text-primary font-medium hover:underline">
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground mt-4">
|
||||
By signing up, you agree to our{" "}
|
||||
<a href="/terms" className="underline hover:text-foreground">Terms</a> and{" "}
|
||||
<a href="/privacy" className="underline hover:text-foreground">Privacy Policy</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpModal;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LogOut, Shield, LayoutDashboard, FileText } from 'lucide-react';
|
||||
import { getRoleDisplayName, Role } from '@/lib/permissions';
|
||||
import { getAuthenticatedDashboardPath } from '@/lib/dashboardNavigation';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PermissionGate } from './auth/PermissionGate';
|
||||
|
||||
export function UserMenu() {
|
||||
const { user, logout, isDemoMode, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => navigate('/login')}>
|
||||
Đăng nhập
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => navigate('/register')}>
|
||||
Đăng ký
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initials = user.name
|
||||
? user.name.split(' ').map((n) => n[0]).join('').toUpperCase().slice(0, 2)
|
||||
: user.email.slice(0, 2).toUpperCase();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 gap-2 px-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-primary text-primary-foreground text-xs">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start text-left hidden sm:flex">
|
||||
{/* <span className="text-sm font-medium">{user.name || user.email}</span> */}
|
||||
<div className="flex items-center gap-1">
|
||||
{user.roles.slice(0, 1).map((role) => (
|
||||
<Badge key={role} variant="secondary" className="text-xs py-0 h-5">
|
||||
{getRoleDisplayName(role as Role)}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>
|
||||
<div className="flex flex-col">
|
||||
<span>{user.name || 'Người dùng'}</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Vai trò: {user.roles.map((r) => getRoleDisplayName(r as Role)).join(', ')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{getAuthenticatedDashboardPath((r) => user.roles.includes(r)) === '/dashboard/documents' && (
|
||||
<DropdownMenuItem onClick={() => navigate('/dashboard/documents')}>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Lịch sử đăng ký
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<PermissionGate permission="admin.access">
|
||||
<DropdownMenuItem onClick={() => navigate('/dashboard')}>
|
||||
<LayoutDashboard className="h-4 w-4 mr-2" />
|
||||
Quản trị duyệt hồ sơ
|
||||
</DropdownMenuItem>
|
||||
</PermissionGate>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Đăng xuất
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* AI Management Tab
|
||||
* Allows administrators to manage AI models, settings, and configurations
|
||||
*/
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useVisibilityAwareRefetchInterval } from "@/hooks/useVisibilityAwareRefetchInterval";
|
||||
import { POLL_INTERVALS } from "@/shared/config/polling";
|
||||
import { apiClient } from "@/shared/api/client";
|
||||
import { Bot, RefreshCw, Settings, CheckCircle2, AlertCircle, Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface HealthStatus {
|
||||
ollama: {
|
||||
status: string;
|
||||
available_models: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface ModelMetadata {
|
||||
name: string;
|
||||
capabilities: string[];
|
||||
role: string;
|
||||
todoInstructions: string;
|
||||
prohibitionInstructions: string;
|
||||
}
|
||||
|
||||
// Model metadata definitions
|
||||
const MODEL_METADATA: Record<string, ModelMetadata> = {
|
||||
"qwen2.5:3b": {
|
||||
name: "Qwen 2.5 3B",
|
||||
capabilities: [
|
||||
"Hỗ trợ đa ngôn ngữ (Tiếng Việt, Tiếng Anh, và nhiều ngôn ngữ khác)",
|
||||
"Hiểu và trả lời câu hỏi về chính sách và tuân thủ",
|
||||
"Phân tích và xác minh nội dung theo tiêu chuẩn ISO 27001, NIST, GDPR",
|
||||
"Hướng dẫn về quy trình workflow và yêu cầu",
|
||||
"Xử lý ngôn ngữ tự nhiên với độ chính xác cao",
|
||||
"Tối ưu hóa cho hiệu suất với 3B tham số",
|
||||
],
|
||||
role: "Bạn là một trợ lý tuân thủ và chính sách hữu ích. Vai trò của bạn là trả lời câu hỏi về quản trị CNTT, chính sách tuân thủ và yêu cầu quy định. Cung cấp hướng dẫn về ISO 27001, NIST, GDPR và các khung tuân thủ khác. Giúp người dùng hiểu quy trình workflow và yêu cầu. Xác minh nội dung theo tiêu chuẩn tuân thủ. Luôn cung cấp lời khuyên rõ ràng, có thể hành động. Nếu bạn không chắc chắn về điều gì đó, hãy nói như vậy thay vì đoán.",
|
||||
todoInstructions: "",
|
||||
prohibitionInstructions: "",
|
||||
},
|
||||
"qwen2.5:4b": {
|
||||
name: "Qwen 2.5 4B",
|
||||
capabilities: [
|
||||
"Hỗ trợ đa ngôn ngữ với hiệu suất cao hơn",
|
||||
"Khả năng xử lý ngữ cảnh phức tạp hơn",
|
||||
"Phân tích sâu hơn về các yêu cầu tuân thủ",
|
||||
"Tối ưu hóa cho các tác vụ chuyên sâu",
|
||||
],
|
||||
role: "Bạn là một trợ lý tuân thủ và chính sách hữu ích với khả năng xử lý các tác vụ phức tạp hơn.",
|
||||
todoInstructions: "",
|
||||
prohibitionInstructions: "",
|
||||
},
|
||||
"gemma3:270M": {
|
||||
name: "Gemma 3 270M",
|
||||
capabilities: [
|
||||
"Mô hình nhẹ, nhanh chóng",
|
||||
"Phù hợp cho các tác vụ đơn giản",
|
||||
"Hỗ trợ tiếng Anh chính",
|
||||
],
|
||||
role: "Bạn là một trợ lý AI nhẹ và nhanh chóng cho các tác vụ cơ bản.",
|
||||
todoInstructions: "",
|
||||
prohibitionInstructions: "",
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "ai_model_config";
|
||||
|
||||
interface ModelConfig {
|
||||
model: string;
|
||||
todoInstructions: string;
|
||||
prohibitionInstructions: string;
|
||||
}
|
||||
|
||||
export function AIManagementTab() {
|
||||
const [selectedModel, setSelectedModel] = useState<string>("qwen2.5:3b");
|
||||
const [todoInstructions, setTodoInstructions] = useState<string>("");
|
||||
const [prohibitionInstructions, setProhibitionInstructions] = useState<string>("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Load saved configuration
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const config: ModelConfig = JSON.parse(saved);
|
||||
if (config.model) setSelectedModel(config.model);
|
||||
if (config.todoInstructions) setTodoInstructions(config.todoInstructions);
|
||||
if (config.prohibitionInstructions) setProhibitionInstructions(config.prohibitionInstructions);
|
||||
} catch (error) {
|
||||
console.error("Error loading AI config:", error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update instructions when model changes
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const config: ModelConfig = JSON.parse(saved);
|
||||
if (config.model === selectedModel) {
|
||||
setTodoInstructions(config.todoInstructions || "");
|
||||
setProhibitionInstructions(config.prohibitionInstructions || "");
|
||||
} else {
|
||||
// Load default for new model
|
||||
const metadata = MODEL_METADATA[selectedModel];
|
||||
if (metadata) {
|
||||
setTodoInstructions(metadata.todoInstructions || "");
|
||||
setProhibitionInstructions(metadata.prohibitionInstructions || "");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading model config:", error);
|
||||
}
|
||||
} else {
|
||||
// Load defaults
|
||||
const metadata = MODEL_METADATA[selectedModel];
|
||||
if (metadata) {
|
||||
setTodoInstructions(metadata.todoInstructions || "");
|
||||
setProhibitionInstructions(metadata.prohibitionInstructions || "");
|
||||
}
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Fetch health status to get available models
|
||||
const healthPoll = useVisibilityAwareRefetchInterval(POLL_INTERVALS.adminAiHealth);
|
||||
const { data: health, isLoading: healthLoading } = useQuery<HealthStatus>({
|
||||
queryKey: ['admin', 'health'],
|
||||
queryFn: async () => {
|
||||
return apiClient.get('/health');
|
||||
},
|
||||
staleTime: 30_000,
|
||||
refetchInterval: healthPoll,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
// Save configuration
|
||||
const saveConfigMutation = useMutation({
|
||||
mutationFn: async (config: ModelConfig) => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
|
||||
return config;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Đã lưu cấu hình mô hình AI!");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Lỗi lưu cấu hình: ${error.message || "Không thể lưu"}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Test model endpoint
|
||||
const testModelMutation = useMutation({
|
||||
mutationFn: async (modelName: string) => {
|
||||
return apiClient.post('/test_ollama', {
|
||||
prompt: "Xin chào, bạn có thể trả lời bằng tiếng Việt không?",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Mô hình hoạt động tốt!");
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Lỗi kiểm tra mô hình: ${error.message || "Không thể kết nối"}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleTestModel = () => {
|
||||
testModelMutation.mutate(selectedModel);
|
||||
};
|
||||
|
||||
const handleRefreshModels = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'health'] });
|
||||
toast.info("Đang làm mới danh sách mô hình...");
|
||||
};
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
saveConfigMutation.mutate({
|
||||
model: selectedModel,
|
||||
todoInstructions,
|
||||
prohibitionInstructions,
|
||||
});
|
||||
};
|
||||
|
||||
const availableModels = health?.ollama?.available_models || [];
|
||||
const isOllamaConnected = health?.ollama?.status === "connected";
|
||||
const currentModelMetadata = MODEL_METADATA[selectedModel] || MODEL_METADATA["qwen2.5:3b"];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Model Selection and Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5" />
|
||||
Cấu hình Mô hình AI
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Chọn và cấu hình mô hình AI cho hệ thống
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Ollama Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Trạng thái Ollama</Label>
|
||||
{healthLoading ? (
|
||||
<Skeleton className="h-6 w-32" />
|
||||
) : (
|
||||
<Badge variant={isOllamaConnected ? "default" : "destructive"} className="mt-1">
|
||||
{isOllamaConnected ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Đã kết nối
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Không kết nối
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefreshModels}
|
||||
disabled={healthLoading}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${healthLoading ? "animate-spin" : ""}`} />
|
||||
Làm mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Chọn Mô hình AI</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Chọn mô hình" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.length > 0 ? (
|
||||
availableModels.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="none" disabled>
|
||||
Không có mô hình
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Model Description - Capabilities */}
|
||||
<div className="space-y-2">
|
||||
<Label>Khả năng của Mô hình</Label>
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-4">
|
||||
{currentModelMetadata?.capabilities && currentModelMetadata.capabilities.length > 0 ? (
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{currentModelMetadata.capabilities.map((capability, index) => (
|
||||
<li key={index} className="flex items-start gap-2">
|
||||
<span className="text-primary mt-1.5">•</span>
|
||||
<span>{capability}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Không có thông tin về khả năng cho mô hình này.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Model Role */}
|
||||
<div className="space-y-2">
|
||||
<Label>Vai trò của Mô hình AI</Label>
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-4">
|
||||
{currentModelMetadata?.role ? (
|
||||
<p className="text-sm whitespace-pre-wrap">{currentModelMetadata.role}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Không có thông tin về vai trò cho mô hình này.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* To-Do Instructions */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="todo-instructions">Hướng dẫn Nhiệm vụ (To-Do Instructions)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Các hướng dẫn về những việc mô hình AI nên làm. Ví dụ: "Luôn trả lời bằng tiếng Việt", "Cung cấp ví dụ cụ thể khi giải thích"
|
||||
</p>
|
||||
<Textarea
|
||||
id="todo-instructions"
|
||||
placeholder="Nhập các hướng dẫn về nhiệm vụ mà mô hình AI nên thực hiện..."
|
||||
value={todoInstructions}
|
||||
onChange={(e) => setTodoInstructions(e.target.value)}
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Prohibition Instructions */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="prohibition-instructions">Hướng dẫn Cấm (Prohibition Instructions)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Các hướng dẫn về những việc mô hình AI không được làm. Ví dụ: "Không được tiết lộ thông tin cá nhân", "Không được đưa ra lời khuyên pháp lý"
|
||||
</p>
|
||||
<Textarea
|
||||
id="prohibition-instructions"
|
||||
placeholder="Nhập các hướng dẫn về những việc mô hình AI không được thực hiện..."
|
||||
value={prohibitionInstructions}
|
||||
onChange={(e) => setProhibitionInstructions(e.target.value)}
|
||||
rows={4}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={saveConfigMutation.isPending}
|
||||
className="flex-1"
|
||||
>
|
||||
{saveConfigMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Đang lưu...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Lưu Cấu hình
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleTestModel}
|
||||
disabled={!isOllamaConnected || testModelMutation.isPending}
|
||||
variant="outline"
|
||||
>
|
||||
{testModelMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Đang kiểm tra...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Kiểm tra
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Available Models List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mô hình có sẵn</CardTitle>
|
||||
<CardDescription>
|
||||
Danh sách tất cả các mô hình Ollama đã được tải trên hệ thống
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{healthLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : availableModels.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{availableModels.map((model, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{model}</span>
|
||||
{model === selectedModel && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Đang sử dụng
|
||||
</Badge>
|
||||
)}
|
||||
{MODEL_METADATA[model] && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{MODEL_METADATA[model].name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Không có mô hình nào được tải</p>
|
||||
<p className="text-xs mt-1">
|
||||
Vui lòng kiểm tra kết nối Ollama hoặc tải mô hình mới
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import type { Person } from "@/data/mockApplications";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { downloadApplicationBackupArchive } from "@/lib/applicationBackupDownload";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = {
|
||||
applicationId: string;
|
||||
author?: Pick<Person, "email" | "name"> | null;
|
||||
/** Full label (e.g. review header); default compact icon in tables */
|
||||
variant?: "full" | "compact";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ApplicationBackupDownloadButton({
|
||||
applicationId,
|
||||
author,
|
||||
variant = "full",
|
||||
className,
|
||||
}: Props) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = useCallback(async () => {
|
||||
if (!applicationId.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await downloadApplicationBackupArchive(applicationId, author);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [applicationId, author]);
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={cn("h-8 w-8 shrink-0", className)}
|
||||
disabled={busy}
|
||||
onClick={() => void run()}
|
||||
aria-label="Tải bản sao lưu ZIP"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Tải bản sao lưu đầy đủ (ZIP)</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("gap-1.5", className)}
|
||||
disabled={busy}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
{busy ? "Đang đóng gói…" : "Tải bản sao lưu"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowUpDown, Archive, Loader2 } from "lucide-react";
|
||||
import { apiClient, detailFromApiError } from "@/shared/api/client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import SearchableSelect, { type SearchableOption } from "@/components/admin/SearchableSelect";
|
||||
import { ViewSubmittedApplicationButton } from "@/components/admin/ViewSubmittedApplicationButton";
|
||||
import { DEPARTMENT_OPTIONS, GROUP_OPTIONS, STATUS_OPTIONS, ALL_OPTION_VALUE } from "@/components/admin/SelectOptions";
|
||||
import type { ApplicationItem, ApplicationStatus } from "@/data/mockApplications";
|
||||
import { useVisibilityAwareRefetchInterval } from "@/hooks/useVisibilityAwareRefetchInterval";
|
||||
import { POLL_INTERVALS } from "@/shared/config/polling";
|
||||
import { normalizeApplicationItem } from "@/lib/applicationReviewApi";
|
||||
import { getApplicationMeritCategoryHint } from "@/components/admin/review/applicationMeritCategoryHint";
|
||||
import type { ResearchEvidenceKind, InitiativeClassification, ApplicationFormState } from "@/components/applicant/initiativeFormTypes";
|
||||
import { EvidenceFilesTableCell } from "@/components/evidence/EvidenceFilesTableCell";
|
||||
import { ApplicationBackupDownloadButton } from "@/components/admin/ApplicationBackupDownloadButton";
|
||||
import { toast } from "sonner";
|
||||
import { formatIsoToDdMmYyyy, formatIsoToDdMmYyyyHhMm } from "@/lib/vnDateFormat";
|
||||
|
||||
type SortOrder = "asc" | "desc";
|
||||
|
||||
interface ApplicationResponse {
|
||||
data: ApplicationItem[];
|
||||
pagination: { page: number; pageSize: number; totalItems: number; totalPages: number };
|
||||
statusCounts?: { approved: number; rejected: number };
|
||||
}
|
||||
|
||||
interface ApplicationReportFile {
|
||||
fileName: string;
|
||||
publicUrl: string;
|
||||
sizeBytes: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
/** API: `inbox` = chờ xử lý (loại đã/hủy); `decided` = chỉ đã duyệt hoặc từ chối */
|
||||
lifecycle: string;
|
||||
name: string;
|
||||
subjectId: string;
|
||||
conferenceId: string;
|
||||
status: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
authorName: string;
|
||||
reviewerName: string;
|
||||
supervisorId: string;
|
||||
reviewStatus: string;
|
||||
topicTypeId: string;
|
||||
boardId: string;
|
||||
sortBy: string;
|
||||
sortOrder: SortOrder;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
lifecycle: "",
|
||||
name: "",
|
||||
subjectId: "",
|
||||
conferenceId: "",
|
||||
status: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
authorName: "",
|
||||
reviewerName: "",
|
||||
supervisorId: "",
|
||||
reviewStatus: "",
|
||||
topicTypeId: "",
|
||||
boardId: "",
|
||||
sortBy: "submittedDate",
|
||||
sortOrder: "desc",
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
export type AdminApplicationsLifecycle = "inbox" | "decided";
|
||||
|
||||
export interface AdminApplicationsListProps {
|
||||
lifecycle?: AdminApplicationsLifecycle;
|
||||
/** Tiêu đề khối lọc (CardTitle) */
|
||||
filterCardTitle?: string;
|
||||
/** Tiêu đề phần kết quả / bảng */
|
||||
resultsSectionTitle?: string;
|
||||
/** Hiện nút Duyệt / Từ chối (chỉ hợp lý khi chờ duyệt) */
|
||||
showReviewActionButtons?: boolean;
|
||||
/** `returnTo` cho nút Xem hồ sơ */
|
||||
viewSubmittedReturnPath?: string;
|
||||
/** Cột tải ZIP sao lưu (trang Dự án / Projects) */
|
||||
showBackupDownloadColumn?: boolean;
|
||||
}
|
||||
|
||||
/** Filter stores YYYY-MM-DD (API); UI shows dd/MM/yyyy */
|
||||
function isoDateToDdMmYyyy(iso: string) {
|
||||
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return "";
|
||||
const [y, m, d] = iso.split("-");
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function parseDdMmYyyyToIso(s: string): string {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return "";
|
||||
const match = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
|
||||
if (!match) return "";
|
||||
const dd = Number(match[1]);
|
||||
const mm = Number(match[2]);
|
||||
const yyyy = Number(match[3]);
|
||||
if (mm < 1 || mm > 12 || dd < 1 || dd > 31) return "";
|
||||
const dt = new Date(yyyy, mm - 1, dd);
|
||||
if (dt.getFullYear() !== yyyy || dt.getMonth() !== mm - 1 || dt.getDate() !== dd) return "";
|
||||
return `${yyyy}-${String(mm).padStart(2, "0")}-${String(dd).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null | undefined, withTime = false) {
|
||||
if (!iso) return "";
|
||||
if (withTime) return formatIsoToDdMmYyyyHhMm(iso);
|
||||
return formatIsoToDdMmYyyy(iso);
|
||||
}
|
||||
|
||||
function optionLabel(options: ReadonlyArray<{ value: string; label: string }>, value?: string | null) {
|
||||
if (value == null || value === "") return "";
|
||||
return options.find((o) => o.value === value)?.label ?? "";
|
||||
}
|
||||
|
||||
/** Đã duyệt: nhãn theo gợi ý nhóm Đơn (2.1.4 → Trung bình). */
|
||||
function approvedMeritBadgeLabel(item: ApplicationItem): "Xuất sắc" | "Khá" | "Trung bình" {
|
||||
const hint = getApplicationMeritCategoryHint({
|
||||
initiativeClassification: (item.initiativeClassification ?? null) as InitiativeClassification,
|
||||
researchEvidenceKind: (item.researchEvidenceKind ?? "") as ResearchEvidenceKind,
|
||||
textbookEvidenceKind: (item.textbookEvidenceKind ?? "") as ApplicationFormState["textbookEvidenceKind"],
|
||||
});
|
||||
if (hint.meritLevel === "xuất sắc") return "Xuất sắc";
|
||||
if (hint.meritLevel === "trung bình") return "Trung bình";
|
||||
if (hint.meritLevel === "khá") return "Khá";
|
||||
if (hint.subgroupCode === "2.1.1" || hint.subgroupCode === "2.1.2" || hint.subgroupCode === "2.2.1") return "Xuất sắc";
|
||||
return "Khá";
|
||||
}
|
||||
|
||||
function statusBadge(status: ApplicationStatus, item?: ApplicationItem) {
|
||||
const label = optionLabel(STATUS_OPTIONS, status) || String(status);
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return <Badge className="bg-amber-500 hover:bg-amber-500/90">{label}</Badge>;
|
||||
case "approved":
|
||||
return (
|
||||
<Badge className="bg-green-600 hover:bg-green-600/90">
|
||||
{item ? approvedMeritBadgeLabel(item) : label}
|
||||
</Badge>
|
||||
);
|
||||
case "rejected":
|
||||
return <Badge variant="destructive">{label}</Badge>;
|
||||
case "transferred":
|
||||
return <Badge className="bg-blue-600 hover:bg-blue-600/90">{label}</Badge>;
|
||||
case "under_review":
|
||||
return <Badge variant="secondary">{label}</Badge>;
|
||||
case "reviewed":
|
||||
return <Badge variant="outline" className="border-green-500 text-green-700">{label}</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">-</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function toSearchParams(filters: FilterState) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
const normalized = String(value ?? "");
|
||||
const defaultValue = String((DEFAULT_FILTERS as any)[key] ?? "");
|
||||
if (normalized && normalized !== defaultValue) {
|
||||
params.set(key, normalized);
|
||||
}
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
function toOptionList(input: unknown): SearchableOption[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input
|
||||
.map((item: any) => {
|
||||
const value = String(item?.id ?? item?.value ?? "");
|
||||
const label = String(item?.name ?? item?.fullName ?? item?.label ?? "");
|
||||
if (!value || !label) return null;
|
||||
return { value, label };
|
||||
})
|
||||
.filter(Boolean) as SearchableOption[];
|
||||
}
|
||||
|
||||
function getNestedData<T = unknown>(raw: any): T {
|
||||
if (Array.isArray(raw)) return raw as T;
|
||||
if (raw?.data) return raw.data as T;
|
||||
return raw as T;
|
||||
}
|
||||
|
||||
function useLookupQuery(path: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard-lookup", path],
|
||||
queryFn: async () => {
|
||||
const data = await apiClient.get(path);
|
||||
return toOptionList(getNestedData(data));
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export default function ApprovedApplicationsList({
|
||||
lifecycle,
|
||||
filterCardTitle,
|
||||
resultsSectionTitle,
|
||||
showReviewActionButtons = true,
|
||||
viewSubmittedReturnPath = "/dashboard",
|
||||
showBackupDownloadColumn = false,
|
||||
}: AdminApplicationsListProps = {}) {
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const lc = lifecycle ?? "";
|
||||
const [filters, setFilters] = useState<FilterState>(() => ({ ...DEFAULT_FILTERS, lifecycle: lc }));
|
||||
const [nameInput, setNameInput] = useState(filters.name);
|
||||
const [authorInput, setAuthorInput] = useState(filters.authorName);
|
||||
const [reviewerInput, setReviewerInput] = useState(filters.reviewerName);
|
||||
const [dateFromText, setDateFromText] = useState(() => isoDateToDdMmYyyy(filters.dateFrom));
|
||||
const [statusOverrides, setStatusOverrides] = useState<Record<string, ApplicationStatus>>({});
|
||||
const [isExportingExcel, setIsExportingExcel] = useState(false);
|
||||
const [isExportingBackups, setIsExportingBackups] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setFilters((prev) => ({ ...prev, name: nameInput, page: 1 }));
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [nameInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setFilters((prev) => ({ ...prev, authorName: authorInput, page: 1 }));
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [authorInput]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setFilters((prev) => ({ ...prev, reviewerName: reviewerInput, page: 1 }));
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [reviewerInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setDateFromText(isoDateToDdMmYyyy(filters.dateFrom));
|
||||
}, [filters.dateFrom]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchParams(toSearchParams(filters), { replace: true });
|
||||
}, [filters, setSearchParams]);
|
||||
|
||||
const conferencesQuery = useLookupQuery("/api/conferences");
|
||||
const supervisorsQuery = useLookupQuery("/api/supervisors");
|
||||
const applicationsRefetchInterval = useVisibilityAwareRefetchInterval(
|
||||
POLL_INTERVALS.adminApplicationsList,
|
||||
);
|
||||
|
||||
const reportFilesQuery = useQuery({
|
||||
queryKey: ["application-report-files"],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await apiClient.get<{ files: ApplicationReportFile[] }>("/api/v1/application-reports");
|
||||
} catch {
|
||||
const response = await fetch("/application-report-index.json", { cache: "no-store" });
|
||||
if (!response.ok) return { files: [] };
|
||||
const files = (await response.json()) as ApplicationReportFile[];
|
||||
return { files: Array.isArray(files) ? files : [] };
|
||||
}
|
||||
},
|
||||
staleTime: 30 * 1000,
|
||||
});
|
||||
|
||||
const applicationsQuery = useQuery({
|
||||
queryKey: ["applications", filters],
|
||||
queryFn: async (): Promise<ApplicationResponse> => {
|
||||
const res = await apiClient.get<ApplicationResponse>("/api/applications", { params: filters });
|
||||
return {
|
||||
...res,
|
||||
data: (res.data ?? []).map((row) =>
|
||||
normalizeApplicationItem(
|
||||
row as ApplicationItem & {
|
||||
draft_case_id?: string;
|
||||
du_va_dung?: string | null;
|
||||
nhan_xet?: string | null;
|
||||
initiative_classification?: ApplicationItem["initiativeClassification"];
|
||||
research_evidence_kind?: ApplicationItem["researchEvidenceKind"];
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
placeholderData: (previous) => previous,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: applicationsRefetchInterval,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnReconnect: true,
|
||||
});
|
||||
|
||||
const data = applicationsQuery.data?.data ?? [];
|
||||
const pagination = applicationsQuery.data?.pagination ?? { page: 1, pageSize: filters.pageSize, totalItems: 0, totalPages: 1 };
|
||||
const statusCounts = applicationsQuery.data?.statusCounts;
|
||||
const showStatusBreakdown =
|
||||
statusCounts != null &&
|
||||
(lifecycle === "decided" || statusCounts.approved + statusCounts.rejected > 0);
|
||||
|
||||
const setFilter = (key: keyof FilterState, value: string | number) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value, page: key === "page" || key === "pageSize" ? Number(value) : 1 }));
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setNameInput("");
|
||||
setAuthorInput("");
|
||||
setReviewerInput("");
|
||||
setDateFromText("");
|
||||
setFilters({ ...DEFAULT_FILTERS, lifecycle: lc });
|
||||
};
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
if (lifecycle === "inbox") {
|
||||
return STATUS_OPTIONS.filter((o) => o.value !== "approved" && o.value !== "rejected");
|
||||
}
|
||||
if (lifecycle === "decided") {
|
||||
return STATUS_OPTIONS.filter((o) => o.value === "approved" || o.value === "rejected");
|
||||
}
|
||||
return STATUS_OPTIONS;
|
||||
}, [lifecycle]);
|
||||
|
||||
const filterCardTitleResolved =
|
||||
filterCardTitle ??
|
||||
(lifecycle === "inbox"
|
||||
? "Hồ sơ chờ duyệt"
|
||||
: lifecycle === "decided"
|
||||
? "Kết quả đã duyệt / từ chối"
|
||||
: "Quản trị duyệt hồ sơ sáng kiến");
|
||||
|
||||
const resultsSectionTitleResolved =
|
||||
resultsSectionTitle ??
|
||||
(lifecycle === "inbox"
|
||||
? "Danh sách chờ duyệt"
|
||||
: lifecycle === "decided"
|
||||
? "Danh sách đã có kết luận"
|
||||
: "Kết quả tìm");
|
||||
|
||||
const toggleSort = (sortBy: string) => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sortBy,
|
||||
sortOrder: prev.sortBy === sortBy && prev.sortOrder === "desc" ? "asc" : "desc",
|
||||
page: 1,
|
||||
}));
|
||||
};
|
||||
|
||||
const exportExcel = async () => {
|
||||
setIsExportingExcel(true);
|
||||
try {
|
||||
const blobData = await apiClient.get<Blob>("/api/applications/export", {
|
||||
params: filters,
|
||||
responseType: "blob",
|
||||
} as any);
|
||||
const blob = new Blob([blobData], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "applications-export.xlsx";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setIsExportingExcel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportAllBackupsZip = async () => {
|
||||
if (isExportingBackups || isExportingExcel) return;
|
||||
setIsExportingBackups(true);
|
||||
try {
|
||||
const blobData = await apiClient.get<Blob>("/api/applications/export-backups", {
|
||||
params: filters,
|
||||
responseType: "blob",
|
||||
timeout: 480_000,
|
||||
} as any);
|
||||
const blob = new Blob([blobData], { type: "application/zip" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `sang-kien-sao-luu-tong-hop-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Đã tải ZIP tổng hợp (mỗi hồ sơ một file ZIP bên trong).");
|
||||
} catch (e) {
|
||||
toast.error(detailFromApiError(e, "Không xuất được ZIP tổng hợp."));
|
||||
} finally {
|
||||
setIsExportingBackups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatus = (item: ApplicationItem): ApplicationStatus => statusOverrides[item.id] ?? item.status;
|
||||
|
||||
const handleReviewAction = (applicationId: string, nextStatus: ApplicationStatus) => {
|
||||
setStatusOverrides((prev) => ({ ...prev, [applicationId]: nextStatus }));
|
||||
};
|
||||
|
||||
const tableColSpan = 10 + (showBackupDownloadColumn ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{filterCardTitleResolved}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Tên sáng kiến</Label>
|
||||
<Input id="name" value={nameInput} onChange={(e) => setNameInput(e.target.value)} placeholder="Nhập tên sáng kiến" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Đơn vị</Label>
|
||||
<Select
|
||||
value={filters.subjectId || ALL_OPTION_VALUE}
|
||||
onValueChange={(v) => setFilter("subjectId", v === ALL_OPTION_VALUE ? "" : v)}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="Chọn" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_OPTION_VALUE}>Tất cả</SelectItem>
|
||||
{DEPARTMENT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Năm</Label>
|
||||
<Select value={filters.conferenceId || ALL_OPTION_VALUE} onValueChange={(v) => setFilter("conferenceId", v === ALL_OPTION_VALUE ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Chọn" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_OPTION_VALUE}>Tất cả</SelectItem>
|
||||
{(conferencesQuery.data ?? []).map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Trạng thái</Label>
|
||||
<Select value={filters.status || ALL_OPTION_VALUE} onValueChange={(v) => setFilter("status", v === ALL_OPTION_VALUE ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Chọn" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_OPTION_VALUE}>Tất cả</SelectItem>
|
||||
{statusFilterOptions.map((option) => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="date-from">Ngày gởi</Label>
|
||||
<Input
|
||||
id="date-from"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
placeholder="dd/mm/yyyy"
|
||||
value={dateFromText}
|
||||
onChange={(e) => setDateFromText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const iso = parseDdMmYyyyToIso(dateFromText);
|
||||
setFilter("dateFrom", iso);
|
||||
setDateFromText(iso ? isoDateToDdMmYyyy(iso) : "");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="author-name">Tác giả</Label>
|
||||
<Input
|
||||
id="author-name"
|
||||
value={authorInput}
|
||||
onChange={(e) => setAuthorInput(e.target.value)}
|
||||
placeholder="Nhập tên tác giả"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reviewer-name">Người đánh giá</Label>
|
||||
<Input
|
||||
id="reviewer-name"
|
||||
value={reviewerInput}
|
||||
onChange={(e) => setReviewerInput(e.target.value)}
|
||||
placeholder="Nhập tên người đánh giá"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Người phụ trách</Label>
|
||||
<SearchableSelect value={filters.supervisorId} placeholder="Chọn người phụ trách" options={supervisorsQuery.data ?? []} onValueChange={(v) => setFilter("supervisorId", v)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Nhóm sáng kiến</Label>
|
||||
<Select value={filters.topicTypeId || ALL_OPTION_VALUE} onValueChange={(v) => setFilter("topicTypeId", v === ALL_OPTION_VALUE ? "" : v)}>
|
||||
<SelectTrigger><SelectValue placeholder="Chọn" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_OPTION_VALUE}>Tất cả</SelectItem>
|
||||
{GROUP_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => setFilters((prev) => ({ ...prev, page: 1 }))}>Tìm</Button>
|
||||
<Button variant="ghost" onClick={handleReset}>Xóa bộ lọc</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{resultsSectionTitleResolved} ({pagination.totalItems})</h3>
|
||||
{showStatusBreakdown ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-green-700 dark:text-green-500">Đã duyệt: {statusCounts.approved}</span>
|
||||
<span className="mx-2">·</span>
|
||||
<span className="font-medium text-destructive">Không duyệt: {statusCounts.rejected}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void exportAllBackupsZip()}
|
||||
className="shrink-0 gap-2"
|
||||
disabled={isExportingBackups || isExportingExcel || applicationsQuery.isLoading}
|
||||
>
|
||||
{isExportingBackups ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Archive className="h-4 w-4" aria-hidden />
|
||||
)}
|
||||
ZIP sao lưu (tất cả)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={exportExcel}
|
||||
className="shrink-0 gap-2 bg-green-600 hover:bg-green-700"
|
||||
disabled={isExportingExcel || isExportingBackups}
|
||||
>
|
||||
{isExportingExcel ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
||||
Xuất Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">File hồ sơ đã lưu trong public</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{reportFilesQuery.isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Đang tải danh sách file...</p>
|
||||
) : (reportFilesQuery.data?.files?.length ?? 0) === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Chưa có file báo cáo nào.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(reportFilesQuery.data?.files ?? []).slice(0, 10).map((file) => (
|
||||
<div key={file.fileName} className="flex items-center justify-between rounded border px-3 py-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{file.fileName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Cập nhật: {formatIsoToDdMmYyyyHhMm(file.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={file.publicUrl} target="_blank" rel="noreferrer">Xem file</a>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">STT</TableHead>
|
||||
<TableHead className="min-w-[250px]"><button type="button" onClick={() => toggleSort("name")} className="inline-flex items-center gap-1">Tên sáng kiến <ArrowUpDown className="h-3 w-3" /></button></TableHead>
|
||||
<TableHead className="min-w-[160px]"><button type="button" onClick={() => toggleSort("author")} className="inline-flex items-center gap-1">Tác giả <ArrowUpDown className="h-3 w-3" /></button></TableHead>
|
||||
<TableHead className="w-[120px]">Người đánh giá</TableHead>
|
||||
<TableHead className="w-[110px]">Nhóm sáng kiến</TableHead>
|
||||
<TableHead className="w-[110px]">Trạng thái</TableHead>
|
||||
<TableHead className="w-[60px] text-center">Minh chứng</TableHead>
|
||||
<TableHead className="min-w-[100px]">Đủ và Đúng</TableHead>
|
||||
<TableHead className="min-w-[160px]">Nhận xét</TableHead>
|
||||
{showBackupDownloadColumn ? (
|
||||
<TableHead className="w-[72px] text-center">Sao lưu</TableHead>
|
||||
) : null}
|
||||
<TableHead className="w-[100px] text-center">Xem hồ sơ</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{applicationsQuery.isLoading ? (
|
||||
<TableRow><TableCell colSpan={tableColSpan} className="py-10 text-center text-muted-foreground">Đang tải dữ liệu...</TableCell></TableRow>
|
||||
) : applicationsQuery.isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableColSpan} className="py-10 text-center text-destructive">
|
||||
Không thể tải hồ sơ đã nộp từ cơ sở dữ liệu. Vui lòng thử lại.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : data.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={tableColSpan} className="py-10 text-center text-muted-foreground">Chưa có hồ sơ nào được nộp thành công.</TableCell></TableRow>
|
||||
) : (
|
||||
data.map((item, index) => {
|
||||
const stt = (pagination.page - 1) * pagination.pageSize + index + 1;
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{stt}</TableCell>
|
||||
<TableCell className="max-w-[260px] truncate font-semibold" title={item.name}>{item.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-semibold">{item.author.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.author.email ?? ""}</div>
|
||||
<div className="text-xs text-muted-foreground">{item.author.phone ?? ""}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.reviewer?.name ?? "—"}</TableCell>
|
||||
<TableCell className="max-w-[220px] align-top">
|
||||
<span
|
||||
className="line-clamp-4"
|
||||
title={optionLabel(GROUP_OPTIONS, item.groupId) || item.topicType || undefined}
|
||||
>
|
||||
{optionLabel(GROUP_OPTIONS, item.groupId) || item.topicType || "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{statusBadge(getStatus(item), item)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<EvidenceFilesTableCell
|
||||
item={item}
|
||||
caseId={item.draftCaseId || item.draft_case_id}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[140px] align-top text-sm">
|
||||
{item.duVaDung?.trim() ? item.duVaDung : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] align-top text-sm">
|
||||
<span className="line-clamp-4 whitespace-pre-wrap" title={item.nhanXet?.trim() || undefined}>
|
||||
{item.nhanXet?.trim() ? item.nhanXet : "—"}
|
||||
</span>
|
||||
</TableCell>
|
||||
{showBackupDownloadColumn ? (
|
||||
<TableCell className="text-center align-middle">
|
||||
<div className="flex justify-center">
|
||||
<ApplicationBackupDownloadButton
|
||||
applicationId={item.id}
|
||||
author={item.author}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="text-center">
|
||||
<ViewSubmittedApplicationButton applicationId={item.id} returnTo={viewSubmittedReturnPath} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center">
|
||||
<div>Trang {pagination.page} / {Math.max(1, pagination.totalPages)} | Hiển thị {pagination.pageSize} / {pagination.totalItems} kết quả</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(filters.pageSize)} onValueChange={(v) => setFilters((prev) => ({ ...prev, pageSize: Number(v), page: 1 }))}>
|
||||
<SelectTrigger className="w-[90px]"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[10, 20, 50, 100].map((size) => <SelectItem key={size} value={String(size)}>{size}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={() => setFilter("page", Math.max(1, filters.page - 1))} disabled={filters.page <= 1}>Trước</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setFilter("page", Math.min(pagination.totalPages, filters.page + 1))} disabled={filters.page >= pagination.totalPages}>Sau</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FileText,
|
||||
Users,
|
||||
Settings,
|
||||
BarChart3,
|
||||
FolderOpen,
|
||||
Bell,
|
||||
HelpCircle,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
BookOpen,
|
||||
Shield,
|
||||
ScrollText
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { PermissionGate } from "@/components/auth/PermissionGate";
|
||||
const LOGO_SRC = "/logo.svg";
|
||||
|
||||
const mainMenuItems = [
|
||||
{ title: "Quản trị duyệt hồ sơ", url: "/dashboard", icon: LayoutDashboard },
|
||||
// { title: "Curriculum", url: "/dashboard/curriculum", icon: BookOpen },
|
||||
// { title: "Analytics", url: "/dashboard/analytics", icon: BarChart3 },
|
||||
{ title: "Kết quả đăng ký", url: "/dashboard/result", icon: FileText },
|
||||
{ title: "Tổng quan", url: "/dashboard/analytics", icon: BarChart3 },
|
||||
];
|
||||
|
||||
const managementItems = [
|
||||
// { title: "Admin Panel", url: "/dashboard/admin", icon: Shield },
|
||||
{ title: "Nhật kí", url: "/dashboard/admin/audit", icon: ScrollText },
|
||||
{ title: "Users", url: "/dashboard/users", icon: Users },
|
||||
{ title: "Projects", url: "/dashboard/projects", icon: FolderOpen },
|
||||
];
|
||||
|
||||
const systemItems = [
|
||||
{ title: "Notifications", url: "/dashboard/notifications", icon: Bell },
|
||||
{ title: "Settings", url: "/dashboard/settings", icon: Settings },
|
||||
{ title: "Help", url: "/dashboard/help", icon: HelpCircle },
|
||||
];
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { state } = useSidebar();
|
||||
const { logout, hasPermission } = useAuth();
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
const isActive = (url: string) => {
|
||||
if (url === "/dashboard") {
|
||||
return location.pathname === "/dashboard" || location.pathname === "/dashboard/";
|
||||
}
|
||||
return location.pathname === url || location.pathname.startsWith(`${url}/`);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const MenuItem = ({ item }: { item: { title: string; url: string; icon: React.ComponentType<{ className?: string }> } }) => (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isActive(item.url)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link to={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="border-b border-sidebar-border p-4">
|
||||
<Link to="/dashboard" className="flex items-center gap-2">
|
||||
<img
|
||||
src={LOGO_SRC}
|
||||
alt="UMP Logo"
|
||||
className="h-8 w-8 object-contain flex-shrink-0"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<span className="font-serif text-lg font-semibold text-sidebar-foreground">
|
||||
UMP
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup className="mt-4">
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{mainMenuItems.map((item) => (
|
||||
<MenuItem key={item.title} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<PermissionGate permission="admin.access">
|
||||
<SidebarGroup>
|
||||
<Collapsible defaultOpen className="group/collapsible">
|
||||
<SidebarGroupLabel asChild>
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Management
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
</SidebarGroupLabel>
|
||||
<CollapsibleContent>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{managementItems.map((item) => (
|
||||
<MenuItem key={item.title} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
</PermissionGate>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>System</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{systemItems.map((item) => (
|
||||
<MenuItem key={item.title} item={item} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border p-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={handleLogout}
|
||||
tooltip="Sign Out"
|
||||
className="text-destructive hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Sign Out</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* Ideas Management Tab
|
||||
* Allows administrators to add ideas and search for similar ones using vector similarity
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, 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 { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient } from "@/shared/api/client";
|
||||
import { Lightbulb, Search, Plus, Trash2, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { formatIsoToDdMmYyyyHhMm } from "@/lib/vnDateFormat";
|
||||
|
||||
interface Idea {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
created_at: string;
|
||||
similarity_score?: number;
|
||||
}
|
||||
|
||||
export function IdeasManagementTab() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState<string>("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<Idea[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch all ideas
|
||||
const { data: allIdeas, isLoading: ideasLoading } = useQuery<{ ideas: Idea[]; count: number }>({
|
||||
queryKey: ['ideas', 'all'],
|
||||
queryFn: async () => {
|
||||
return apiClient.get('/api/v1/ideas?limit=100');
|
||||
},
|
||||
});
|
||||
|
||||
// Add idea mutation
|
||||
const addIdeaMutation = useMutation({
|
||||
mutationFn: async (idea: { title: string; description: string; category?: string }) => {
|
||||
return apiClient.post('/api/v1/ideas', idea);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Đã thêm ý tưởng thành công!");
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setCategory("");
|
||||
queryClient.invalidateQueries({ queryKey: ['ideas'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Lỗi thêm ý tưởng: ${error.message || "Không thể thêm"}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Delete idea mutation
|
||||
const deleteIdeaMutation = useMutation({
|
||||
mutationFn: async (ideaId: string) => {
|
||||
return apiClient.delete(`/api/v1/ideas/${ideaId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Đã xóa ý tưởng thành công!");
|
||||
queryClient.invalidateQueries({ queryKey: ['ideas'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Lỗi xóa ý tưởng: ${error.message || "Không thể xóa"}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Initialize UMP ideas mutation
|
||||
const initializeUMPMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
return apiClient.post('/api/v1/ideas/initialize-ump');
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
toast.success(`Đã thêm ${data.count || 10} ý tưởng UMP thành công!`);
|
||||
queryClient.invalidateQueries({ queryKey: ['ideas'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(`Lỗi khởi tạo ý tưởng UMP: ${error.message || "Không thể khởi tạo"}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddIdea = () => {
|
||||
if (!title.trim() || !description.trim()) {
|
||||
toast.error("Vui lòng điền đầy đủ tiêu đề và mô tả");
|
||||
return;
|
||||
}
|
||||
addIdeaMutation.mutate({ title, description, category: category || undefined });
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
toast.error("Vui lòng nhập từ khóa tìm kiếm");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const response = await apiClient.post<{ results: Idea[]; count: number }>('/api/v1/ideas/search', {
|
||||
query: searchQuery,
|
||||
limit: 10,
|
||||
score_threshold: 0.5,
|
||||
});
|
||||
setSearchResults(response.results || []);
|
||||
if (response.results.length === 0) {
|
||||
toast.info("Không tìm thấy ý tưởng tương tự");
|
||||
} else {
|
||||
toast.success(`Tìm thấy ${response.results.length} ý tưởng tương tự`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Lỗi tìm kiếm: ${error.message || "Không thể tìm kiếm"}`);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (ideaId: string) => {
|
||||
if (confirm("Bạn có chắc chắn muốn xóa ý tưởng này?")) {
|
||||
deleteIdeaMutation.mutate(ideaId);
|
||||
}
|
||||
};
|
||||
|
||||
const categories = [
|
||||
"Giáo dục - AI",
|
||||
"Giáo dục - Chuyển đổi số",
|
||||
"Giáo dục - AR/VR",
|
||||
"Giáo dục - Quản lý chất lượng",
|
||||
"Nghiên cứu - AI",
|
||||
"Nghiên cứu - Dịch tễ học",
|
||||
"Nghiên cứu - Y học chính xác",
|
||||
"Tác động xã hội - Telehealth",
|
||||
"Tác động xã hội - Sức khỏe",
|
||||
"Khởi nghiệp - MedTech",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add New Idea */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Thêm Ý tưởng Mới
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Thêm ý tưởng đổi mới sáng tạo vào cơ sở dữ liệu để tìm kiếm tương tự
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="idea-title">Tiêu đề Ý tưởng</Label>
|
||||
<Input
|
||||
id="idea-title"
|
||||
placeholder="Nhập tiêu đề ý tưởng..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="idea-description">Mô tả Ý tưởng</Label>
|
||||
<Textarea
|
||||
id="idea-description"
|
||||
placeholder="Nhập mô tả chi tiết về ý tưởng..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="idea-category">Danh mục (Tùy chọn)</Label>
|
||||
<Select value={category} onValueChange={setCategory}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Chọn danh mục" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Không có danh mục</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAddIdea}
|
||||
disabled={addIdeaMutation.isPending || !title.trim() || !description.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{addIdeaMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Đang thêm...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Thêm Ý tưởng
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Search Similar Ideas */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
Tìm kiếm Ý tưởng Tương tự
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Tìm kiếm các ý tưởng tương tự trong cơ sở dữ liệu sử dụng vector similarity
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="search-query">Nhập ý tưởng hoặc mô tả để tìm kiếm</Label>
|
||||
<Textarea
|
||||
id="search-query"
|
||||
placeholder="Nhập mô tả ý tưởng mới của bạn để tìm các ý tưởng tương tự..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
className="w-full"
|
||||
>
|
||||
{isSearching ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Đang tìm kiếm...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Tìm kiếm
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="space-y-3 mt-4">
|
||||
<h3 className="font-semibold">Kết quả tìm kiếm ({searchResults.length})</h3>
|
||||
{searchResults.map((idea) => (
|
||||
<Card key={idea.id} className="border-l-4 border-l-primary">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold">{idea.title}</h4>
|
||||
{idea.category && (
|
||||
<Badge variant="outline">{idea.category}</Badge>
|
||||
)}
|
||||
{idea.similarity_score !== undefined && (
|
||||
<Badge variant="default">
|
||||
{(idea.similarity_score * 100).toFixed(1)}% tương tự
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{idea.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Thêm vào: {formatIsoToDdMmYyyyHhMm(idea.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* All Ideas List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lightbulb className="h-5 w-5" />
|
||||
Tất cả Ý tưởng ({allIdeas?.count || 0})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Danh sách tất cả các ý tưởng đã được lưu trong cơ sở dữ liệu
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => initializeUMPMutation.mutate()}
|
||||
disabled={initializeUMPMutation.isPending}
|
||||
>
|
||||
{initializeUMPMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Đang thêm...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Thêm 10 ý tưởng UMP
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{ideasLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : allIdeas && allIdeas.ideas.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{allIdeas.ideas.map((idea) => (
|
||||
<Card key={idea.id} className="border">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="font-semibold">{idea.title}</h4>
|
||||
{idea.category && (
|
||||
<Badge variant="outline">{idea.category}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">{idea.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Thêm vào: {formatIsoToDdMmYyyyHhMm(idea.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(idea.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Lightbulb className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Chưa có ý tưởng nào</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Overview Tab
|
||||
* Displays system statistics, health status, and key metrics
|
||||
*/
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVisibilityAwareRefetchInterval } from "@/hooks/useVisibilityAwareRefetchInterval";
|
||||
import { POLL_INTERVALS } from "@/shared/config/polling";
|
||||
import { apiClient } from "@/shared/api/client";
|
||||
import { Activity, Users, Database, Cpu, CheckCircle2, AlertCircle, Clock } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { formatIsoToDdMmYyyyHhMmSs } from "@/lib/vnDateFormat";
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
active_workflows: number;
|
||||
ollama: {
|
||||
status: string;
|
||||
available_models: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function OverviewTab() {
|
||||
const healthPoll = useVisibilityAwareRefetchInterval(POLL_INTERVALS.adminOverviewHealth);
|
||||
const { data: health, isLoading, error } = useQuery<HealthStatus>({
|
||||
queryKey: ['admin', 'health'],
|
||||
queryFn: async () => {
|
||||
return apiClient.get('/health');
|
||||
},
|
||||
staleTime: 30_000,
|
||||
refetchInterval: healthPoll,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Trạng thái Hệ thống",
|
||||
value: health?.status === "healthy" ? "Hoạt động" : "Lỗi",
|
||||
icon: Activity,
|
||||
status: health?.status === "healthy" ? "success" : "error",
|
||||
description: health?.status === "healthy" ? "Tất cả dịch vụ đang hoạt động bình thường" : "Có vấn đề với hệ thống",
|
||||
},
|
||||
{
|
||||
title: "Workflows Đang hoạt động",
|
||||
value: health?.active_workflows ?? 0,
|
||||
icon: Database,
|
||||
status: "info",
|
||||
description: "Số lượng workflow đang được xử lý",
|
||||
},
|
||||
{
|
||||
title: "Mô hình AI",
|
||||
value: health?.ollama?.available_models?.length ?? 0,
|
||||
icon: Cpu,
|
||||
status: health?.ollama?.status === "connected" ? "success" : "error",
|
||||
description: health?.ollama?.status === "connected"
|
||||
? `${health.ollama.available_models?.join(", ") || "Không có"}`
|
||||
: "Ollama không kết nối được",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* System Status */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : error ? (
|
||||
<div className="text-sm text-destructive">Lỗi tải dữ liệu</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge
|
||||
variant={stat.status === "success" ? "default" : stat.status === "error" ? "destructive" : "secondary"}
|
||||
className="text-xs"
|
||||
>
|
||||
{stat.status === "success" ? (
|
||||
<><CheckCircle2 className="h-3 w-3 mr-1" /> Hoạt động</>
|
||||
) : stat.status === "error" ? (
|
||||
<><AlertCircle className="h-3 w-3 mr-1" /> Lỗi</>
|
||||
) : (
|
||||
<><Clock className="h-3 w-3 mr-1" /> Thông tin</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">{stat.description}</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ollama Models Detail */}
|
||||
{health?.ollama && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Mô hình AI có sẵn</CardTitle>
|
||||
<CardDescription>
|
||||
Danh sách các mô hình Ollama đã được tải và sẵn sàng sử dụng
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : health.ollama.available_models && health.ollama.available_models.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{health.ollama.available_models.map((model, index) => (
|
||||
<Badge key={index} variant="outline" className="text-sm">
|
||||
{model}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Không có mô hình nào được tải</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* System Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thông tin Hệ thống</CardTitle>
|
||||
<CardDescription>Thông tin chi tiết về trạng thái hệ thống</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
) : health ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Thời gian cập nhật:</span>
|
||||
<span>{formatIsoToDdMmYyyyHhMmSs(health.timestamp)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Trạng thái Ollama:</span>
|
||||
<Badge variant={health.ollama.status === "connected" ? "default" : "destructive"}>
|
||||
{health.ollama.status === "connected" ? "Đã kết nối" : "Không kết nối"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Không có dữ liệu</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Role Permissions Tab
|
||||
* Allows administrators to manage user roles and permissions
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Shield, User, Eye, Edit3, Lock, CheckCircle2 } from "lucide-react";
|
||||
import { Role, Permission, ROLE_PERMISSIONS, ROLE_DISPLAY_NAMES, getPermissionsForRole } from "@/lib/permissions";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface PermissionGroup {
|
||||
category: string;
|
||||
permissions: Permission[];
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const PERMISSION_GROUPS: PermissionGroup[] = [
|
||||
{
|
||||
category: "Dashboard",
|
||||
permissions: ["dashboard.access"],
|
||||
icon: Eye,
|
||||
},
|
||||
{
|
||||
category: "Ứng dụng",
|
||||
permissions: ["application.view", "application.edit", "application.verify", "application.approve"],
|
||||
icon: Edit3,
|
||||
},
|
||||
{
|
||||
category: "Đánh giá",
|
||||
permissions: ["evaluation.view"],
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
{
|
||||
category: "Chat Assistant",
|
||||
permissions: ["chat.view", "chat.interact"],
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
category: "Chương trình",
|
||||
permissions: ["curriculum.view", "curriculum.edit", "curriculum.delete"],
|
||||
icon: Edit3,
|
||||
},
|
||||
{
|
||||
category: "Quản trị",
|
||||
permissions: ["admin.access", "admin.users", "admin.settings"],
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
category: "Báo cáo",
|
||||
permissions: ["reports.view", "reports.create", "reports.edit"],
|
||||
icon: Edit3,
|
||||
},
|
||||
];
|
||||
|
||||
export function RolePermissionsTab() {
|
||||
const [selectedRole, setSelectedRole] = useState<Role>("admin");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Role Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Quản lý Vai trò và Quyền
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Xem và quản lý quyền truy cập cho các vai trò trong hệ thống
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={selectedRole} onValueChange={(value) => setSelectedRole(value as Role)}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
{(["admin", "editor", "viewer"] as Role[]).map((role) => (
|
||||
<TabsTrigger key={role} value={role} className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
{ROLE_DISPLAY_NAMES[role]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{(["admin", "editor", "viewer"] as Role[]).map((role) => (
|
||||
<TabsContent key={role} value={role} className="mt-6">
|
||||
<RolePermissionsView role={role} />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Permission Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tổng quan Quyền</CardTitle>
|
||||
<CardDescription>
|
||||
So sánh quyền giữa các vai trò
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{(["admin", "editor", "viewer"] as Role[]).map((role) => {
|
||||
const permissions = getPermissionsForRole(role);
|
||||
return (
|
||||
<div key={role} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
<span className="font-medium">{ROLE_DISPLAY_NAMES[role]}</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{permissions.length} quyền</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{permissions.slice(0, 5).map((perm) => (
|
||||
<Badge key={perm} variant="outline" className="text-xs">
|
||||
{perm}
|
||||
</Badge>
|
||||
))}
|
||||
{permissions.length > 5 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{permissions.length - 5} khác
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolePermissionsView({ role }: { role: Role }) {
|
||||
const rolePermissions = getPermissionsForRole(role);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{ROLE_DISPLAY_NAMES[role]}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{rolePermissions.length} quyền được cấp
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="default">{rolePermissions.length} quyền</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{PERMISSION_GROUPS.map((group) => {
|
||||
const groupPermissions = group.permissions.filter((perm) =>
|
||||
rolePermissions.includes(perm)
|
||||
);
|
||||
const hasAnyPermission = groupPermissions.length > 0;
|
||||
|
||||
return (
|
||||
<Card key={group.category} className={hasAnyPermission ? "" : "opacity-50"}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<group.icon className="h-4 w-4" />
|
||||
{group.category}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{group.permissions.map((permission) => {
|
||||
const hasPermission = rolePermissions.includes(permission);
|
||||
return (
|
||||
<div
|
||||
key={permission}
|
||||
className="flex items-center justify-between p-2 rounded border"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPermission ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Label
|
||||
htmlFor={permission}
|
||||
className={`text-sm ${hasPermission ? "" : "text-muted-foreground"}`}
|
||||
>
|
||||
{permission}
|
||||
</Label>
|
||||
</div>
|
||||
<Badge variant={hasPermission ? "default" : "secondary"}>
|
||||
{hasPermission ? "Có" : "Không"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface SearchableOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SearchableSelectProps {
|
||||
value: string;
|
||||
placeholder: string;
|
||||
options: SearchableOption[];
|
||||
onValueChange: (value: string) => void;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
export default function SearchableSelect({
|
||||
value,
|
||||
placeholder,
|
||||
options,
|
||||
onValueChange,
|
||||
emptyMessage = "Không có dữ liệu",
|
||||
}: SearchableSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [triggerSearch, setTriggerSearch] = useState("");
|
||||
|
||||
const selectedLabel = useMemo(
|
||||
() => options.find((option) => option.value === value)?.label,
|
||||
[options, value],
|
||||
);
|
||||
const filteredOptions = useMemo(() => {
|
||||
const normalized = triggerSearch.trim().toLowerCase();
|
||||
if (!normalized) return options;
|
||||
return options.filter((option) => option.label.toLowerCase().includes(normalized));
|
||||
}, [options, triggerSearch]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="relative">
|
||||
<Input
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
value={triggerSearch || selectedLabel || ""}
|
||||
placeholder={placeholder}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => {
|
||||
setTriggerSearch(e.target.value);
|
||||
if (value) onValueChange("");
|
||||
}}
|
||||
className="pr-8"
|
||||
/>
|
||||
<ChevronsUpDown className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 opacity-50" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Tìm kiếm..." value={triggerSearch} onValueChange={setTriggerSearch} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => {
|
||||
onValueChange(option.value === value ? "" : option.value);
|
||||
setTriggerSearch("");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DEPARTMENT_OPTIONS } from '@/data/departmentOptions';
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Chưa duyệt' },
|
||||
{ value: 'approved', label: 'Đã duyệt' },
|
||||
{ value: 'rejected', label: 'Không duyệt' },
|
||||
{ value: 'transferred', label: 'Chuyển hội đồng' },
|
||||
{ value: 'under_review', label: 'Đang đánh giá' },
|
||||
{ value: 'reviewed', label: 'Đã đánh giá' },
|
||||
];
|
||||
|
||||
const REVIEW_STATUS_OPTIONS = [
|
||||
{ value: 'not_reviewed', label: 'Chưa đánh giá' },
|
||||
{ value: 'under_review', label: 'Đang đánh giá' },
|
||||
{ value: 'reviewed', label: 'Đã đánh giá' },
|
||||
];
|
||||
const ALL_OPTION_VALUE = '__all__';
|
||||
|
||||
const GROUP_OPTIONS = [
|
||||
{
|
||||
value: 'internal_technical_management',
|
||||
label:
|
||||
'Nhóm 1 - 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 Đại học Y Dược TP.HCM',
|
||||
},
|
||||
{
|
||||
value: 'innovation_from_research',
|
||||
label: 'Nhóm 2.1 - Sáng kiến – cải tiến kỹ thuật từ các nghiên cứu khoa học (đã đăng tạp chí/hội nghị)',
|
||||
},
|
||||
{
|
||||
value: 'innovation_from_materials',
|
||||
label: 'Nhóm 2.2 - 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',
|
||||
},
|
||||
];
|
||||
|
||||
export { DEPARTMENT_OPTIONS, STATUS_OPTIONS, REVIEW_STATUS_OPTIONS, GROUP_OPTIONS, ALL_OPTION_VALUE };
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* End-to-end style test: admin eye button → staff review route → GET application + draft (mocked).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { apiClient } from "@/shared/api/client";
|
||||
import { getPermissionsForRoles, type Permission } from "@/lib/permissions";
|
||||
import type { User } from "@/contexts/AuthContext";
|
||||
import { ViewSubmittedApplicationButton } from "@/components/admin/ViewSubmittedApplicationButton";
|
||||
import AdminApplicationReviewPage from "@/pages/AdminApplicationReviewPage";
|
||||
|
||||
const adminUser: User = {
|
||||
id: "admin-e2e-1",
|
||||
email: "admin@e2e.test",
|
||||
name: "Admin E2E",
|
||||
roles: ["admin"],
|
||||
availableRoles: ["admin"],
|
||||
permissions: getPermissionsForRoles(["admin"]),
|
||||
};
|
||||
|
||||
function buildMockAuth() {
|
||||
return {
|
||||
user: adminUser,
|
||||
loading: false,
|
||||
isAuthenticated: true,
|
||||
isDemoMode: false,
|
||||
hasPermission: (permission: Permission) => adminUser.permissions.includes(permission),
|
||||
hasAnyPermission: (ps: Permission[]) => ps.some((p) => adminUser.permissions.includes(p)),
|
||||
hasRole: (r: "admin" | "editor" | "viewer") => r === "admin",
|
||||
login: vi.fn().mockResolvedValue({ success: true }),
|
||||
register: vi.fn().mockResolvedValue({ success: true }),
|
||||
logout: vi.fn().mockResolvedValue(undefined),
|
||||
setDemoUser: vi.fn(),
|
||||
clearDemoUser: vi.fn(),
|
||||
updateUserFields: vi.fn(),
|
||||
applyAuthUser: vi.fn().mockReturnValue({ success: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const mockUseAuth = vi.fn(() => buildMockAuth());
|
||||
|
||||
vi.mock("@/contexts/AuthContext", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
AuthProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ChatAssistant", () => ({
|
||||
default: function MockChat() {
|
||||
return <div data-testid="chat-assistant-mock" />;
|
||||
},
|
||||
}));
|
||||
|
||||
const SAMPLE_APP = {
|
||||
id: "admin-eye-app-1",
|
||||
name: "Hồ sơ admin E2E",
|
||||
submittedDate: "2026-04-22T10:00:00.000Z",
|
||||
author: { id: "CASE-ADMIN-E2E", name: "Tác giả phụ", email: "author@e2e.test" },
|
||||
status: "pending" as const,
|
||||
reviewStatus: "not_reviewed" as const,
|
||||
draftCaseId: "CASE-ADMIN-E2E",
|
||||
draft_case_id: "CASE-ADMIN-E2E",
|
||||
};
|
||||
|
||||
const SAMPLE_DRAFT_BUNDLE = {
|
||||
caseId: "CASE-ADMIN-E2E",
|
||||
updatedAt: "2026-04-22T10:00:00.000Z",
|
||||
tabs: {
|
||||
report: { initiativeName: SAMPLE_APP.name, authorName: SAMPLE_APP.author.name },
|
||||
application: { initiativeName: SAMPLE_APP.name },
|
||||
contribution: { initiativeName: SAMPLE_APP.name, mainAuthor: SAMPLE_APP.author.name, participants: [] },
|
||||
},
|
||||
};
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderAdminEyeFlow() {
|
||||
const client = makeQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MemoryRouter initialEntries={["/dashboard/admin-applications"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/dashboard/admin-applications"
|
||||
element={
|
||||
<ViewSubmittedApplicationButton
|
||||
applicationId={SAMPLE_APP.id}
|
||||
returnTo="/dashboard/admin-applications"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/dashboard/admin/applications/review" element={<AdminApplicationReviewPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Admin ViewSubmittedApplicationButton (integration)", () => {
|
||||
const getSpy = vi.spyOn(apiClient, "get");
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseAuth.mockImplementation(() => buildMockAuth());
|
||||
getSpy.mockImplementation((url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes(`/api/applications/${encodeURIComponent(SAMPLE_APP.id)}`)) {
|
||||
return Promise.resolve(SAMPLE_APP);
|
||||
}
|
||||
if (u.includes("/api/v1/application-drafts/")) {
|
||||
return Promise.resolve(SAMPLE_DRAFT_BUNDLE);
|
||||
}
|
||||
return Promise.reject(new Error(`Unmocked GET: ${u}`));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getSpy.mockReset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("navigates to staff review URL and loads application detail plus draft bundle", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAdminEyeFlow();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Xem hồ sơ/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: /Xem hồ sơ đã nộp/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
getSpy.mock.calls.some(
|
||||
(c) =>
|
||||
String(c[0]).includes("/api/applications/") &&
|
||||
String(c[0]).includes(SAMPLE_APP.id) &&
|
||||
!String(c[0]).includes("mine"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSpy.mock.calls.some((c) => String(c[0]).includes("/api/v1/application-drafts/"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Eye } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildStaffApplicationReviewHref } from "@/lib/applicationReviewNavigation";
|
||||
|
||||
type Props = {
|
||||
applicationId: string;
|
||||
/** Dashboard path to return to after review (must start with `/dashboard` per `ApplicationReviewPage`). */
|
||||
returnTo?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens submitted forms for an application via `GET /api/applications/:id` + draft bundle on the review route.
|
||||
* Uses staff review route + read-only forms (`formsView=1`).
|
||||
*/
|
||||
export function ViewSubmittedApplicationButton({
|
||||
applicationId,
|
||||
returnTo = "/dashboard",
|
||||
className,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={className}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
buildStaffApplicationReviewHref({
|
||||
applicationId,
|
||||
returnTo,
|
||||
viewOnly: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
aria-label="Xem hồ sơ"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
userProfileService,
|
||||
type AdminProfileDetail,
|
||||
type PendingProfileRow,
|
||||
type RegisteredUserRow,
|
||||
} from '@/lib/user-profile-service';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ProfileVerificationBadge } from '@/components/profile';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { FileDown, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { ProfileVerificationStatus } from '@/components/profile/types';
|
||||
import { formatIsoToDdMmYyyyHhMm } from '@/lib/vnDateFormat';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
const VERIFICATION_LABEL_VI: Record<ProfileVerificationStatus, string> = {
|
||||
draft: 'Nháp',
|
||||
pending: 'Chờ xác minh',
|
||||
verified: 'Đã xác minh',
|
||||
rejected: 'Từ chối',
|
||||
};
|
||||
|
||||
function formatWhen(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return formatIsoToDdMmYyyyHhMm(iso) || '—';
|
||||
}
|
||||
|
||||
function academicTitleDisplay(row: RegisteredUserRow): string {
|
||||
const base = row.academicTitleLabelVi ?? '—';
|
||||
if (base === '—') return '—';
|
||||
if (row.academicTitleOther?.trim()) return `${base} — ${row.academicTitleOther.trim()}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function rolesDisplayVi(roles: RegisteredUserRow['roles']): string {
|
||||
const map: Record<string, string> = { admin: 'QT', editor: 'HĐ', viewer: 'NĐ' };
|
||||
if (!roles.length) return '';
|
||||
return roles.map((r) => map[r] ?? r).join(', ');
|
||||
}
|
||||
|
||||
function exportRegisteredUsersExcel(rows: RegisteredUserRow[]) {
|
||||
const sheetRows = rows.map((r) => ({
|
||||
'Họ và tên': r.fullName,
|
||||
Email: r.email,
|
||||
'Điện thoại': r.phone ?? '',
|
||||
'Ngày tạo tài khoản': formatWhen(r.createdAt),
|
||||
'Vai trò': rolesDisplayVi(r.roles),
|
||||
'Mã nhân sự': r.employeeId ?? '',
|
||||
'Đơn vị (danh mục)': r.unitCatalogName ?? '',
|
||||
'Đơn vị (nhập tay)': r.unitNameFreetext ?? '',
|
||||
'Học hàm / học vị': r.academicTitleLabelVi ?? '',
|
||||
'Khác (ghi rõ)': r.academicTitleOther ?? '',
|
||||
'Chức danh': r.jobTitle ?? '',
|
||||
'Trạng thái xác minh': VERIFICATION_LABEL_VI[r.profileVerificationStatus] ?? r.profileVerificationStatus,
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(sheetRows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Nguoi dung');
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
XLSX.writeFile(wb, `tai-khoan-da-dang-ky-${stamp}.xlsx`);
|
||||
}
|
||||
|
||||
export function AdminUserProfilesManager() {
|
||||
const { user: sessionUser } = useAuth();
|
||||
const [pending, setPending] = useState<PendingProfileRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [registry, setRegistry] = useState<RegisteredUserRow[]>([]);
|
||||
const [registryLoading, setRegistryLoading] = useState(true);
|
||||
const [detail, setDetail] = useState<AdminProfileDetail | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [registryError, setRegistryError] = useState('');
|
||||
const [readonlyRow, setReadonlyRow] = useState<RegisteredUserRow | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<RegisteredUserRow | null>(null);
|
||||
const [removeOpen, setRemoveOpen] = useState(false);
|
||||
const [removeEmailConfirm, setRemoveEmailConfirm] = useState('');
|
||||
const [removeBusy, setRemoveBusy] = useState(false);
|
||||
const [roleDraft, setRoleDraft] = useState({ admin: false, editor: false, viewer: false });
|
||||
const [rolesBusy, setRolesBusy] = useState(false);
|
||||
|
||||
const loadPending = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const res = await userProfileService.listPendingProfiles();
|
||||
if (res.success) {
|
||||
setPending(res.items);
|
||||
} else {
|
||||
setError(res.error);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const loadRegistry = useCallback(async () => {
|
||||
setRegistryLoading(true);
|
||||
setRegistryError('');
|
||||
const res = await userProfileService.listRegisteredUsers();
|
||||
if (res.success) {
|
||||
setRegistry(res.items);
|
||||
} else {
|
||||
setRegistryError(res.error);
|
||||
}
|
||||
setRegistryLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPending();
|
||||
}, [loadPending]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRegistry();
|
||||
}, [loadRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readonlyRow) return;
|
||||
setRoleDraft({
|
||||
admin: readonlyRow.roles.includes('admin'),
|
||||
editor: readonlyRow.roles.includes('editor'),
|
||||
viewer: readonlyRow.roles.includes('viewer'),
|
||||
});
|
||||
}, [readonlyRow]);
|
||||
|
||||
const openDetail = async (userId: string) => {
|
||||
setError('');
|
||||
const res = await userProfileService.getAdminProfileDetail(userId);
|
||||
if (res.success) {
|
||||
setDetail(res.detail);
|
||||
setRejectReason('');
|
||||
} else {
|
||||
toast.error(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
if (!detail) return;
|
||||
setBusy(true);
|
||||
const v = detail.staffProfile.version;
|
||||
const res = await userProfileService.verifyProfile(detail.userId, v);
|
||||
setBusy(false);
|
||||
if (res.success) {
|
||||
toast.success('Đã xác minh hồ sơ.');
|
||||
setDetail(null);
|
||||
void loadPending();
|
||||
void loadRegistry();
|
||||
} else {
|
||||
toast.error(res.error);
|
||||
void loadPending();
|
||||
void loadRegistry();
|
||||
void openDetail(detail.userId);
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async () => {
|
||||
if (!detail || !rejectReason.trim()) {
|
||||
toast.error('Nhập lý do từ chối.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const v = detail.staffProfile.version;
|
||||
const res = await userProfileService.rejectProfile(detail.userId, v, rejectReason.trim());
|
||||
setBusy(false);
|
||||
if (res.success) {
|
||||
toast.success('Đã từ chối hồ sơ.');
|
||||
setDetail(null);
|
||||
void loadPending();
|
||||
void loadRegistry();
|
||||
} else {
|
||||
toast.error(res.error);
|
||||
void loadPending();
|
||||
void loadRegistry();
|
||||
void openDetail(detail.userId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-serif font-semibold tracking-tight">Hồ sơ người dùng</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Danh sách tài khoản đã đăng ký và hàng chờ xác minh hồ sơ nhân sự.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Tài khoản đã đăng ký</CardTitle>
|
||||
<CardDescription>
|
||||
Xem, xuất Excel; quản trị có thể xóa vĩnh viễn tài khoản (không áp dụng tài khoản quản trị).
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={registryLoading || registry.length === 0}
|
||||
onClick={() => {
|
||||
exportRegisteredUsersExcel(registry);
|
||||
toast.success('Đã tải file Excel.');
|
||||
}}
|
||||
>
|
||||
<FileDown className="mr-1.5 h-4 w-4" />
|
||||
Xuất Excel
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadRegistry()}>
|
||||
Làm mới
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{registryLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Đang tải danh sách…
|
||||
</div>
|
||||
) : registryError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{registryError}</AlertDescription>
|
||||
</Alert>
|
||||
) : registry.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Chưa có tài khoản hoạt động.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Họ tên</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead className="whitespace-nowrap">Ngày tạo TK</TableHead>
|
||||
<TableHead>Trạng thái HS</TableHead>
|
||||
<TableHead className="whitespace-nowrap">Vai trò</TableHead>
|
||||
<TableHead>Mã NS</TableHead>
|
||||
<TableHead className="w-[1%]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{registry.map((r) => (
|
||||
<TableRow key={r.userId}>
|
||||
<TableCell className="font-medium">{r.fullName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.email}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{formatWhen(r.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ProfileVerificationBadge status={r.profileVerificationStatus} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{rolesDisplayVi(r.roles) || '—'}
|
||||
</TableCell>
|
||||
<TableCell>{r.employeeId ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setReadonlyRow(r)}>
|
||||
Xem
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Hàng chờ xác minh</CardTitle>
|
||||
<CardDescription>Trạng thái «Chờ xác minh» từ người nộp đơn.</CardDescription>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadPending()}>
|
||||
Làm mới
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Đang tải hàng chờ…
|
||||
</div>
|
||||
) : pending.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Không có hồ sơ chờ xử lý.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Họ tên</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Mã NS</TableHead>
|
||||
<TableHead>Gửi lúc</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((r) => (
|
||||
<TableRow key={r.userId}>
|
||||
<TableCell className="font-medium">{r.fullName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.email}</TableCell>
|
||||
<TableCell>{r.employeeId ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{r.verificationSubmittedAt
|
||||
? formatIsoToDdMmYyyyHhMm(r.verificationSubmittedAt)
|
||||
: '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => void openDetail(r.userId)}>
|
||||
Xem
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={readonlyRow !== null} onOpenChange={(o) => !o && setReadonlyRow(null)}>
|
||||
<DialogContent className="max-h-[min(90vh,720px)] overflow-y-auto sm:max-w-xl">
|
||||
{readonlyRow ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{readonlyRow.fullName}</DialogTitle>
|
||||
<DialogDescription>{readonlyRow.email}</DialogDescription>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<ProfileVerificationBadge status={readonlyRow.profileVerificationStatus} />
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Điện thoại</span>
|
||||
<p>{readonlyRow.phone ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Ngày tạo tài khoản</span>
|
||||
<p>{formatWhen(readonlyRow.createdAt)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Đơn vị (danh mục)</span>
|
||||
<p>{readonlyRow.unitCatalogName ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Đơn vị (nhập tay)</span>
|
||||
<p>{readonlyRow.unitNameFreetext ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Mã nhân sự</span>
|
||||
<p>{readonlyRow.employeeId ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Học hàm / học vị</span>
|
||||
<p>{academicTitleDisplay(readonlyRow)}</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<span className="text-muted-foreground">Chức danh</span>
|
||||
<p>{readonlyRow.jobTitle ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const isSelf = sessionUser?.id === readonlyRow.userId;
|
||||
const adminLocked =
|
||||
readonlyRow.policyAdminLocked || (Boolean(isSelf) && roleDraft.admin);
|
||||
return (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Vai trò truy cập</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Ba cột tương ứng: Quản trị · Hội đồng · Người nộp đơn (chọn ít nhất một).
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Label htmlFor="role-admin" className="text-xs font-normal text-muted-foreground">
|
||||
Quản trị
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="role-admin"
|
||||
checked={roleDraft.admin}
|
||||
disabled={adminLocked}
|
||||
onCheckedChange={(v) => setRoleDraft((d) => ({ ...d, admin: v === true }))}
|
||||
aria-label="Vai trò quản trị"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Label htmlFor="role-editor" className="text-xs font-normal text-muted-foreground">
|
||||
Hội đồng
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="role-editor"
|
||||
checked={roleDraft.editor}
|
||||
onCheckedChange={(v) => setRoleDraft((d) => ({ ...d, editor: v === true }))}
|
||||
aria-label="Vai trò hội đồng"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<Label htmlFor="role-viewer" className="text-xs font-normal text-muted-foreground">
|
||||
Người nộp đơn
|
||||
</Label>
|
||||
<Checkbox
|
||||
id="role-viewer"
|
||||
checked={roleDraft.viewer}
|
||||
onCheckedChange={(v) => setRoleDraft((d) => ({ ...d, viewer: v === true }))}
|
||||
aria-label="Vai trò người nộp đơn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{readonlyRow.policyAdminLocked ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Quản trị bắt buộc: email nằm trong danh sách quản trị hiện tại trên máy chủ
|
||||
(AUTH_ADMIN_EMAILS / mặc định).
|
||||
</p>
|
||||
) : null}
|
||||
{Boolean(isSelf) && roleDraft.admin ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Bạn không thể tự gỡ quyền Quản trị tại đây.
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={rolesBusy}
|
||||
onClick={async () => {
|
||||
if (!roleDraft.admin && !roleDraft.editor && !roleDraft.viewer) {
|
||||
toast.error('Chọn ít nhất một vai trò.');
|
||||
return;
|
||||
}
|
||||
const uid = readonlyRow.userId;
|
||||
setRolesBusy(true);
|
||||
const res = await userProfileService.updateUserRoles(uid, roleDraft);
|
||||
setRolesBusy(false);
|
||||
if (!res.success) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Đã cập nhật vai trò.');
|
||||
setReadonlyRow((prev) =>
|
||||
prev && prev.userId === uid
|
||||
? {
|
||||
...prev,
|
||||
roles: res.roles,
|
||||
adminFromPolicy: res.adminFromPolicy,
|
||||
policyAdminLocked: res.policyAdminLocked,
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
void loadRegistry();
|
||||
}}
|
||||
>
|
||||
{rolesBusy ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Đang lưu…
|
||||
</>
|
||||
) : (
|
||||
'Lưu vai trò'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{sessionUser && readonlyRow.userId !== sessionUser.id ? (
|
||||
<div className="border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRemoveEmailConfirm('');
|
||||
setRemoveTarget(readonlyRow);
|
||||
setReadonlyRow(null);
|
||||
setRemoveOpen(true);
|
||||
}}
|
||||
>
|
||||
Xóa tài khoản khỏi CSDL
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Dùng khi cần gỡ đăng ký để thử lại (ví dụ đăng ký OTP cùng email). Thao tác không hoàn tác.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={removeOpen}
|
||||
onOpenChange={(o) => {
|
||||
setRemoveOpen(o);
|
||||
if (!o) {
|
||||
setRemoveEmailConfirm('');
|
||||
setRemoveTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Xóa tài khoản vĩnh viễn?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-3 text-left text-sm text-muted-foreground">
|
||||
<p>
|
||||
Người dùng <span className="font-medium text-foreground">{removeTarget?.fullName}</span> và dữ liệu liên
|
||||
quan tài khoản (OTP, vai trò, hồ sơ nhân sự…) sẽ bị xóa khỏi cơ sở dữ liệu khi không còn ràng buộc khác.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="remove-confirm-email" className="text-foreground">
|
||||
Nhập đúng email để xác nhận
|
||||
</label>
|
||||
<Input
|
||||
id="remove-confirm-email"
|
||||
type="email"
|
||||
autoComplete="off"
|
||||
placeholder={removeTarget?.email ?? 'email@...'}
|
||||
value={removeEmailConfirm}
|
||||
onChange={(e) => setRemoveEmailConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel type="button">Hủy</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={
|
||||
removeBusy ||
|
||||
!removeTarget ||
|
||||
removeEmailConfirm.trim().toLowerCase() !== removeTarget.email.trim().toLowerCase()
|
||||
}
|
||||
onClick={async () => {
|
||||
if (!removeTarget) return;
|
||||
setRemoveBusy(true);
|
||||
const res = await userProfileService.removeUserAccount(removeTarget.userId, removeEmailConfirm);
|
||||
setRemoveBusy(false);
|
||||
if (res.success) {
|
||||
toast.success('Đã xóa tài khoản.');
|
||||
setRemoveOpen(false);
|
||||
setRemoveEmailConfirm('');
|
||||
setRemoveTarget(null);
|
||||
void loadRegistry();
|
||||
void loadPending();
|
||||
} else {
|
||||
toast.error(res.error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{removeBusy ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Đang xóa…
|
||||
</>
|
||||
) : (
|
||||
'Xóa vĩnh viễn'
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{detail ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>{detail.fullName}</CardTitle>
|
||||
<ProfileVerificationBadge status={detail.staffProfile.profileVerificationStatus} />
|
||||
</div>
|
||||
<CardDescription>
|
||||
{detail.email} · Phiên bản hồ sơ: {detail.staffProfile.version}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Điện thoại</span>
|
||||
<p>{detail.phone ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Đơn vị (danh mục)</span>
|
||||
<p>{detail.unitCatalogName ?? detail.unitId ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Đơn vị (tự nhập)</span>
|
||||
<p>{detail.staffProfile.unitNameFreetext ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Mã nhân sự</span>
|
||||
<p>{detail.staffProfile.employeeId ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Học hàm / học vị</span>
|
||||
<p>
|
||||
{detail.staffProfile.academicTitleCode ?? '—'}
|
||||
{detail.staffProfile.academicTitleCode === 'other'
|
||||
? ` — ${detail.staffProfile.academicTitleOther ?? ''}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Chức vụ</span>
|
||||
<p>{detail.staffProfile.jobTitle ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reject-reason">Lý do từ chối (khi từ chối)</Label>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" onClick={() => void verify()} disabled={busy}>
|
||||
Xác minh
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={() => void reject()} disabled={busy}>
|
||||
Từ chối
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setDetail(null)}>
|
||||
Đóng
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AdminUserProfilesManager } from './AdminUserProfilesManager';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DecidedApplicationsPanel } from "@/components/admin/result/DecidedApplicationsPanel";
|
||||
|
||||
/**
|
||||
* Đã có kết luận (đã duyệt / từ chối), tách khỏi danh sách hồ sơ mới chờ xử lý ở `/dashboard`.
|
||||
*/
|
||||
export default function ConsideredInitiativesList() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<DecidedApplicationsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import ApprovedApplicationsList from "@/components/admin/ApprovedApplicationsList";
|
||||
|
||||
const DECIDED_LIFECYCLE = "decided" as const;
|
||||
|
||||
/**
|
||||
* **Kết quả đăng ký**: submitted applications whose list `status` is approved or rejected
|
||||
* (`lifecycle=decided` on `GET /api/applications`). Kept separate from the inbox on `/dashboard`.
|
||||
*/
|
||||
export function DecidedApplicationsPanel() {
|
||||
return (
|
||||
<ApprovedApplicationsList
|
||||
lifecycle={DECIDED_LIFECYCLE}
|
||||
showReviewActionButtons={false}
|
||||
viewSubmittedReturnPath="/dashboard/result"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { detailFromApiError } from "@/shared/api/client";
|
||||
import {
|
||||
deleteAdminApplicationResult,
|
||||
type AdminDecision,
|
||||
upsertAdminApplicationResult,
|
||||
fetchAdminApplicationResult,
|
||||
} from "@/lib/applicationAdminResultApi";
|
||||
import { invalidateAfterAdminApplicationResultChange } from "@/components/admin/result/invalidateAdminApplicationResultQueries";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DecisionToggle(props: {
|
||||
value: AdminDecision;
|
||||
onChange: (v: AdminDecision) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { value, onChange, disabled } = props;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === "approved" ? "default" : "outline"}
|
||||
className={cn(value === "approved" && "bg-green-600 hover:bg-green-600/90")}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange("approved")}
|
||||
>
|
||||
<Check className="mr-2 h-4 w-4" aria-hidden />
|
||||
Duyệt
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={value === "rejected" ? "destructive" : "outline"}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange("rejected")}
|
||||
>
|
||||
<X className="mr-2 h-4 w-4" aria-hidden />
|
||||
Từ chối
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets an **admin** create / read / update / delete adjudication outcomes for a submitted application (`applicationId`).
|
||||
* Syncs backend `initiatives.status` (approved | rejected).
|
||||
*/
|
||||
export function ResultManager() {
|
||||
const { hasRole } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [applicationIdInput, setApplicationIdInput] = useState("");
|
||||
const [loadedId, setLoadedId] = useState("");
|
||||
|
||||
const [decision, setDecision] = useState<AdminDecision>("approved");
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [rationale, setRationale] = useState("");
|
||||
|
||||
const canUse = hasRole("admin");
|
||||
|
||||
const resultQuery = useQuery({
|
||||
queryKey: ["admin-application-result", loadedId],
|
||||
queryFn: () => fetchAdminApplicationResult(loadedId),
|
||||
enabled: Boolean(canUse && loadedId.trim()),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadedId.trim() || !resultQuery.isSuccess) return;
|
||||
const row = resultQuery.data;
|
||||
if (!row) {
|
||||
setDecision("approved");
|
||||
setFeedback("");
|
||||
setRationale("");
|
||||
return;
|
||||
}
|
||||
setDecision(row.decision);
|
||||
setFeedback(row.feedback ?? "");
|
||||
setRationale(row.rationale ?? "");
|
||||
}, [loadedId, resultQuery.isSuccess, resultQuery.data]);
|
||||
|
||||
const existsOnServer = resultQuery.data != null;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const id = loadedId.trim();
|
||||
const payload = {
|
||||
decision,
|
||||
feedback: feedback.trim(),
|
||||
rationale: rationale.trim() || null,
|
||||
};
|
||||
return upsertAdminApplicationResult(id, payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success("Đã lưu kết quả.");
|
||||
await invalidateAfterAdminApplicationResultChange(queryClient, loadedId);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(detailFromApiError(err, "Không lưu được kết quả."));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteAdminApplicationResult(loadedId.trim()),
|
||||
onSuccess: async () => {
|
||||
toast.success("Đã xóa kết quả (hồ sơ trở về trạng thái đã nộp).");
|
||||
await invalidateAfterAdminApplicationResultChange(queryClient, loadedId);
|
||||
setDecision("approved");
|
||||
setFeedback("");
|
||||
setRationale("");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(detailFromApiError(err, "Không xóa được kết quả."));
|
||||
},
|
||||
});
|
||||
|
||||
const handleLoad = () => {
|
||||
const id = applicationIdInput.trim();
|
||||
if (!id) {
|
||||
toast.message("Nhập mã hồ sơ (applicationId).");
|
||||
return;
|
||||
}
|
||||
setLoadedId(id);
|
||||
};
|
||||
|
||||
if (!canUse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const busy = resultQuery.isFetching || saveMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Quản lý kết quả duyệt (quản trị)</CardTitle>
|
||||
<CardDescription>
|
||||
Nhập mã hồ sơ như trong bảng đã nộp (ví dụ <code className="text-xs bg-muted px-1 rounded">sub-…</code>), chọn{' '}
|
||||
<strong>Duyệt</strong> hoặc <strong>Từ chối</strong>, nhập nhận xét và lý do — lưu vào cơ sở dữ liệu.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="grow space-y-2">
|
||||
<Label htmlFor="result-manager-app-id">Mã hồ sơ (applicationId)</Label>
|
||||
<Input
|
||||
id="result-manager-app-id"
|
||||
value={applicationIdInput}
|
||||
onChange={(e) => setApplicationIdInput(e.target.value)}
|
||||
placeholder="sub-xxxx… hoặc mã CASE"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="secondary" onClick={handleLoad} disabled={busy}>
|
||||
{resultQuery.isFetching && loadedId ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Tải dữ liệu
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loadedId ? (
|
||||
<>
|
||||
{resultQuery.isError ? (
|
||||
<p className="text-sm text-destructive">{detailFromApiError(resultQuery.error, "Không tải được kết quả.")}</p>
|
||||
) : null}
|
||||
|
||||
{resultQuery.isSuccess ? (
|
||||
<div className="rounded-md border bg-muted/40 px-3 py-2 text-sm space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Trạng thái bản ghi: </span>
|
||||
<strong>{resultQuery.data ? "Đã có kết quả — có thể sửa hoặc xóa" : "Chưa có — tạo mới khi Lưu"}</strong>
|
||||
</p>
|
||||
{resultQuery.data?.applicationName ? (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Tên (từ hồ sơ): </span>
|
||||
{resultQuery.data.applicationName}
|
||||
</p>
|
||||
) : null}
|
||||
{resultQuery.data?.updatedByFullName ? (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Người đánh giá (lần cập nhật cuối): </span>
|
||||
<strong>{resultQuery.data.updatedByFullName}</strong>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Quyết định</Label>
|
||||
<DecisionToggle value={decision} onChange={setDecision} disabled={busy} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="result-feedback">Nhận xét / phản hồi</Label>
|
||||
<Textarea
|
||||
id="result-feedback"
|
||||
rows={4}
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
placeholder="Nội dung gửi ứng viên (ghi rõ điểm mạnh, yêu cầu chỉnh sửa, v.v.)"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="result-rationale">
|
||||
Lý do & chi tiết (duyệt hoặc từ chối) <span className="text-muted-foreground font-normal">— tùy chọn</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="result-rationale"
|
||||
rows={4}
|
||||
value={rationale}
|
||||
onChange={(e) => setRationale(e.target.value)}
|
||||
placeholder="Tiêu chí căn cứ, trích Phiếu đánh giá, hoặc lý do từ chối cụ thể…"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={busy || !resultQuery.isSuccess}
|
||||
>
|
||||
{(saveMutation.isPending || resultQuery.isFetching) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{existsOnServer ? "Cập nhật kết quả" : "Lưu kết quả mới"}
|
||||
</Button>
|
||||
|
||||
{existsOnServer ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="button" variant="outline" className="text-destructive border-destructive/40" disabled={busy}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Xóa kết quả
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Xóa kết quả đã lưu?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Hồ sơ sẽ trở lại trạng thái đã nộp (submitted). Thao tác này không xóa dữ liệu bản nháp.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Hủy</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
>
|
||||
Xóa
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nhập mã hồ sơ và bấm « Tải dữ liệu » để bắt đầu.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/** TanStack partial match invalidates every `["applications", …]` list query. */
|
||||
export const APPLICATIONS_ROOT_QUERY_KEY = ["applications"] as const;
|
||||
|
||||
/**
|
||||
* After saving or deleting an admin adjudication result, refresh result detail, list filters,
|
||||
* and any cached application detail so **Kết quả đăng ký** stays aligned with Postgres.
|
||||
*/
|
||||
export async function invalidateAfterAdminApplicationResultChange(
|
||||
queryClient: QueryClient,
|
||||
applicationId: string,
|
||||
): Promise<void> {
|
||||
const id = applicationId.trim();
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin-application-result", id] });
|
||||
await queryClient.invalidateQueries({ queryKey: APPLICATIONS_ROOT_QUERY_KEY });
|
||||
await queryClient.invalidateQueries({ queryKey: ["application-detail", id] });
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { InitiativeDraft } from "@/components/applicant/initiative-draft/types";
|
||||
import type { ContributionDraftShape } from "@/components/applicant/initiative-draft/contributionDraftTypes";
|
||||
import { ApplicationFormDocxPreview } from "@/components/applicant/initiative-draft/ApplicationFormDocxPreview";
|
||||
|
||||
export type AdminApplicationFormDocxPreviewProps = {
|
||||
draft: InitiativeDraft;
|
||||
contribution: ContributionDraftShape;
|
||||
className?: string;
|
||||
caseId?: string;
|
||||
docxStaffFooter: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Xem trước mẫu Word giống tab ứng viên, nhưng cho Ban quản trị (chỉ đọc): không có « Tải DOCX » / « Tải PDF ».
|
||||
* Hội đồng dùng {@link ApplicationFormDocxPreview} qua {@link ReviewSnapshotSections} (giữ hai nút tải).
|
||||
*/
|
||||
export function AdminApplicationFormDocxPreview({
|
||||
draft,
|
||||
contribution,
|
||||
className,
|
||||
caseId,
|
||||
docxStaffFooter,
|
||||
}: AdminApplicationFormDocxPreviewProps) {
|
||||
return (
|
||||
<ApplicationFormDocxPreview
|
||||
draft={draft}
|
||||
contribution={contribution}
|
||||
className={className}
|
||||
caseId={caseId}
|
||||
onSaveAllDrafts={undefined}
|
||||
docxStaffFooter={docxStaffFooter}
|
||||
hideDocxPdfExportButtons
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useState, type ReactElement } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type {
|
||||
CouncilRankRecommendation,
|
||||
RequiredSlotEvidenceStatusLabel,
|
||||
} from "@/components/admin/review/evaluationCategoryRecommendation";
|
||||
|
||||
function reviewStatusTextClass(label: RequiredSlotEvidenceStatusLabel): string {
|
||||
if (label === "Đạt") return "text-green-600 dark:text-green-400 font-medium";
|
||||
if (label === "Không đạt" || label === "Chưa có tệp trên kho") {
|
||||
return "text-red-600 dark:text-red-400 font-medium";
|
||||
}
|
||||
return "text-muted-foreground font-medium";
|
||||
}
|
||||
|
||||
function cannotConfirmApproveFromEvidence(status: RequiredSlotEvidenceStatusLabel): boolean {
|
||||
return (
|
||||
status === "Chưa có tệp trên kho" ||
|
||||
status === "Không đạt" ||
|
||||
status === "Chưa xác định loại minh chứng" ||
|
||||
status === "Đang tải trạng thái kho…" ||
|
||||
status === "Chưa tải được kho"
|
||||
);
|
||||
}
|
||||
|
||||
export function renderMinhChungKhoSummary(
|
||||
recommendation: CouncilRankRecommendation,
|
||||
): ReactElement {
|
||||
return (
|
||||
<dl className="mt-2 text-xs space-y-1">
|
||||
<div>
|
||||
<dt className="text-muted-foreground inline">Loại minh chứng (theo Đơn): </dt>
|
||||
<dd className="inline text-foreground">{recommendation.evidencePathLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground inline">Trạng thái thẩm định kho (đã phê duyệt / đã từ chối): </dt>
|
||||
<dd className={`inline ${reviewStatusTextClass(recommendation.requiredSlotEvidenceStatusLabel)}`}>
|
||||
{recommendation.requiredSlotEvidenceStatusLabel}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export type AdminApproveReviewDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
docxCompletenessGaps: string[];
|
||||
recommendation: CouncilRankRecommendation;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function AdminApproveReviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
docxCompletenessGaps,
|
||||
recommendation,
|
||||
onConfirm,
|
||||
}: AdminApproveReviewDialogProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const approveBlocked = cannotConfirmApproveFromEvidence(recommendation.requiredSlotEvidenceStatusLabel);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (busy || approveBlocked) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Xác nhận duyệt hồ sơ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<p className="font-medium text-foreground">Gợi ý hạng (tham khảo)</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
<span className="font-semibold text-foreground">{recommendation.rank}</span>
|
||||
{" — "}
|
||||
{recommendation.detail}
|
||||
</p>
|
||||
{renderMinhChungKhoSummary(recommendation)}
|
||||
</div>
|
||||
{docxCompletenessGaps.length > 0 ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Cảnh báo trước khi duyệt</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-4 mt-2 space-y-1">
|
||||
{docxCompletenessGaps.map((g) => (
|
||||
<li key={g}>{g}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Không phát hiện thiếu sót cơ bản theo Đơn và kho minh chứng.</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={busy || approveBlocked}
|
||||
title={
|
||||
approveBlocked
|
||||
? "Không thể duyệt: minh chứng kho chưa đủ điều kiện (trống, chưa phân loại, từ chối trên kho hoặc chưa tải được trạng thái)."
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{busy ? "Đang lưu…" : "Xác nhận duyệt"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { InitiativeDraft } from "@/components/applicant/initiative-draft/types";
|
||||
import type { ContributionDraftShape } from "@/components/applicant/initiative-draft/contributionDraftTypes";
|
||||
import { ReviewSnapshotSections } from "@/components/applicant/initiative-draft/ReviewSnapshotSections";
|
||||
import { AdminApplicationFormDocxPreview } from "@/components/admin/review/AdminApplicationFormDocxPreview";
|
||||
import { AdminEvaluationFormActions } from "@/components/admin/review/AdminEvaluationFormActions";
|
||||
import { EvidenceVaultLinkButton } from "@/components/evidence/EvidenceVaultLinkButton";
|
||||
import { getApplicationMeritCategoryHint } from "@/components/admin/review/applicationMeritCategoryHint";
|
||||
import {
|
||||
AdminStaffReadonlyReviewDialog,
|
||||
type StaffReadonlyDialogVariant,
|
||||
} from "@/components/admin/review/AdminStaffReadonlyReviewDialog";
|
||||
import { useEvidenceStaffReviewAck } from "@/hooks/useEvidenceStaffReviewAck";
|
||||
|
||||
export type AdminDocxTemplatePreviewProps = {
|
||||
draft: InitiativeDraft;
|
||||
contribution: ContributionDraftShape;
|
||||
caseId: string;
|
||||
applicationId: string;
|
||||
/**
|
||||
* Hội đồng: Từ chối / Kho minh chứng / Duyệt.
|
||||
* Quản trị (chỉ đọc): bỏ qua — footer chỉ còn nút mở kho minh chứng.
|
||||
*/
|
||||
councilReviewActions?: {
|
||||
onReject: () => void;
|
||||
onApprove: () => void;
|
||||
};
|
||||
/**
|
||||
* Quản trị (chỉ đọc): kết quả {@link collectDocxTemplateCompletenessGaps} để hiển thị trong hộp thoại Từ chối / Duyệt.
|
||||
* Không truyền thì coi như không có thiếu sót (tránh báo đỏ sai khi tích hợp chưa truyền prop).
|
||||
*/
|
||||
docxCompletenessGaps?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Giống tab xem lại của ứng viên ({@link ReviewPanel}): DOCX từ ba tab Đơn / Báo cáo / Đóng góp.
|
||||
*/
|
||||
export function AdminDocxTemplatePreview({
|
||||
draft,
|
||||
contribution,
|
||||
caseId,
|
||||
applicationId,
|
||||
councilReviewActions,
|
||||
docxCompletenessGaps = [],
|
||||
}: AdminDocxTemplatePreviewProps) {
|
||||
const meritHint = useMemo(
|
||||
() => getApplicationMeritCategoryHint(draft.application),
|
||||
[draft.application],
|
||||
);
|
||||
|
||||
const [staffDialog, setStaffDialog] = useState<StaffReadonlyDialogVariant | null>(null);
|
||||
const evidenceStaffReviewAcknowledged = useEvidenceStaffReviewAck(caseId.trim() ? caseId : undefined);
|
||||
|
||||
const docxStaffFooter = councilReviewActions ? (
|
||||
<AdminEvaluationFormActions
|
||||
caseId={caseId}
|
||||
applicationId={applicationId}
|
||||
onReject={councilReviewActions.onReject}
|
||||
onApprove={councilReviewActions.onApprove}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap justify-end gap-2 items-center">
|
||||
<EvidenceVaultLinkButton caseId={caseId} applicationId={applicationId} />
|
||||
{evidenceStaffReviewAcknowledged ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-destructive/50 text-destructive bg-red-500 text-white hover:bg-destructive/10 px-3"
|
||||
aria-label="Từ chối (xem trước)"
|
||||
title="Từ chối (xem trước)"
|
||||
onClick={() => setStaffDialog("reject")}
|
||||
>
|
||||
<X className="h-4 w-4 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="px-3 bg-green-500 text-white hover:bg-green-600"
|
||||
aria-label="Duyệt (xem trước)"
|
||||
title="Duyệt (xem trước)"
|
||||
onClick={() => setStaffDialog("approve")}
|
||||
>
|
||||
<Check className="h-4 w-4 shrink-0" aria-hidden />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="border-destructive/50 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setStaffDialog("reject")}
|
||||
>
|
||||
Từ chối
|
||||
</Button>
|
||||
<Button type="button" onClick={() => setStaffDialog("approve")}>
|
||||
Duyệt
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<AdminStaffReadonlyReviewDialog
|
||||
open={staffDialog !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setStaffDialog(null);
|
||||
}}
|
||||
applicationId={applicationId}
|
||||
variant={staffDialog ?? "approve"}
|
||||
meritHint={meritHint}
|
||||
docxCompletenessGaps={docxCompletenessGaps}
|
||||
staffEvidenceReviewAcknowledged={evidenceStaffReviewAcknowledged}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="h-full pt-6">
|
||||
{councilReviewActions ? (
|
||||
<ReviewSnapshotSections
|
||||
draft={draft}
|
||||
contribution={contribution}
|
||||
caseId={caseId}
|
||||
docxStaffFooter={docxStaffFooter}
|
||||
/>
|
||||
) : (
|
||||
<AdminApplicationFormDocxPreview
|
||||
draft={draft}
|
||||
contribution={contribution}
|
||||
caseId={caseId}
|
||||
docxStaffFooter={docxStaffFooter}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EvidenceVaultLinkButton } from "@/components/evidence/EvidenceVaultLinkButton";
|
||||
|
||||
export type AdminEvaluationFormActionsProps = {
|
||||
caseId: string;
|
||||
applicationId: string;
|
||||
onReject: () => void;
|
||||
onApprove: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hành động Hội đồng trên Phiếu đánh giá: từ chối, mở kho minh chứng, duyệt.
|
||||
*/
|
||||
export function AdminEvaluationFormActions({
|
||||
caseId,
|
||||
applicationId,
|
||||
onReject,
|
||||
onApprove,
|
||||
}: AdminEvaluationFormActionsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button type="button" variant="outline" className="border-destructive/50 text-destructive hover:bg-destructive/10" onClick={onReject}>
|
||||
Từ chối
|
||||
</Button>
|
||||
<EvidenceVaultLinkButton caseId={caseId} applicationId={applicationId} />
|
||||
<Button type="button" onClick={onApprove}>
|
||||
Duyệt
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import type { CouncilRankRecommendation } from "@/components/admin/review/evaluationCategoryRecommendation";
|
||||
import { renderMinhChungKhoSummary } from "@/components/admin/review/AdminApproveReviewDialog";
|
||||
|
||||
export type AdminRejectReviewDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
docxCompletenessGaps: string[];
|
||||
/** Cùng khối « Minh chứng (kho) » như hộp thoại duyệt. */
|
||||
evidenceSummary?: CouncilRankRecommendation | null;
|
||||
onConfirm: (feedback: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function AdminRejectReviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
docxCompletenessGaps,
|
||||
evidenceSummary,
|
||||
onConfirm,
|
||||
}: AdminRejectReviewDialogProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [feedback, setFeedback] = useState("");
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await onConfirm(feedback.trim());
|
||||
setFeedback("");
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) setFeedback("");
|
||||
onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Từ chối hồ sơ</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{evidenceSummary ? (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<p className="font-medium text-foreground">Minh chứng (kho)</p>
|
||||
{renderMinhChungKhoSummary(evidenceSummary)}
|
||||
</div>
|
||||
) : null}
|
||||
{docxCompletenessGaps.length > 0 ? (
|
||||
<Alert>
|
||||
<AlertTitle>Gợi ý lý do (từ kiểm tra tự động)</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-4 mt-2 space-y-1 text-sm">
|
||||
{docxCompletenessGaps.map((g) => (
|
||||
<li key={g}>{g}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div>
|
||||
<Label htmlFor="reject-feedback">Phản hồi cho ứng viên (tùy chọn)</Label>
|
||||
<Textarea
|
||||
id="reject-feedback"
|
||||
className="mt-1.5 min-h-[100px]"
|
||||
placeholder="Nhập lý do từ chối hoặc yêu cầu bổ sung…"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? "Đang lưu…" : "Xác nhận từ chối"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { detailFromApiError } from "@/shared/api/client";
|
||||
import { upsertAdminApplicationResult } from "@/lib/applicationAdminResultApi";
|
||||
import { invalidateAfterAdminApplicationResultChange } from "@/components/admin/result/invalidateAdminApplicationResultQueries";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
type ApplicationMeritCategoryHint,
|
||||
formatApplicationSubgroupLabel,
|
||||
} from "@/components/admin/review/applicationMeritCategoryHint";
|
||||
|
||||
function MeritCategorySection({ hint }: { hint: ApplicationMeritCategoryHint }) {
|
||||
const subgroupLabel = formatApplicationSubgroupLabel(hint.subgroupCode);
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-sm space-y-2">
|
||||
<p className="font-medium text-foreground">Gợi ý theo nhóm Đơn</p>
|
||||
{subgroupLabel ? (
|
||||
<p>
|
||||
<span className="text-muted-foreground">Mục Đơn: </span>
|
||||
<span
|
||||
className={
|
||||
hint.subgroupCode === "2.2.1" ? "font-medium text-foreground" : "font-mono font-medium text-foreground"
|
||||
}
|
||||
>
|
||||
{subgroupLabel}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{hint.meritLevel ? (
|
||||
<p className="font-medium text-foreground">Gợi ý mức xét: {hint.meritLevel}</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground">{hint.detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full DOCX/kho completeness — chỉ hiển thị sau khi admin đã ✓/✗ ít nhất một minh chứng trên kho (từ nút mở kho minh chứng). */
|
||||
function DocxTemplateCompletenessSection({ gaps }: { gaps: string[] }) {
|
||||
const complete = gaps.length === 0;
|
||||
|
||||
if (complete) {
|
||||
return (
|
||||
<Alert className="border-green-700/40 bg-green-50/80 dark:bg-green-950/30 dark:border-green-700/50">
|
||||
<AlertTitle>Mẫu DOCX / kho minh chứng</AlertTitle>
|
||||
<AlertDescription className="text-foreground/90">
|
||||
Không phát hiện thiếu sót cơ bản theo Đơn và kho minh chứng — các trường cần cho mẫu đã được đối chiếu đủ.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Các mục chưa đủ hoặc cần kiểm tra</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc pl-4 mt-2 space-y-1 text-sm">
|
||||
{gaps.map((g) => (
|
||||
<li key={g}>{g}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingEvidenceStaffReviewPrompt() {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>Chưa mở khóa đối chiếu DOCX / kho minh chứng</AlertTitle>
|
||||
<AlertDescription className="text-sm leading-relaxed">
|
||||
Đóng hộp thoại này, rồi mở kho minh chứng bằng nút ở chân khu xem mẫu — nút đó hiển thị nhãn tổng hợp{" "}
|
||||
<span className="font-medium">Đạt</span>, <span className="font-medium">Không đạt</span> hoặc{" "}
|
||||
<span className="font-medium">Chưa xem</span> (cùng quy tắc với tooltip « Mở kho minh chứng — … »). Trên trang
|
||||
kho, nhấn <span className="font-medium">✓</span> phê duyệt hoặc <span className="font-medium">✗</span> từ chối đối với
|
||||
ít nhất một dòng có tệp. Sau đó mở lại thao tác từ chối / duyệt để xem tóm tắt đầy đủ.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export type StaffReadonlyDialogVariant = "reject" | "approve";
|
||||
|
||||
export type AdminStaffReadonlyReviewDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Public application id — persisted with decision and feedback on confirm. */
|
||||
applicationId: string;
|
||||
variant: StaffReadonlyDialogVariant;
|
||||
meritHint: ApplicationMeritCategoryHint;
|
||||
/** From {@link collectDocxTemplateCompletenessGaps} — Đơn + kho minh chứng. */
|
||||
docxCompletenessGaps: string[];
|
||||
/** Sau khi admin đã ✓/✗ minh chứng trên kho (mở qua nút kho minh chứng; cùng mã case). */
|
||||
staffEvidenceReviewAcknowledged: boolean;
|
||||
};
|
||||
|
||||
const titles: Record<StaffReadonlyDialogVariant, string> = {
|
||||
reject: "Từ chối (xem trước)",
|
||||
approve: "Duyệt (xem trước)",
|
||||
};
|
||||
|
||||
/**
|
||||
* Hộp thoại giữa màn hình cho quản trị viên chỉ đọc: trạng thái mẫu DOCX, gợi ý mức xét, phản hồi.
|
||||
*/
|
||||
export function AdminStaffReadonlyReviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
applicationId,
|
||||
variant,
|
||||
meritHint,
|
||||
docxCompletenessGaps,
|
||||
staffEvidenceReviewAcknowledged,
|
||||
}: AdminStaffReadonlyReviewDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) setFeedback("");
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const confirm = async () => {
|
||||
const id = applicationId.trim();
|
||||
if (!id) {
|
||||
toast.error("Thiếu mã hồ sơ — không thể lưu kết quả.");
|
||||
return;
|
||||
}
|
||||
const trimmed = feedback.trim();
|
||||
const decision = variant === "approve" ? "approved" : "rejected";
|
||||
setSaving(true);
|
||||
try {
|
||||
await upsertAdminApplicationResult(id, {
|
||||
decision,
|
||||
feedback: trimmed,
|
||||
rationale: null,
|
||||
});
|
||||
await invalidateAfterAdminApplicationResultChange(queryClient, id);
|
||||
handleOpenChange(false);
|
||||
toast.success(trimmed ? "Đã lưu kết quả và phản hồi." : "Đã lưu kết quả.", {
|
||||
...(trimmed ? { description: trimmed.length > 160 ? `${trimmed.slice(0, 157)}…` : trimmed } : {}),
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(detailFromApiError(e, "Không lưu được kết quả."));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[min(90vh,720px)] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{titles[variant]}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
{staffEvidenceReviewAcknowledged ? (
|
||||
<DocxTemplateCompletenessSection gaps={docxCompletenessGaps} />
|
||||
) : (
|
||||
<PendingEvidenceStaffReviewPrompt />
|
||||
)}
|
||||
<MeritCategorySection hint={meritHint} />
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="staff-readonly-review-feedback">Phản hồi / nhận xét</Label>
|
||||
<Textarea
|
||||
id="staff-readonly-review-feedback"
|
||||
className="min-h-[100px] resize-y"
|
||||
placeholder="Nhập phản hồi hoặc ghi chú…"
|
||||
value={feedback}
|
||||
onChange={(e) => setFeedback(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
Đóng
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void confirm()}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
||||
Đang lưu…
|
||||
</>
|
||||
) : (
|
||||
"Xác nhận"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { AdminDocxTemplatePreview } from "@/components/admin/review/AdminDocxTemplatePreview";
|
||||
import { AdminApproveReviewDialog } from "@/components/admin/review/AdminApproveReviewDialog";
|
||||
import { AdminRejectReviewDialog } from "@/components/admin/review/AdminRejectReviewDialog";
|
||||
import {
|
||||
collectDocxTemplateCompletenessGaps,
|
||||
mergeApplicationTabForReview,
|
||||
} from "@/components/admin/review/docxTemplateCompleteness";
|
||||
import {
|
||||
recommendCouncilRankFromEvidence,
|
||||
type StaffBundleEvidenceQueryPhase,
|
||||
} from "@/components/admin/review/evaluationCategoryRecommendation";
|
||||
import { saveCouncilReviewOutcome } from "@/components/admin/review/reviewOutcomeStorage";
|
||||
import type { ReviewDraftTabs } from "@/components/admin/review/buildInitiativeDraftFromReviewTabs";
|
||||
import { buildInitiativeDraftFromReviewTabs } from "@/components/admin/review/buildInitiativeDraftFromReviewTabs";
|
||||
import { reviveContributionDraft } from "@/components/applicant/applicationDrafts";
|
||||
import type { ContributionDraftShape } from "@/components/applicant/initiative-draft/contributionDraftTypes";
|
||||
import { getApplicationEvidence } from "@/lib/applicationEvidenceApi";
|
||||
import { useCouncilEvaluationSnapshotReader } from "@/components/admin/review/councilEvaluationSnapshotContext";
|
||||
|
||||
function validateSnapshotForReject(s: ReturnType<typeof useCouncilEvaluationSnapshotReader>): boolean {
|
||||
if (!s) {
|
||||
toast.error("Chưa tải được dữ liệu Phiếu đánh giá. Mở tab Phiếu Đánh Giá và điền tên sáng kiến, tác giả.");
|
||||
return false;
|
||||
}
|
||||
if (!s.initiativeName.trim()) {
|
||||
toast.error("Vui lòng nhập tên sáng kiến ở tab Phiếu Đánh Giá.");
|
||||
return false;
|
||||
}
|
||||
if (!s.authorName.trim()) {
|
||||
toast.error("Vui lòng nhập tên tác giả ở tab Phiếu Đánh Giá.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateSnapshotForApprove(s: ReturnType<typeof useCouncilEvaluationSnapshotReader>): boolean {
|
||||
if (!validateSnapshotForReject(s)) return false;
|
||||
if (!s!.noveltyLevel) {
|
||||
toast.error("Vui lòng chọn mức độ tính mới ở tab Phiếu Đánh Giá.");
|
||||
return false;
|
||||
}
|
||||
if (!s!.effectivenessLevel) {
|
||||
toast.error("Vui lòng chọn mức độ tính hiệu quả ở tab Phiếu Đánh Giá.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function evidenceSlotLabel(kind: "research" | "textbook" | "technical"): string {
|
||||
if (kind === "research") return "2.1 Nghiên cứu";
|
||||
if (kind === "textbook") return "2.2 Sách / GT";
|
||||
return "Kỹ thuật";
|
||||
}
|
||||
|
||||
const AUTO_REJECT_EMPTY_VAULT_EVIDENCE =
|
||||
"Hệ thống: từ chối tự động — minh chứng bắt buộc theo phân loại Đơn chưa có tệp trên kho.";
|
||||
|
||||
export type StaffApplicationBundleReviewTabProps = {
|
||||
caseId: string;
|
||||
applicationId: string;
|
||||
draftTabs: ReviewDraftTabs | undefined;
|
||||
/** Hội đồng: hiển thị Từ chối / Duyệt và hộp thoại lưu kết quả. Quản trị: chỉ xem DOCX + kho MinIO. */
|
||||
enableCouncilReviewActions: boolean;
|
||||
onCouncilApprove?: () => void | Promise<void>;
|
||||
onCouncilReject?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tab sau « Xác nhận tỷ lệ đóng góp »: xem trước mẫu DOCX từ JSON đã lưu (Postgres qua `loadApplicantDraftBundle`),
|
||||
* trạng thái minh chứng trên kho (API → presigned MinIO), và (Hội đồng) Duyệt / Từ chối.
|
||||
*/
|
||||
export function StaffApplicationBundleReviewTab({
|
||||
caseId,
|
||||
applicationId,
|
||||
draftTabs,
|
||||
enableCouncilReviewActions,
|
||||
onCouncilApprove,
|
||||
onCouncilReject,
|
||||
}: StaffApplicationBundleReviewTabProps) {
|
||||
const evaluationSnapshot = useCouncilEvaluationSnapshotReader();
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
|
||||
const evidenceQuery = useQuery({
|
||||
queryKey: ["staff-bundle-review-evidence", caseId],
|
||||
queryFn: () => getApplicationEvidence(caseId),
|
||||
enabled: Boolean(caseId?.trim()),
|
||||
staleTime: 20_000,
|
||||
});
|
||||
|
||||
const docxCompletenessGaps = useMemo(
|
||||
() => collectDocxTemplateCompletenessGaps(draftTabs, evidenceQuery.data ?? null),
|
||||
[draftTabs, evidenceQuery.data],
|
||||
);
|
||||
|
||||
const evidencePhase: StaffBundleEvidenceQueryPhase = !caseId?.trim()
|
||||
? "error"
|
||||
: evidenceQuery.isError
|
||||
? "error"
|
||||
: evidenceQuery.isSuccess
|
||||
? "ready"
|
||||
: "loading";
|
||||
|
||||
const councilRankRecommendation = useMemo(
|
||||
() =>
|
||||
recommendCouncilRankFromEvidence({
|
||||
application: mergeApplicationTabForReview(draftTabs),
|
||||
bundle: evidenceQuery.data ?? null,
|
||||
evidencePhase,
|
||||
}),
|
||||
[draftTabs, evidenceQuery.data, evidencePhase],
|
||||
);
|
||||
|
||||
const previewDraft = useMemo(
|
||||
() => (caseId.trim() ? buildInitiativeDraftFromReviewTabs(caseId, draftTabs) : null),
|
||||
[caseId, draftTabs],
|
||||
);
|
||||
|
||||
const previewContribution = useMemo((): ContributionDraftShape => {
|
||||
return (reviveContributionDraft(draftTabs?.contribution) ?? {}) as ContributionDraftShape;
|
||||
}, [draftTabs?.contribution]);
|
||||
|
||||
const confirmReject = async (feedback: string) => {
|
||||
if (!evaluationSnapshot) return;
|
||||
saveCouncilReviewOutcome({
|
||||
applicationId,
|
||||
caseId,
|
||||
initiativeName: evaluationSnapshot.initiativeName.trim(),
|
||||
authorName: evaluationSnapshot.authorName.trim(),
|
||||
decision: "rejected",
|
||||
recommendedRank: councilRankRecommendation.rank,
|
||||
recommendedRankDetail: councilRankRecommendation.detail,
|
||||
evidencePathLabel: councilRankRecommendation.evidencePathLabel,
|
||||
feedback: feedback || undefined,
|
||||
});
|
||||
await onCouncilReject?.();
|
||||
};
|
||||
|
||||
const openReject = () => {
|
||||
if (!enableCouncilReviewActions) return;
|
||||
if (!validateSnapshotForReject(evaluationSnapshot)) return;
|
||||
setRejectOpen(true);
|
||||
};
|
||||
|
||||
const openApprove = () => {
|
||||
if (!enableCouncilReviewActions) return;
|
||||
if (!validateSnapshotForApprove(evaluationSnapshot)) return;
|
||||
if (councilRankRecommendation.requiredSlotEvidenceStatusLabel === "Chưa có tệp trên kho") {
|
||||
toast.warning(
|
||||
"Không thể duyệt khi minh chứng bắt buộc trên kho còn trống — đã ghi nhận từ chối tự động.",
|
||||
);
|
||||
void confirmReject(AUTO_REJECT_EMPTY_VAULT_EVIDENCE);
|
||||
return;
|
||||
}
|
||||
setApproveOpen(true);
|
||||
};
|
||||
|
||||
const confirmApprove = async () => {
|
||||
if (!evaluationSnapshot) return;
|
||||
if (councilRankRecommendation.requiredSlotEvidenceStatusLabel === "Chưa có tệp trên kho") {
|
||||
toast.error("Thiếu minh chứng trên kho — không lưu duyệt.");
|
||||
setApproveOpen(false);
|
||||
await confirmReject(AUTO_REJECT_EMPTY_VAULT_EVIDENCE);
|
||||
return;
|
||||
}
|
||||
saveCouncilReviewOutcome({
|
||||
applicationId,
|
||||
caseId,
|
||||
initiativeName: evaluationSnapshot.initiativeName.trim(),
|
||||
authorName: evaluationSnapshot.authorName.trim(),
|
||||
decision: "approved",
|
||||
recommendedRank: councilRankRecommendation.rank,
|
||||
recommendedRankDetail: councilRankRecommendation.detail,
|
||||
evidencePathLabel: councilRankRecommendation.evidencePathLabel,
|
||||
totalScore: evaluationSnapshot.totalScore,
|
||||
});
|
||||
await onCouncilApprove?.();
|
||||
};
|
||||
|
||||
if (!previewDraft) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-4">
|
||||
Chưa có mã case hoặc dữ liệu bản nháp từ máy chủ — không thể dựng mẫu DOCX.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const bundle = evidenceQuery.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert className="border-border bg-muted/30">
|
||||
{/* <AlertTitle className="text-sm font-semibold">Minh chứng trên kho (MinIO)</AlertTitle> */}
|
||||
<AlertDescription className="text-sm mt-2 space-y-2">
|
||||
{evidenceQuery.isLoading ? (
|
||||
<p className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" aria-hidden />
|
||||
Đang tải trạng thái tệp từ máy chủ…
|
||||
</p>
|
||||
) : evidenceQuery.isError ? (
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
Không tải được danh sách minh chứng — vẫn có thể xem mẫu DOCX; hãy thử mở kho minh chứng hoặc tải lại
|
||||
trang.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{/* {(["research", "textbook", "technical"] as const).map((k) => {
|
||||
const meta = bundle?.[k];
|
||||
const ok = Boolean(meta?.storageKey);
|
||||
return (
|
||||
<Badge
|
||||
key={k}
|
||||
variant={ok ? "default" : "secondary"}
|
||||
className={ok ? "bg-green-700 hover:bg-green-700/90" : ""}
|
||||
>
|
||||
{evidenceSlotLabel(k)}: {ok ? meta?.originalName || "Đã có tệp" : "Chưa có"}
|
||||
</Badge>
|
||||
);
|
||||
})} */}
|
||||
</div>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{enableCouncilReviewActions ? (
|
||||
<>
|
||||
<AdminApproveReviewDialog
|
||||
open={approveOpen}
|
||||
onOpenChange={setApproveOpen}
|
||||
docxCompletenessGaps={docxCompletenessGaps}
|
||||
recommendation={councilRankRecommendation}
|
||||
onConfirm={confirmApprove}
|
||||
/>
|
||||
<AdminRejectReviewDialog
|
||||
open={rejectOpen}
|
||||
onOpenChange={setRejectOpen}
|
||||
docxCompletenessGaps={docxCompletenessGaps}
|
||||
evidenceSummary={councilRankRecommendation}
|
||||
onConfirm={confirmReject}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<AdminDocxTemplatePreview
|
||||
draft={previewDraft}
|
||||
contribution={previewContribution}
|
||||
caseId={caseId}
|
||||
applicationId={applicationId}
|
||||
councilReviewActions={
|
||||
enableCouncilReviewActions
|
||||
? { onReject: openReject, onApprove: openApprove }
|
||||
: undefined
|
||||
}
|
||||
docxCompletenessGaps={docxCompletenessGaps}
|
||||
/>
|
||||
|
||||
{enableCouncilReviewActions ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Điểm và nội dung Phiếu đánh giá (Mẫu 04) lấy từ tab « Phiếu Đánh Giá ». Hãy điền đủ các mục bắt buộc ở đó
|
||||
trước khi bấm Duyệt.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tài khoản quản trị: xem mẫu hồ sơ và đối chiếu minh chứng trên kho; quyết định Hội đồng thực hiện ở luồng
|
||||
council (nếu được cấp quyền).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getApplicationMeritCategoryHint } from "@/components/admin/review/applicationMeritCategoryHint";
|
||||
|
||||
describe("getApplicationMeritCategoryHint", () => {
|
||||
it("returns trung bình for research + poster-without-review (2.1.4)", () => {
|
||||
const hint = getApplicationMeritCategoryHint({
|
||||
initiativeClassification: "research",
|
||||
researchEvidenceKind: "poster-without-review",
|
||||
textbookEvidenceKind: "",
|
||||
});
|
||||
expect(hint.subgroupCode).toBe("2.1.4");
|
||||
expect(hint.meritLevel).toBe("trung bình");
|
||||
expect(hint.detail).toContain("không phản biện");
|
||||
});
|
||||
|
||||
it("returns khá for research + poster (2.1.3)", () => {
|
||||
const hint = getApplicationMeritCategoryHint({
|
||||
initiativeClassification: "research",
|
||||
researchEvidenceKind: "poster",
|
||||
textbookEvidenceKind: "",
|
||||
});
|
||||
expect(hint.subgroupCode).toBe("2.1.3");
|
||||
expect(hint.meritLevel).toBe("khá");
|
||||
});
|
||||
|
||||
it("returns xuất sắc for textbook + book (sách giáo trình)", () => {
|
||||
const hint = getApplicationMeritCategoryHint({
|
||||
initiativeClassification: "textbook",
|
||||
researchEvidenceKind: "",
|
||||
textbookEvidenceKind: "book",
|
||||
});
|
||||
expect(hint.subgroupCode).toBe("2.2.1");
|
||||
expect(hint.meritLevel).toBe("xuất sắc");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ApplicationFormState } from "@/components/applicant/initiativeFormTypes";
|
||||
|
||||
export type ApplicationMeritCategoryHint = {
|
||||
/** Mã mục con trên Đơn (ví dụ 2.1.1); nhóm sách/giáo trình lưu mã nội bộ 2.2.1 — nhãn hiển thị dùng {@link formatApplicationSubgroupLabel}. */
|
||||
subgroupCode: string | null;
|
||||
/**
|
||||
* Theo hướng dẫn nội bộ: 2.1.1–2.1.2 và 2.2.1 (sách/giáo trình) → xuất sắc; 2.1.3, 2.2.2 → khá; 2.1.4 → trung bình.
|
||||
* Nhóm 1 hoặc thiếu dữ liệu → null.
|
||||
*/
|
||||
meritLevel: "xuất sắc" | "khá" | "trung bình" | null;
|
||||
/** Mô tả ngắn loại minh chứng / trạng thái (hiển thị cùng gợi ý mức). */
|
||||
detail: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gợi ý hạng mức (xuất sắc / khá / trung bình) theo nhóm minh chứng trên Đơn.
|
||||
*/
|
||||
export function getApplicationMeritCategoryHint(
|
||||
application: Pick<
|
||||
ApplicationFormState,
|
||||
"initiativeClassification" | "researchEvidenceKind" | "textbookEvidenceKind"
|
||||
>,
|
||||
): ApplicationMeritCategoryHint {
|
||||
const c = application.initiativeClassification;
|
||||
const k = application.researchEvidenceKind;
|
||||
const tb = application.textbookEvidenceKind;
|
||||
|
||||
if (c === "technical") {
|
||||
return {
|
||||
subgroupCode: "1",
|
||||
meritLevel: null,
|
||||
detail: "Nhóm 1 — minh chứng áp dụng kỹ thuật / đơn vị (không áp dụng nhãn xuất sắc / khá theo nhóm 2.x).",
|
||||
};
|
||||
}
|
||||
|
||||
if (c === "research") {
|
||||
if (k === "international") {
|
||||
return {
|
||||
subgroupCode: "2.1.1",
|
||||
meritLevel: "xuất sắc",
|
||||
detail: "Bài báo tạp chí quốc tế.",
|
||||
};
|
||||
}
|
||||
if (k === "domestic") {
|
||||
return {
|
||||
subgroupCode: "2.1.2",
|
||||
meritLevel: "xuất sắc",
|
||||
detail: "Bài báo tạp chí trong nước.",
|
||||
};
|
||||
}
|
||||
if (k === "poster") {
|
||||
return {
|
||||
subgroupCode: "2.1.3",
|
||||
meritLevel: "khá",
|
||||
detail: "Poster / báo cáo hội nghị có phản biện.",
|
||||
};
|
||||
}
|
||||
if (k === "poster-without-review") {
|
||||
return {
|
||||
subgroupCode: "2.1.4",
|
||||
meritLevel: "trung bình",
|
||||
detail: "Poster / báo cáo hội nghị không phản biện.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
subgroupCode: null,
|
||||
meritLevel: null,
|
||||
detail: "Nhóm 2.1 — chưa chọn loại minh chứng trên Đơn.",
|
||||
};
|
||||
}
|
||||
|
||||
if (c === "textbook") {
|
||||
if (tb === "book") {
|
||||
return {
|
||||
subgroupCode: "2.2.1",
|
||||
meritLevel: "xuất sắc",
|
||||
detail: "Sách, giáo trình.",
|
||||
};
|
||||
}
|
||||
if (tb === "reference") {
|
||||
return {
|
||||
subgroupCode: "2.2.2",
|
||||
meritLevel: "khá",
|
||||
detail: "Tài liệu tham khảo.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
subgroupCode: null,
|
||||
meritLevel: null,
|
||||
detail: "Nhóm 2.2 — chưa chọn loại minh chứng trên Đơn.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subgroupCode: null,
|
||||
meritLevel: null,
|
||||
detail: "Chưa có phân loại sáng kiến (Nhóm 1 / 2.1 / 2.2) trên Đơn.",
|
||||
};
|
||||
}
|
||||
|
||||
/** Nhãn «Mục Đơn» trong UI admin — mục sách/giáo trình hiển thị là Xuất sắc (thay mã 2.2.1). */
|
||||
export function formatApplicationSubgroupLabel(subgroupCode: string | null): string | null {
|
||||
if (subgroupCode == null || subgroupCode === "") return null;
|
||||
if (subgroupCode === "2.2.1") return "Xuất sắc";
|
||||
return subgroupCode;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { InitiativeDraft } from '@/components/applicant/initiative-draft/types';
|
||||
import { emptyInitiativeDraft } from '@/components/applicant/initiative-draft/types';
|
||||
import type { ApplicationFormState, InitiativeFormState } from '@/components/applicant/initiativeFormTypes';
|
||||
|
||||
export type ReviewDraftTabs = {
|
||||
report?: Record<string, unknown>;
|
||||
application?: Record<string, unknown>;
|
||||
contribution?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,42 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
/** Snapshot từ tab Phiếu đánh giá — dùng khi Duyệt/Từ chối ở tab mẫu DOCX / minh chứng. */
|
||||
export type CouncilEvaluationSnapshot = {
|
||||
initiativeName: string;
|
||||
authorName: string;
|
||||
noveltyLevel: "high" | "medium" | "low" | null;
|
||||
effectivenessLevel: "high" | "medium" | "low" | null;
|
||||
totalScore: number;
|
||||
};
|
||||
|
||||
type Ctx = {
|
||||
snapshot: CouncilEvaluationSnapshot | null;
|
||||
setSnapshot: (s: CouncilEvaluationSnapshot | null) => void;
|
||||
};
|
||||
|
||||
const CouncilEvaluationSnapshotContext = createContext<Ctx | null>(null);
|
||||
|
||||
export function CouncilEvaluationSnapshotProvider({ children }: { children: ReactNode }) {
|
||||
const [snapshot, setSnapshot] = useState<CouncilEvaluationSnapshot | null>(null);
|
||||
const value = useMemo(() => ({ snapshot, setSnapshot }), [snapshot]);
|
||||
return (
|
||||
<CouncilEvaluationSnapshotContext.Provider value={value}>{children}</CouncilEvaluationSnapshotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCouncilEvaluationSnapshotWriter() {
|
||||
const ctx = useContext(CouncilEvaluationSnapshotContext);
|
||||
const setSnapshot = ctx?.setSnapshot;
|
||||
const stable = useCallback(
|
||||
(s: CouncilEvaluationSnapshot | null) => {
|
||||
setSnapshot?.(s);
|
||||
},
|
||||
[setSnapshot],
|
||||
);
|
||||
return setSnapshot ? stable : null;
|
||||
}
|
||||
|
||||
export function useCouncilEvaluationSnapshotReader() {
|
||||
const ctx = useContext(CouncilEvaluationSnapshotContext);
|
||||
return ctx?.snapshot ?? null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ApplicationFormState } from "@/components/applicant/initiativeFormTypes";
|
||||
import type { EvidenceBundle } from "@/lib/applicationEvidenceApi";
|
||||
import type { ReviewDraftTabs } from "@/components/admin/review/buildInitiativeDraftFromReviewTabs";
|
||||
|
||||
/**
|
||||
* Gộp tab Đơn từ bản xem lại hồ sơ (JSON đã lưu) để so khớp với API minh chứng.
|
||||
*/
|
||||
export function mergeApplicationTabForReview(tabs: ReviewDraftTabs | undefined): Partial<ApplicationFormState> {
|
||||
return (tabs?.application ?? {}) as Partial<ApplicationFormState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Các điểm cần lưu ý trước khi duyệt: khớp mẫu DOCX / đủ minh chứng trên kho.
|
||||
*/
|
||||
export function collectDocxTemplateCompletenessGaps(
|
||||
tabs: ReviewDraftTabs | undefined,
|
||||
bundle: EvidenceBundle | null,
|
||||
): string[] {
|
||||
const gaps: string[] = [];
|
||||
const app = mergeApplicationTabForReview(tabs);
|
||||
const name = String(app.initiativeName ?? "").trim();
|
||||
if (!name) gaps.push("Thiếu tên sáng kiến trên Đơn — mẫu DOCX có thể không đầy đủ.");
|
||||
|
||||
const authors = app.authors;
|
||||
if (!Array.isArray(authors) || authors.length === 0) {
|
||||
gaps.push("Thiếu danh sách tác giả trên Đơn.");
|
||||
}
|
||||
|
||||
const classification = app.initiativeClassification ?? null;
|
||||
if (!classification) {
|
||||
gaps.push("Chưa chọn phân loại sáng kiến (Nhóm 1 / 2.1 / 2.2).");
|
||||
return gaps;
|
||||
}
|
||||
|
||||
if (!bundle) {
|
||||
gaps.push("Chưa tải được trạng thái kho minh chứng — không kiểm tra được tệp bắt buộc.");
|
||||
return gaps;
|
||||
}
|
||||
|
||||
if (classification === "research" && (!bundle.research || !String(bundle.research.storageKey ?? "").trim())) {
|
||||
gaps.push("Nhóm 2.1: chưa có minh chứng nghiên cứu trên kho.");
|
||||
}
|
||||
if (classification === "textbook" && (!bundle.textbook || !String(bundle.textbook.storageKey ?? "").trim())) {
|
||||
gaps.push("Nhóm 2.2: chưa có minh chứng sách / giáo trình trên kho.");
|
||||
}
|
||||
if (classification === "technical" && (!bundle.technical || !String(bundle.technical.storageKey ?? "").trim())) {
|
||||
gaps.push("Nhóm 1: chưa có minh chứng đơn vị / kỹ thuật trên kho.");
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { EvidenceBundle } from "@/lib/applicationEvidenceApi";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { recommendCouncilRankFromEvidence } from "@/components/admin/review/evaluationCategoryRecommendation";
|
||||
|
||||
const bundleWithResearch: EvidenceBundle = {
|
||||
research: {
|
||||
kind: "research",
|
||||
originalName: "a.pdf",
|
||||
byteSize: 10,
|
||||
storageKey: "initiatives/x/research.pdf",
|
||||
downloadUrl: null,
|
||||
uploadedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
textbook: null,
|
||||
technical: null,
|
||||
};
|
||||
|
||||
const bundleWithTextbook: EvidenceBundle = {
|
||||
research: null,
|
||||
textbook: {
|
||||
kind: "textbook",
|
||||
originalName: "t.pdf",
|
||||
byteSize: 10,
|
||||
storageKey: "initiatives/x/textbook.pdf",
|
||||
downloadUrl: null,
|
||||
uploadedAt: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
technical: null,
|
||||
};
|
||||
|
||||
describe("recommendCouncilRankFromEvidence", () => {
|
||||
it("suggests Xuất sắc when textbook draft is Xuất sắc — Sách, giáo trình and textbook slot has a file on kho", () => {
|
||||
const r = recommendCouncilRankFromEvidence({
|
||||
application: {
|
||||
initiativeClassification: "textbook",
|
||||
textbookEvidenceKind: "book",
|
||||
},
|
||||
bundle: bundleWithTextbook,
|
||||
evidencePhase: "ready",
|
||||
});
|
||||
expect(r.rank).toContain("Xuất sắc");
|
||||
expect(r.detail).toContain("Sách, giáo trình");
|
||||
});
|
||||
|
||||
it("suggests Trung bình when research draft is 2.1.4 and research slot has a file on kho", () => {
|
||||
const r = recommendCouncilRankFromEvidence({
|
||||
application: {
|
||||
initiativeClassification: "research",
|
||||
researchEvidenceKind: "poster-without-review",
|
||||
},
|
||||
bundle: bundleWithResearch,
|
||||
evidencePhase: "ready",
|
||||
});
|
||||
expect(r.rank).toContain("Trung bình");
|
||||
expect(r.detail).toContain("2.1.4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { ApplicationFormState } from "@/components/applicant/initiativeFormTypes";
|
||||
import type { EvidenceBundle, EvidenceItemMeta } from "@/lib/applicationEvidenceApi";
|
||||
|
||||
/**
|
||||
* Trạng thái minh chứng bắt buộc theo phân loại Đơn — cùng từ vựng Đạt / Không đạt / Chưa xem như thẩm định từng dòng trên kho
|
||||
* ({@link ApplicationEvidenceDashboard}, {@link patchEvidenceReview}).
|
||||
*/
|
||||
export type RequiredSlotEvidenceStatusLabel =
|
||||
| "Đạt"
|
||||
| "Không đạt"
|
||||
| "Chưa xem"
|
||||
| "Chưa có tệp trên kho"
|
||||
| "Chưa xác định loại minh chứng"
|
||||
| "Đang tải trạng thái kho…"
|
||||
| "Chưa tải được kho";
|
||||
|
||||
export type CouncilRankRecommendation = {
|
||||
rank: string;
|
||||
detail: string;
|
||||
evidencePathLabel: string;
|
||||
/** Trạng thái thẩm định kho cho đúng một loại minh chứng bắt buộc (theo phân loại). */
|
||||
requiredSlotEvidenceStatusLabel: RequiredSlotEvidenceStatusLabel;
|
||||
};
|
||||
|
||||
export type StaffBundleEvidenceQueryPhase = "loading" | "error" | "ready";
|
||||
|
||||
/** Ánh xạ `reviewStatus` API → nhãn hiển thị đồng bộ với bảng kho. */
|
||||
export function evidenceItemReviewLabel(meta: EvidenceItemMeta | null | undefined): RequiredSlotEvidenceStatusLabel {
|
||||
if (!meta || !String(meta.storageKey ?? "").trim()) {
|
||||
return "Chưa có tệp trên kho";
|
||||
}
|
||||
const rs = meta.reviewStatus;
|
||||
if (rs === "approved") return "Đạt";
|
||||
if (rs === "rejected") return "Không đạt";
|
||||
return "Chưa xem";
|
||||
}
|
||||
|
||||
function requiredSlotStatusForClassification(
|
||||
classification: ApplicationFormState["initiativeClassification"] | null,
|
||||
bundle: EvidenceBundle | null,
|
||||
evidencePhase: StaffBundleEvidenceQueryPhase,
|
||||
): RequiredSlotEvidenceStatusLabel {
|
||||
if (!classification) return "Chưa xác định loại minh chứng";
|
||||
if (evidencePhase === "loading") return "Đang tải trạng thái kho…";
|
||||
if (evidencePhase === "error") return "Chưa tải được kho";
|
||||
const { slot } = evidenceLabelForClassification(classification);
|
||||
if (!slot) return "Chưa xác định loại minh chứng";
|
||||
return evidenceItemReviewLabel(bundle?.[slot] ?? null);
|
||||
}
|
||||
|
||||
function evidenceLabelForClassification(
|
||||
c: ApplicationFormState["initiativeClassification"],
|
||||
): { path: string; slot: keyof EvidenceBundle | null } {
|
||||
if (c === "research") return { path: "Nhóm 2.1 — minh chứng nghiên cứu (tạp chí / hội nghị)", slot: "research" };
|
||||
if (c === "textbook") return { path: "Nhóm 2.2 — minh chứng sách / giáo trình", slot: "textbook" };
|
||||
if (c === "technical") return { path: "Nhóm 1 — minh chứng áp dụng kỹ thuật / đơn vị", slot: "technical" };
|
||||
return { path: "Phân loại Đơn", slot: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gợi ý hạng xét duyệt dựa trên phân loại trên Đơn và tệp đã có trên kho minh chứng.
|
||||
*
|
||||
* @param evidencePhase Khi `loading`, không kết luận « thiếu tệp »; khi `ready`, đối chiếu `bundle`.
|
||||
*/
|
||||
export function recommendCouncilRankFromEvidence(input: {
|
||||
application: Partial<ApplicationFormState>;
|
||||
bundle: EvidenceBundle | null;
|
||||
evidencePhase?: StaffBundleEvidenceQueryPhase;
|
||||
}): CouncilRankRecommendation {
|
||||
const evidencePhase = input.evidencePhase ?? "ready";
|
||||
const classification = input.application.initiativeClassification ?? null;
|
||||
const { path, slot } = classification
|
||||
? evidenceLabelForClassification(classification)
|
||||
: { path: "Phân loại Đơn", slot: null as keyof EvidenceBundle | null };
|
||||
const meta = slot && input.bundle && evidencePhase === "ready" ? input.bundle[slot] : null;
|
||||
const hasFile = Boolean(meta?.storageKey);
|
||||
const requiredSlotEvidenceStatusLabel = requiredSlotStatusForClassification(
|
||||
classification,
|
||||
input.bundle,
|
||||
evidencePhase,
|
||||
);
|
||||
|
||||
if (!classification) {
|
||||
return {
|
||||
rank: "Chưa xác định",
|
||||
detail: "Đơn chưa có phân loại sáng kiến — cần đối chiếu tab Đơn trước khi ghi nhận hạng gợi ý.",
|
||||
evidencePathLabel: path,
|
||||
requiredSlotEvidenceStatusLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (classification === "technical") {
|
||||
return {
|
||||
rank: hasFile ? "Nhóm 01 — Tốt (đủ minh chứng kho)" : "Nhóm 01 — cần đối chiếu",
|
||||
detail: hasFile
|
||||
? "Phân loại Nhóm 1 và đã có minh chứng trên kho — có thể xét theo quy định Nhóm 01."
|
||||
: "Phân loại Nhóm 1 nhưng chưa thấy tệp minh chứng trên kho — nên mở kho minh chứng trước khi duyệt.",
|
||||
evidencePathLabel: path,
|
||||
requiredSlotEvidenceStatusLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (classification === "research" || classification === "textbook") {
|
||||
const rek = input.application.researchEvidenceKind;
|
||||
const tek = input.application.textbookEvidenceKind;
|
||||
const excellentHintResearch = classification === "research" && rek === "international" && hasFile;
|
||||
const excellentHintTextbook = classification === "textbook" && tek === "book" && hasFile;
|
||||
const excellentHint = excellentHintResearch || excellentHintTextbook;
|
||||
const mediumHint = classification === "research" && rek === "poster-without-review" && hasFile;
|
||||
return {
|
||||
rank: excellentHint
|
||||
? "Nhóm 2 — Xuất sắc (gợi ý)"
|
||||
: mediumHint
|
||||
? "Nhóm 2 — Trung bình (gợi ý)"
|
||||
: "Nhóm 2 — Tốt (gợi ý)",
|
||||
detail: excellentHintResearch
|
||||
? "Nhóm 2.1 với bài báo quốc tế và minh chứng trên kho — đủ điều kiện gợi ý xét Xuất sắc theo hướng dẫn nội bộ."
|
||||
: excellentHintTextbook
|
||||
? "Nhóm 2.2 — Xuất sắc / Sách, giáo trình với minh chứng trên kho — đủ điều kiện gợi ý xét Xuất sắc theo hướng dẫn nội bộ."
|
||||
: mediumHint
|
||||
? "Nhóm 2.1.4 (poster / hội nghị không phản biện) với minh chứng trên kho — gợi ý mức Trung bình theo Đơn."
|
||||
: hasFile
|
||||
? "Đã có minh chứng trên kho — đối chiếu nội dung tệp với Đơn trước khi chốt hạng."
|
||||
: "Chưa có minh chứng trên kho — cần bổ sung hoặc đối chiếu trước khi duyệt.",
|
||||
evidencePathLabel: path,
|
||||
requiredSlotEvidenceStatusLabel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rank: "Khác",
|
||||
detail: "Không phân loại được tự động — xem tab Đơn và kho minh chứng.",
|
||||
evidencePathLabel: path,
|
||||
requiredSlotEvidenceStatusLabel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export const COUNCIL_REVIEW_OUTCOMES_STORAGE_KEY = "council-evaluation-review-outcomes-v1";
|
||||
|
||||
export type CouncilReviewOutcomeRecord = {
|
||||
savedAt: string;
|
||||
applicationId: string;
|
||||
caseId: string;
|
||||
initiativeName: string;
|
||||
authorName: string;
|
||||
decision: "approved" | "rejected";
|
||||
recommendedRank: string;
|
||||
recommendedRankDetail: string;
|
||||
evidencePathLabel: string;
|
||||
totalScore?: number;
|
||||
feedback?: string;
|
||||
};
|
||||
|
||||
function readAll(): CouncilReviewOutcomeRecord[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(COUNCIL_REVIEW_OUTCOMES_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed) ? (parsed as CouncilReviewOutcomeRecord[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Đọc nhật ký quyết định Phiếu đánh giá (localStorage). */
|
||||
export function listCouncilReviewOutcomes(): CouncilReviewOutcomeRecord[] {
|
||||
return readAll();
|
||||
}
|
||||
|
||||
/** Lưu nhật ký quyết định Hội đồng (phiên bản front-end; có thể thay bằng API sau). */
|
||||
export const COUNCIL_REVIEW_OUTCOMES_CHANGED_EVENT = "council-review-outcomes-changed";
|
||||
|
||||
export function saveCouncilReviewOutcome(entry: Omit<CouncilReviewOutcomeRecord, "savedAt">): void {
|
||||
const row: CouncilReviewOutcomeRecord = { ...entry, savedAt: new Date().toISOString() };
|
||||
const next = [row, ...readAll()].slice(0, 500);
|
||||
localStorage.setItem(COUNCIL_REVIEW_OUTCOMES_STORAGE_KEY, JSON.stringify(next));
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(COUNCIL_REVIEW_OUTCOMES_CHANGED_EVENT));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user