sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Dev image for the admin/council SPA — built within the npm workspace (context = repo root).
|
||||
# In docker-compose the workspace dirs are bind-mounted and deps are reinstalled on start,
|
||||
# so this image just needs Node + a warm install for the first run.
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests first (layer cache). All three are needed so the workspace
|
||||
# install resolves; only the admin app + shared source are baked below.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY frontend_user/package.json ./frontend_user/
|
||||
COPY frontend_admin/package.json ./frontend_admin/
|
||||
COPY frontend_investigator/package.json ./frontend_investigator/
|
||||
COPY frontend_publisher/package.json ./frontend_publisher/
|
||||
RUN npm install
|
||||
|
||||
# Source (overridden by bind mounts in compose; baked so `docker run` also works).
|
||||
COPY shared ./shared
|
||||
COPY frontend_admin ./frontend_admin
|
||||
|
||||
EXPOSE 5174
|
||||
CMD ["sh", "-c", "npm install && npm run dev -w frontend_admin -- --host 0.0.0.0 --port 5174"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Production image for the admin/council SPA (frontend_admin).
|
||||
# Multi-stage: build the minified bundle in the npm workspace, then serve it with nginx.
|
||||
# The runtime image holds ONLY static files — no Node, no source, no Vite dev server.
|
||||
#
|
||||
# Build context = repo ROOT (the npm workspace):
|
||||
# docker build -f frontend_admin/Dockerfile.prod -t frontend_admin .
|
||||
|
||||
# ---- build stage ----
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests first (layer cache); all three are needed for `npm ci` to resolve.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY frontend_user/package.json ./frontend_user/
|
||||
COPY frontend_admin/package.json ./frontend_admin/
|
||||
COPY frontend_investigator/package.json ./frontend_investigator/
|
||||
COPY frontend_publisher/package.json ./frontend_publisher/
|
||||
RUN npm ci
|
||||
|
||||
# Only the sources this app needs: the admin app + the shared kernel (consumed as source).
|
||||
COPY shared ./shared
|
||||
COPY frontend_admin ./frontend_admin
|
||||
|
||||
# Vite production build → /app/frontend_admin/dist (minified, sourcemap:false).
|
||||
RUN npm run build -w frontend_admin
|
||||
|
||||
# ---- runtime stage (static only) ----
|
||||
FROM nginx:1.27-alpine
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY frontend_admin/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/frontend_admin/dist /usr/share/nginx/html
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UMP Sáng kiến — Quản trị / Hội đồng</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Merriweather:wght@400;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,59 @@
|
||||
# Production SPA + API reverse proxy for the admin/council app (frontend_admin, port 8080).
|
||||
# Serves the minified Vite build only — no dev server, no /src, no sourcemaps. This service is
|
||||
# bound to localhost in docker-compose.prod.yml; expose it only behind an authenticated proxy.
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
server_tokens off; # do not advertise the nginx version
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Defense-in-depth headers (inherited by locations that set no add_header of their own).
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
# See frontend_user/nginx/default.conf for the Content-Security-Policy note.
|
||||
|
||||
client_max_body_size 50m;
|
||||
|
||||
gzip on;
|
||||
gzip_comp_level 5;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Same-origin API + submitted PDFs → backend (the browser never talks to be0 directly).
|
||||
location /api/ {
|
||||
proxy_pass http://be0:4402;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
proxy_read_timeout 300;
|
||||
}
|
||||
|
||||
location /submitted-initiatives/ {
|
||||
proxy_pass http://be0:4402;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Content-addressed build assets (hashed filenames) — safe to cache hard.
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA history fallback — unknown routes return index.html, never a listing or source file.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "frontend_admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ump/shared": "*",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"axios": "^1.13.4",
|
||||
"lucide-react": "^0.462.0",
|
||||
"microdiff": "^1.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
@@ -0,0 +1,52 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth, Toaster, ForgotPasswordPage, ResetPasswordPage, RegistrationWithOtp } from '@ump/shared';
|
||||
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { DatasetsPage } from './pages/DatasetsPage';
|
||||
import { ComingSoonPage } from './pages/ComingSoonPage';
|
||||
import { AuditLogManagerPage } from './admin/audit/AuditLogManagerPage';
|
||||
import { AdminLayout } from './layouts/AdminLayout';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return <div style={{ padding: 24 }}>Đang tải…</div>;
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/admin/register" element={<RegistrationWithOtp variant="admin" loginPath="/login" />} />
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AdminLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/datasets" element={<DatasetsPage />} />
|
||||
<Route path="/models" element={<ComingSoonPage title="Mô hình ML" />} />
|
||||
<Route path="/users" element={<ComingSoonPage title="Quản lý người dùng" />} />
|
||||
<Route path="/audit" element={<AuditLogManagerPage />} />
|
||||
<Route path="/system" element={<ComingSoonPage title="Quản trị hệ thống" />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -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 '@ump/shared';
|
||||
import { ScrollArea } from '@ump/shared';
|
||||
import { Separator } from '@ump/shared';
|
||||
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 '@ump/shared';
|
||||
import { Input } from '@ump/shared';
|
||||
import { Label } from '@ump/shared';
|
||||
|
||||
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 '@ump/shared';
|
||||
|
||||
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 '@ump/shared';
|
||||
import { auditActionLabel } from '@/applicant/audit/actionLabels';
|
||||
import { Button } from '@ump/shared';
|
||||
|
||||
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,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,23 @@
|
||||
import { apiClient } from '@ump/shared';
|
||||
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 'CREATE':
|
||||
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,285 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
||||
All colors MUST be HSL.
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Warm canvas + cool institutional primary — clearer layers than flat gray-on-cream */
|
||||
--background: 38 32% 96%;
|
||||
--foreground: 222 28% 16%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 28% 16%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 28% 16%;
|
||||
|
||||
--primary: 217 52% 38%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 220 18% 92%;
|
||||
--secondary-foreground: 222 28% 22%;
|
||||
|
||||
--muted: 36 22% 92%;
|
||||
--muted-foreground: 220 12% 42%;
|
||||
|
||||
--accent: 168 42% 38%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 72% 48%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 220 14% 88%;
|
||||
--input: 220 14% 88%;
|
||||
--ring: 217 52% 38%;
|
||||
|
||||
--radius: 1rem;
|
||||
|
||||
/* Custom design tokens — tuned to sit cleanly on the new base */
|
||||
--tag-financing: 215 48% 78%;
|
||||
--tag-lifestyle: 162 36% 72%;
|
||||
--tag-community: 32 42% 62%;
|
||||
--tag-wellness: 278 32% 74%;
|
||||
--tag-travel: 198 48% 72%;
|
||||
--tag-creativity: 328 38% 76%;
|
||||
--tag-growth: 48 46% 68%;
|
||||
|
||||
/* Polish design tokens */
|
||||
--cream: 40 38% 92%;
|
||||
--cream-foreground: 222 28% 16%;
|
||||
--surface-elevated: 0 0% 99%;
|
||||
--shadow-soft: 222 28% 16%;
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Sidebar tokens */
|
||||
--sidebar-background: 220 16% 97%;
|
||||
--sidebar-foreground: 222 28% 16%;
|
||||
--sidebar-primary: 217 52% 38%;
|
||||
--sidebar-primary-foreground: 210 40% 98%;
|
||||
--sidebar-accent: 220 18% 93%;
|
||||
--sidebar-accent-foreground: 222 28% 22%;
|
||||
--sidebar-border: 220 14% 88%;
|
||||
--sidebar-ring: 217 52% 38%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 24% 12%;
|
||||
--foreground: 210 36% 96%;
|
||||
|
||||
--card: 222 22% 16%;
|
||||
--card-foreground: 210 36% 96%;
|
||||
|
||||
--popover: 222 24% 12%;
|
||||
--popover-foreground: 210 36% 96%;
|
||||
|
||||
--primary: 213 62% 58%;
|
||||
--primary-foreground: 222 28% 12%;
|
||||
|
||||
--secondary: 217 18% 22%;
|
||||
--secondary-foreground: 210 36% 96%;
|
||||
|
||||
--muted: 220 16% 20%;
|
||||
--muted-foreground: 220 12% 64%;
|
||||
|
||||
--accent: 168 40% 44%;
|
||||
--accent-foreground: 210 36% 98%;
|
||||
|
||||
--destructive: 0 63% 48%;
|
||||
--destructive-foreground: 210 36% 98%;
|
||||
|
||||
--border: 217 16% 24%;
|
||||
--input: 217 16% 24%;
|
||||
--ring: 213 62% 58%;
|
||||
|
||||
/* Dark mode tag colors */
|
||||
--tag-financing: 215 42% 42%;
|
||||
--tag-lifestyle: 162 32% 40%;
|
||||
--tag-community: 32 34% 42%;
|
||||
--tag-wellness: 278 28% 46%;
|
||||
--tag-travel: 198 42% 44%;
|
||||
--tag-creativity: 328 32% 46%;
|
||||
--tag-growth: 48 38% 44%;
|
||||
|
||||
/* Dark mode polish tokens */
|
||||
--cream: 222 18% 22%;
|
||||
--cream-foreground: 210 36% 96%;
|
||||
--surface-elevated: 222 22% 18%;
|
||||
--shadow-soft: 222 28% 6%;
|
||||
|
||||
/* Sidebar tokens */
|
||||
--sidebar-background: 222 22% 14%;
|
||||
--sidebar-foreground: 210 36% 96%;
|
||||
--sidebar-primary: 213 62% 58%;
|
||||
--sidebar-primary-foreground: 222 28% 12%;
|
||||
--sidebar-accent: 217 18% 20%;
|
||||
--sidebar-accent-foreground: 210 36% 96%;
|
||||
--sidebar-border: 217 16% 24%;
|
||||
--sidebar-ring: 213 62% 58%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-serif tracking-tight;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.card-hover {
|
||||
@apply transition-all duration-500 hover:scale-[1.02];
|
||||
box-shadow: 0 4px 20px -4px hsl(var(--shadow-soft) / 0.1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
box-shadow: 0 20px 40px -10px hsl(var(--shadow-soft) / 0.15);
|
||||
}
|
||||
|
||||
.pill-nav {
|
||||
@apply rounded-full bg-[hsl(var(--surface-elevated))] backdrop-blur-lg border border-border/50;
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
@apply w-12 h-12 rounded-full bg-[hsl(var(--cream)/0.9)] backdrop-blur-sm flex items-center justify-center text-[hsl(var(--cream-foreground))] hover:bg-[hsl(var(--cream))] hover:scale-110 transition-all duration-300;
|
||||
box-shadow: 0 4px 12px -2px hsl(var(--shadow-soft) / 0.15);
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Stagger animations */
|
||||
.stagger-1 {
|
||||
animation-delay: 0.1s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-2 {
|
||||
animation-delay: 0.2s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-3 {
|
||||
animation-delay: 0.3s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-4 {
|
||||
animation-delay: 0.4s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-5 {
|
||||
animation-delay: 0.5s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-6 {
|
||||
animation-delay: 0.6s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.tag-financing {
|
||||
@apply bg-[hsl(var(--tag-financing))] text-primary;
|
||||
}
|
||||
|
||||
.tag-lifestyle {
|
||||
@apply bg-[hsl(var(--tag-lifestyle))] text-primary;
|
||||
}
|
||||
|
||||
.tag-community {
|
||||
@apply bg-[hsl(var(--tag-community))] text-primary;
|
||||
}
|
||||
|
||||
.tag-wellness {
|
||||
@apply bg-[hsl(var(--tag-wellness))] text-primary;
|
||||
}
|
||||
|
||||
.tag-travel {
|
||||
@apply bg-[hsl(var(--tag-travel))] text-primary;
|
||||
}
|
||||
|
||||
.tag-creativity {
|
||||
@apply bg-[hsl(var(--tag-creativity))] text-primary;
|
||||
}
|
||||
|
||||
.tag-growth {
|
||||
@apply bg-[hsl(var(--tag-growth))] text-primary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Link, NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '@ump/shared';
|
||||
|
||||
import { ADMIN_NAV, ADMIN_NAV_GROUPS } from '../lib/adminNav';
|
||||
|
||||
/**
|
||||
* Admin/council shell — grouped left sidebar (DYD-reference IA) + main column.
|
||||
* Sidebar sections (CHÍNH / QUẢN LÝ / HỆ THỐNG) come from `ADMIN_NAV`; admin-only
|
||||
* surfaces are hidden for non-admin (council/editor) users.
|
||||
*/
|
||||
export function AdminLayout() {
|
||||
const { user, hasRole, logout } = useAuth();
|
||||
const isAdmin = hasRole('admin');
|
||||
const visible = ADMIN_NAV.filter((n) => !n.adminOnly || isAdmin);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
{/* ── Sidebar ───────────────────────────────────────────── */}
|
||||
<aside className="sticky top-0 flex h-screen w-64 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex h-16 shrink-0 items-center gap-3 border-b border-sidebar-border px-4"
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Đại học Y Dược Thành phố Hồ Chí Minh"
|
||||
className="h-9 w-9 shrink-0 object-contain"
|
||||
/>
|
||||
<div className="leading-tight">
|
||||
<div className="font-serif text-sm font-semibold text-sidebar-foreground">
|
||||
Đại học Y Dược
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">Thành phố Hồ Chí Minh</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="flex-1 space-y-6 overflow-y-auto px-3 py-5">
|
||||
{ADMIN_NAV_GROUPS.map((group) => {
|
||||
const items = visible.filter((n) => n.group === group);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={group} className="space-y-1">
|
||||
<div className="px-3 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? 'bg-sidebar-primary font-medium text-sidebar-primary-foreground'
|
||||
: 'text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{item.label}</span>
|
||||
{!item.ready && (
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-normal text-muted-foreground">
|
||||
sắp có
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* ── Main column ───────────────────────────────────────── */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center justify-between gap-4 border-b border-border bg-card px-6">
|
||||
<span className="text-sm font-medium text-muted-foreground">Trang Quản trị / Hội đồng</span>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="hidden text-muted-foreground sm:inline">{user?.email}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-md border px-3 py-1.5 transition-colors hover:bg-muted"
|
||||
>
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 px-6 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Database,
|
||||
Boxes,
|
||||
Users,
|
||||
ScrollText,
|
||||
SlidersHorizontal,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Single source of truth for the admin/council navigation.
|
||||
*
|
||||
* Consumed by both `AdminLayout` (grouped left sidebar) and `DashboardPage` (hub cards)
|
||||
* so the shell stays in sync as slice-5 surfaces are ported in. `ready: false` items render
|
||||
* `ComingSoonPage` until their real feature page lands at the same route.
|
||||
*
|
||||
* Information architecture mirrors the DYD admin reference: three groups —
|
||||
* CHÍNH (core review workflow) · QUẢN LÝ (managed resources) · HỆ THỐNG (system ops).
|
||||
*/
|
||||
export type AdminNavGroup = 'CHÍNH' | 'QUẢN LÝ' | 'HỆ THỐNG';
|
||||
|
||||
/** Render order of the sidebar section headers. */
|
||||
export const ADMIN_NAV_GROUPS: AdminNavGroup[] = ['CHÍNH', 'QUẢN LÝ', 'HỆ THỐNG'];
|
||||
|
||||
export interface AdminNavItem {
|
||||
to: string;
|
||||
/** Short label for the sidebar. */
|
||||
label: string;
|
||||
/** One-line description for the hub cards. */
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
group: AdminNavGroup;
|
||||
/** Admin-only surfaces are hidden from the nav for non-admin (council/editor) users. */
|
||||
adminOnly: boolean;
|
||||
/** Whether the real feature page is wired (vs. a ComingSoon placeholder). */
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export const ADMIN_NAV: AdminNavItem[] = [
|
||||
// ── CHÍNH ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
to: '/',
|
||||
label: 'Tổng quan',
|
||||
description: 'Bảng điều khiển quản trị · truy cập nhanh các chức năng',
|
||||
icon: LayoutDashboard,
|
||||
group: 'CHÍNH',
|
||||
adminOnly: false,
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
to: '/datasets',
|
||||
label: 'Kho dữ liệu lâm sàng',
|
||||
description: 'Toàn bộ bộ dữ liệu hình ảnh trên hệ thống',
|
||||
icon: Database,
|
||||
group: 'CHÍNH',
|
||||
adminOnly: true,
|
||||
ready: true,
|
||||
},
|
||||
{
|
||||
to: '/models',
|
||||
label: 'Mô hình ML',
|
||||
description: 'Quản lý mô hình học máy dùng chung',
|
||||
icon: Boxes,
|
||||
group: 'CHÍNH',
|
||||
adminOnly: true,
|
||||
ready: false,
|
||||
},
|
||||
// ── QUẢN LÝ ────────────────────────────────────────────────────────────
|
||||
{
|
||||
to: '/users',
|
||||
label: 'Người dùng',
|
||||
description: 'Quản lý tài khoản, hồ sơ và phân quyền',
|
||||
icon: Users,
|
||||
group: 'QUẢN LÝ',
|
||||
adminOnly: true,
|
||||
ready: false,
|
||||
},
|
||||
// ── HỆ THỐNG ───────────────────────────────────────────────────────────
|
||||
{
|
||||
to: '/system',
|
||||
label: 'Quản trị hệ thống',
|
||||
description: 'Cấu hình AI và ma trận phân quyền',
|
||||
icon: SlidersHorizontal,
|
||||
group: 'HỆ THỐNG',
|
||||
adminOnly: true,
|
||||
ready: false,
|
||||
},
|
||||
{
|
||||
to: '/audit',
|
||||
label: 'Nhật ký',
|
||||
description: 'Theo dõi hoạt động và lịch sử thao tác hệ thống',
|
||||
icon: ScrollText,
|
||||
group: 'HỆ THỐNG',
|
||||
adminOnly: true,
|
||||
ready: true,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) throw new Error('Root element #root not found');
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Construction } from 'lucide-react';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@ump/shared';
|
||||
|
||||
/**
|
||||
* Placeholder for admin/council nav targets whose feature pages have not been
|
||||
* migrated into frontend_admin yet (slice 5: submissions review, results, users,
|
||||
* audit, system panel). Styled with the shared design tokens so the shell looks
|
||||
* complete and nothing 404s while surfaces are ported in.
|
||||
*/
|
||||
export function ComingSoonPage({ title }: { title?: string }) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Construction className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="font-serif">{title ?? 'Tính năng đang phát triển'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chức năng này đang được chuyển vào trang quản trị. Vui lòng quay lại sau.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { getRoleDisplayName, useAuth, type Role } from '@ump/shared';
|
||||
|
||||
import { ADMIN_NAV, ADMIN_NAV_GROUPS } from '../lib/adminNav';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { user, roles, hasRole } = useAuth();
|
||||
const isAdmin = hasRole('admin');
|
||||
|
||||
// Hub cards = every nav target except the hub itself, filtered by role.
|
||||
const visible = ADMIN_NAV.filter((n) => n.to !== '/' && (!n.adminOnly || isAdmin));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="space-y-1">
|
||||
<h1 className="font-serif text-2xl font-semibold tracking-tight">
|
||||
Bảng điều khiển Quản trị / Hội đồng
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user?.name ?? user?.email}
|
||||
{' · '}
|
||||
{roles.map((r: Role) => getRoleDisplayName(r)).join(', ') || '—'}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{!isAdmin ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
Tài khoản của bạn chưa có quyền quản trị. Nếu cần truy cập các chức năng quản trị,
|
||||
vui lòng liên hệ quản trị viên hệ thống.
|
||||
</div>
|
||||
) : (
|
||||
ADMIN_NAV_GROUPS.map((group) => {
|
||||
const items = visible.filter((n) => n.group === group);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<section key={group} className="space-y-3">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{group}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((card) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Link
|
||||
key={card.to}
|
||||
to={card.to}
|
||||
className="group flex flex-col gap-3 rounded-xl border bg-card p-5 transition-colors hover:border-primary/40 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{card.label}
|
||||
{!card.ready && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||
sắp có
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{card.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Database, Search } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
listDatasets,
|
||||
VISIBILITY_LABEL,
|
||||
} from '@ump/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Clinical data repository — every imaging dataset on the platform (admin scope=all).
|
||||
* Read-only inventory for milestone 1; per-dataset drill-down lands in a later slice.
|
||||
*/
|
||||
export function DatasetsPage() {
|
||||
const [q, setQ] = useState('');
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['imagehub', 'datasets', 'all'],
|
||||
queryFn: () => listDatasets({ scope: 'all' }),
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) return data ?? [];
|
||||
return (data ?? []).filter(
|
||||
(d) =>
|
||||
d.name.toLowerCase().includes(needle) ||
|
||||
(d.ownerEmail ?? '').toLowerCase().includes(needle) ||
|
||||
d.modalityTags.some((t) => t.toLowerCase().includes(needle)),
|
||||
);
|
||||
}, [data, q]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-serif text-2xl font-semibold text-foreground">Kho dữ liệu lâm sàng</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Toàn bộ bộ dữ liệu hình ảnh trên hệ thống của các nhà nghiên cứu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
|
||||
<CardTitle className="text-base">
|
||||
{isLoading ? 'Đang tải…' : `${filtered.length} bộ dữ liệu`}
|
||||
</CardTitle>
|
||||
<div className="relative w-64 max-w-full">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Tìm theo tên, chủ sở hữu, nhãn…"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isError && <p className="text-sm text-destructive">Không tải được kho dữ liệu.</p>}
|
||||
{!isError && !isLoading && filtered.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<Database className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Chưa có bộ dữ liệu nào.</p>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tên bộ dữ liệu</TableHead>
|
||||
<TableHead>Chủ sở hữu</TableHead>
|
||||
<TableHead>Hiển thị</TableHead>
|
||||
<TableHead className="text-right">Tệp</TableHead>
|
||||
<TableHead className="text-right">Phiên bản</TableHead>
|
||||
<TableHead>Cập nhật</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div>{d.name}</div>
|
||||
{d.modalityTags.length > 0 && (
|
||||
<div className="mt-0.5 flex flex-wrap gap-1">
|
||||
{d.modalityTags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="text-[10px]">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{d.ownerEmail ?? '—'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{VISIBILITY_LABEL[d.visibility]}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{d.fileCount}</TableCell>
|
||||
<TableCell className="text-right">{d.versionCount}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{d.updatedAt ? new Date(d.updatedAt).toLocaleDateString('vi-VN') : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoginRegisterCard } from '@ump/shared';
|
||||
|
||||
export function LoginPage() {
|
||||
return <LoginRegisterCard registerPath="/admin/register" registerLabel="Đăng ký - Quản trị" />;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Textarea,
|
||||
approveProject,
|
||||
detailFromApiError,
|
||||
formatIsoToDdMmYyyyHhMm,
|
||||
listMyProjects,
|
||||
rejectProject,
|
||||
PROJECT_STATUS_LABEL,
|
||||
useAuth,
|
||||
type ProjectStatus,
|
||||
type ResearchProject,
|
||||
} from '@ump/shared';
|
||||
|
||||
const STATUS_FILTERS: { value: ProjectStatus | 'all'; label: string }[] = [
|
||||
{ value: 'submitted', label: 'Chờ duyệt' },
|
||||
{ value: 'approved', label: 'Đã duyệt' },
|
||||
{ value: 'rejected', label: 'Bị từ chối' },
|
||||
{ value: 'all', label: 'Tất cả' },
|
||||
];
|
||||
|
||||
function statusBadge(s: ProjectStatus) {
|
||||
const variant = s === 'approved' ? 'default' : s === 'rejected' ? 'destructive' : s === 'submitted' ? 'secondary' : 'outline';
|
||||
return <Badge variant={variant}>{PROJECT_STATUS_LABEL[s]}</Badge>;
|
||||
}
|
||||
|
||||
function prettyKey(k: string): string {
|
||||
const base = k.split('.').pop() ?? k;
|
||||
return base.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function contentEntries(content: Record<string, unknown>): { key: string; label: string; value: string }[] {
|
||||
return Object.entries(content)
|
||||
.filter(([, v]) => v !== '' && v != null && !(Array.isArray(v) && v.length === 0))
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
label: prettyKey(k),
|
||||
value: Array.isArray(v) ? `${v.length} mục` : String(v),
|
||||
}));
|
||||
}
|
||||
|
||||
export function ResearchReviewPage() {
|
||||
const { hasRole } = useAuth();
|
||||
const isAdmin = hasRole('admin');
|
||||
const qc = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState<ProjectStatus | 'all'>('submitted');
|
||||
const [review, setReview] = useState<ResearchProject | null>(null);
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['research-review'],
|
||||
queryFn: () => listMyProjects({ mine: false }),
|
||||
enabled: isAdmin,
|
||||
});
|
||||
const all = projectsQuery.data ?? [];
|
||||
const rows = useMemo(
|
||||
() => (statusFilter === 'all' ? all : all.filter((p) => p.status === statusFilter)),
|
||||
[all, statusFilter],
|
||||
);
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['research-review'] });
|
||||
const approveMut = useMutation({
|
||||
mutationFn: (v: { id: string; code?: string; note?: string }) => approveProject(v.id, { code: v.code, note: v.note }),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã phê duyệt đề tài — bảng điều khiển của chủ nhiệm đã mở.');
|
||||
setReview(null);
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(detailFromApiError(e, 'Phê duyệt thất bại.')),
|
||||
});
|
||||
const rejectMut = useMutation({
|
||||
mutationFn: (v: { id: string; note?: string }) => rejectProject(v.id, { note: v.note }),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã từ chối đề tài.');
|
||||
setReview(null);
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(detailFromApiError(e, 'Từ chối thất bại.')),
|
||||
});
|
||||
|
||||
if (!isAdmin) {
|
||||
return <p className="text-sm text-muted-foreground">Chỉ tài khoản quản trị mới thẩm định được đề tài.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Thẩm định đề tài nghiên cứu</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Xem các thuyết minh đề tài đã nộp và phê duyệt / từ chối. Phê duyệt sẽ mở bảng điều khiển quản lý cho chủ nhiệm.
|
||||
</p>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as ProjectStatus | 'all')}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Danh sách đề tài</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">STT</TableHead>
|
||||
<TableHead className="min-w-[240px]">Tên đề tài</TableHead>
|
||||
<TableHead className="min-w-[160px]">Chủ nhiệm</TableHead>
|
||||
<TableHead className="w-[140px]">Cấp</TableHead>
|
||||
<TableHead className="w-[150px]">Ngày nộp</TableHead>
|
||||
<TableHead className="w-[120px]">Trạng thái</TableHead>
|
||||
<TableHead className="w-[120px] text-center">Thao tác</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projectsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">Đang tải…</TableCell>
|
||||
</TableRow>
|
||||
) : projectsQuery.isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-destructive">Không tải được danh sách đề tài.</TableCell>
|
||||
</TableRow>
|
||||
) : rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">Không có đề tài nào.</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((p, i) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">{p.title || '(Chưa đặt tên)'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{p.piName || '—'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{p.level || '—'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.submittedAt ? formatIsoToDdMmYyyyHhMm(p.submittedAt) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>{statusBadge(p.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button size="sm" variant="outline" onClick={() => setReview(p)}>
|
||||
{p.status === 'submitted' ? 'Xem & duyệt' : 'Xem'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ReviewDialog
|
||||
project={review}
|
||||
submitting={approveMut.isPending || rejectMut.isPending}
|
||||
onOpenChange={(o) => !o && setReview(null)}
|
||||
onApprove={(code, note) => review && approveMut.mutate({ id: review.id, code, note })}
|
||||
onReject={(note) => review && rejectMut.mutate({ id: review.id, note })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewDialog(props: {
|
||||
project: ResearchProject | null;
|
||||
submitting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onApprove: (code?: string, note?: string) => void;
|
||||
onReject: (note?: string) => void;
|
||||
}) {
|
||||
const { project, submitting, onOpenChange, onApprove, onReject } = props;
|
||||
const [code, setCode] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [syncedId, setSyncedId] = useState<string | null>(null);
|
||||
|
||||
if (project && project.id !== syncedId) {
|
||||
setSyncedId(project.id);
|
||||
setCode(project.code ?? '');
|
||||
setNote(project.reviewNote ?? '');
|
||||
}
|
||||
|
||||
const entries = project ? contentEntries(project.content) : [];
|
||||
const pending = project?.status === 'submitted';
|
||||
|
||||
return (
|
||||
<Dialog open={project != null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-serif">{project?.title || 'Thuyết minh đề tài'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{project && (
|
||||
<div className="space-y-4 py-1 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||
<div><span className="text-muted-foreground">Chủ nhiệm:</span> {project.piName || '—'}</div>
|
||||
<div><span className="text-muted-foreground">Cấp:</span> {project.level || '—'}</div>
|
||||
<div><span className="text-muted-foreground">Thời gian:</span> {project.periodMonths ? `${project.periodMonths} tháng` : '—'}</div>
|
||||
<div><span className="text-muted-foreground">Kinh phí:</span> {project.budgetTotal != null ? `${project.budgetTotal} tr.đ` : '—'}</div>
|
||||
<div className="col-span-2"><span className="text-muted-foreground">Trạng thái:</span> {statusBadge(project.status)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1 block">Nội dung thuyết minh</Label>
|
||||
<div className="max-h-64 space-y-1.5 overflow-y-auto rounded-md border bg-muted/30 p-3">
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Không có nội dung.</p>
|
||||
) : (
|
||||
entries.map((e) => (
|
||||
<div key={e.key} className="grid grid-cols-[180px_1fr] gap-2">
|
||||
<span className="truncate text-xs text-muted-foreground" title={e.label}>{e.label}</span>
|
||||
<span className="whitespace-pre-wrap break-words">{e.value}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pending ? (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rv-code">Mã số (cấp khi phê duyệt, tùy chọn)</Label>
|
||||
<Input id="rv-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="VD: ĐTUD-2026-001" disabled={submitting} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="rv-note">Nhận xét / lý do</Label>
|
||||
<Textarea id="rv-note" value={note} onChange={(e) => setNote(e.target.value)} placeholder="Nhận xét của hội đồng…" disabled={submitting} />
|
||||
</div>
|
||||
</div>
|
||||
) : project.reviewNote ? (
|
||||
<div className="border-t pt-3">
|
||||
<Label className="mb-1 block">Nhận xét</Label>
|
||||
<p className="text-muted-foreground">{project.reviewNote}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{pending ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onReject(note.trim() || undefined)} disabled={submitting} className="gap-1.5">
|
||||
<XCircle className="h-4 w-4" /> Từ chối
|
||||
</Button>
|
||||
<Button onClick={() => onApprove(code.trim() || undefined, note.trim() || undefined)} disabled={submitting} className="gap-1.5">
|
||||
<CheckCircle2 className="h-4 w-4" /> Phê duyệt
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>Đóng</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FileDown, Pencil, Plus, Power, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
useAuth,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Button,
|
||||
buttonVariants,
|
||||
Input,
|
||||
Label,
|
||||
Textarea,
|
||||
Badge,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
cn,
|
||||
formatIsoToDdMmYyyyHhMm,
|
||||
detailFromApiError,
|
||||
listTemplates,
|
||||
createTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
downloadTemplateFile,
|
||||
saveArrayBufferAs,
|
||||
TEMPLATE_DOCX_MIME,
|
||||
type DocumentTemplate,
|
||||
} from '@ump/shared';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function TemplatesPage() {
|
||||
const { hasRole } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const isAdmin = hasRole('admin');
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: listTemplates,
|
||||
enabled: isAdmin,
|
||||
});
|
||||
const templates = templatesQuery.data ?? [];
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DocumentTemplate | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DocumentTemplate | null>(null);
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['templates'] });
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: createTemplate,
|
||||
onSuccess: (t) => {
|
||||
toast.success(`Đã tạo mẫu "${t.name}" — ${t.fields.length} trường.`);
|
||||
setCreateOpen(false);
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(detailFromApiError(e, 'Không tạo được mẫu.')),
|
||||
});
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (v: { id: string; patch: { name?: string; description?: string; isActive?: boolean } }) =>
|
||||
updateTemplate(v.id, v.patch),
|
||||
onSuccess: () => {
|
||||
setEditing(null);
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(detailFromApiError(e, 'Không cập nhật được mẫu.')),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => deleteTemplate(id, { hard: true }),
|
||||
onSuccess: () => {
|
||||
toast.success('Đã xóa mẫu.');
|
||||
setDeleteTarget(null);
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => toast.error(detailFromApiError(e, 'Không xóa được mẫu.')),
|
||||
});
|
||||
|
||||
const handleDownload = async (t: DocumentTemplate) => {
|
||||
try {
|
||||
const buf = await downloadTemplateFile(t.id);
|
||||
saveArrayBufferAs(buf, t.originalFilename || `${t.name}.docx`, TEMPLATE_DOCX_MIME);
|
||||
} catch (e) {
|
||||
toast.error(detailFromApiError(e, 'Không tải được tệp mẫu.'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chỉ tài khoản quản trị mới quản lý được mẫu tài liệu.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Mẫu tài liệu</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tải lên mẫu .docx (Word) với các trường <code>{'{{ ten_truong }}'}</code>. Người nộp đơn
|
||||
sẽ điền theo từng mẫu.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Tạo mẫu mới
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Danh sách mẫu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">STT</TableHead>
|
||||
<TableHead className="min-w-[200px]">Tên mẫu</TableHead>
|
||||
<TableHead className="min-w-[200px]">Mô tả</TableHead>
|
||||
<TableHead className="w-[90px] text-center">Số trường</TableHead>
|
||||
<TableHead className="w-[120px]">Trạng thái</TableHead>
|
||||
<TableHead className="w-[150px]">Cập nhật</TableHead>
|
||||
<TableHead className="w-[200px] text-center">Thao tác</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{templatesQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
Đang tải…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : templatesQuery.isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-destructive">
|
||||
Không tải được danh sách mẫu.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : templates.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-10 text-center text-muted-foreground">
|
||||
Chưa có mẫu nào. Bấm « Tạo mẫu mới » để tải lên.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
templates.map((t, i) => (
|
||||
<TableRow key={t.id} data-state={t.isActive ? undefined : 'selected'}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium" title={t.originalFilename ?? ''}>
|
||||
{t.name}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] truncate text-muted-foreground" title={t.description ?? ''}>
|
||||
{t.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary">{t.fields.length}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.isActive ? (
|
||||
<Badge className="bg-green-600 hover:bg-green-600/90">Đang dùng</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Ẩn</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{t.updatedAt ? formatIsoToDdMmYyyyHhMm(t.updatedAt) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<Button size="icon" variant="outline" title="Tải tệp .docx" onClick={() => void handleDownload(t)}>
|
||||
<FileDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="icon" variant="outline" title="Sửa thông tin" onClick={() => setEditing(t)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={t.isActive ? 'Ẩn mẫu (vô hiệu hóa)' : 'Hiện mẫu (kích hoạt)'}
|
||||
disabled={updateMut.isPending}
|
||||
onClick={() => updateMut.mutate({ id: t.id, patch: { isActive: !t.isActive } })}
|
||||
>
|
||||
<Power className={cn('h-4 w-4', t.isActive ? 'text-green-600' : 'text-muted-foreground')} />
|
||||
</Button>
|
||||
<Button size="icon" variant="destructive" title="Xóa vĩnh viễn" onClick={() => setDeleteTarget(t)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<CreateTemplateDialog
|
||||
open={createOpen}
|
||||
submitting={createMut.isPending}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={(v) => createMut.mutate(v)}
|
||||
/>
|
||||
|
||||
<EditTemplateDialog
|
||||
template={editing}
|
||||
submitting={updateMut.isPending}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
onSubmit={(patch) => editing && updateMut.mutate({ id: editing.id, patch })}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteTarget != null} onOpenChange={(o) => !o && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Xóa mẫu vĩnh viễn?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget ? (
|
||||
<>
|
||||
Xóa hẳn mẫu <span className="font-medium text-foreground">"{deleteTarget.name}"</span> và tệp .docx
|
||||
khỏi kho. Thao tác này không thể hoàn tác. Để tạm ẩn thay vì xóa, hãy dùng nút vô hiệu hóa.
|
||||
</>
|
||||
) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Hủy</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={cn(buttonVariants({ variant: 'destructive' }))}
|
||||
onClick={() => deleteTarget && deleteMut.mutate(deleteTarget.id)}
|
||||
>
|
||||
Xóa vĩnh viễn
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTemplateDialog(props: {
|
||||
open: boolean;
|
||||
submitting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (v: { name: string; description?: string; file: File }) => void;
|
||||
}) {
|
||||
const { open, submitting, onOpenChange, onSubmit } = props;
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reset = () => {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setFile(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!name.trim()) return toast.error('Nhập tên mẫu.');
|
||||
if (!file) return toast.error('Chọn tệp .docx.');
|
||||
if (!file.name.toLowerCase().endsWith('.docx')) return toast.error('Chỉ chấp nhận tệp .docx (Word).');
|
||||
onSubmit({ name: name.trim(), description: description.trim() || undefined, file });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) reset();
|
||||
onOpenChange(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Tạo mẫu tài liệu mới</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tpl-name">Tên mẫu</Label>
|
||||
<Input id="tpl-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="VD: Đơn xin nghỉ phép" disabled={submitting} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tpl-desc">Mô tả (tùy chọn)</Label>
|
||||
<Textarea id="tpl-desc" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Mục đích của mẫu này…" disabled={submitting} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tpl-file">Tệp .docx (Word, chứa các trường {'{{ ten }}'})</Label>
|
||||
<Input
|
||||
id="tpl-file"
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{file ? <p className="text-xs text-muted-foreground">Đã chọn: {file.name}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button type="button" onClick={submit} disabled={submitting}>
|
||||
{submitting ? 'Đang tải lên…' : 'Tạo mẫu'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTemplateDialog(props: {
|
||||
template: DocumentTemplate | null;
|
||||
submitting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (patch: { name?: string; description?: string }) => void;
|
||||
}) {
|
||||
const { template, submitting, onOpenChange, onSubmit } = props;
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Sync local state when a new template is opened.
|
||||
const [syncedId, setSyncedId] = useState<string | null>(null);
|
||||
if (template && template.id !== syncedId) {
|
||||
setSyncedId(template.id);
|
||||
setName(template.name);
|
||||
setDescription(template.description ?? '');
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={template != null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sửa thông tin mẫu</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tpl-edit-name">Tên mẫu</Label>
|
||||
<Input id="tpl-edit-name" value={name} onChange={(e) => setName(e.target.value)} disabled={submitting} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tpl-edit-desc">Mô tả</Label>
|
||||
<Textarea id="tpl-edit-desc" value={description} onChange={(e) => setDescription(e.target.value)} disabled={submitting} />
|
||||
</div>
|
||||
{template ? (
|
||||
<div className="space-y-1">
|
||||
<Label>Các trường ({template.fields.length})</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{template.fields.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">Không phát hiện trường {'{{ }}'} nào.</span>
|
||||
) : (
|
||||
template.fields.map((f) => (
|
||||
<Badge key={f.key} variant="outline" className="font-mono text-xs">
|
||||
{f.key}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!name.trim()) return toast.error('Tên mẫu không được để trống.');
|
||||
onSubmit({ name: name.trim(), description: description.trim() });
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Đang lưu…' : 'Lưu'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}", "../shared/src/**/*.{ts,tsx}"],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: "2rem",
|
||||
screens: {
|
||||
"2xl": "1400px",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
serif: ['Merriweather', 'Georgia', 'serif'],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar-background))",
|
||||
foreground: "hsl(var(--sidebar-foreground))",
|
||||
primary: "hsl(var(--sidebar-primary))",
|
||||
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
|
||||
accent: "hsl(var(--sidebar-accent))",
|
||||
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: {
|
||||
height: "0",
|
||||
},
|
||||
to: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
},
|
||||
"accordion-up": {
|
||||
from: {
|
||||
height: "var(--radix-accordion-content-height)",
|
||||
},
|
||||
to: {
|
||||
height: "0",
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@ump/shared": ["../shared/src/index.ts"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import path from 'path';
|
||||
|
||||
// Proxy /api → be0 (locally :4402; in Docker set VITE_DEV_PROXY_TARGET=http://be0:4402).
|
||||
const apiProxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:4402';
|
||||
// Vite 5 checks the Host header; allow all when VITE_ALLOWED_HOSTS is unset, else the list.
|
||||
const allowedHosts = (process.env.VITE_ALLOWED_HOSTS ?? '')
|
||||
.split(',')
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
allowedHosts: allowedHosts.length > 0 ? allowedHosts : true,
|
||||
proxy: {
|
||||
'/api': { target: apiProxyTarget, changeOrigin: true, secure: false },
|
||||
'/submitted-initiatives': { target: apiProxyTarget, changeOrigin: true, secure: false },
|
||||
},
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
// Consume the shared kernel as source (no build step) — Vite transpiles it.
|
||||
'@ump/shared': path.resolve(__dirname, '../shared/src/index.ts'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// Never ship source maps — keeps the original TypeScript un-reconstructable in DevTools.
|
||||
sourcemap: false,
|
||||
},
|
||||
esbuild: {
|
||||
// Strip console/debugger from production bundles so no debug/internal info leaks.
|
||||
drop: mode === 'production' ? ['console', 'debugger'] : [],
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user