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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+22
View File
@@ -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"]
+34
View File
@@ -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;"]
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UMP 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>
+59
View File
@@ -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;
}
}
+33
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

+52
View File
@@ -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 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 thao tác (Audit)</h1>
<p className="text-muted-foreground text-sm max-w-3xl">
Theo dõi CRUD đăng nhập của người nộp đơn 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 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;
}
+23
View File
@@ -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);
}
});
}
+61
View File
@@ -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;
}
+285
View File
@@ -0,0 +1,285 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
@layer base {
:root {
/* Warm canvas + cool institutional primary — clearer layers than flat gray-on-cream */
--background: 38 32% 96%;
--foreground: 222 28% 16%;
--card: 0 0% 100%;
--card-foreground: 222 28% 16%;
--popover: 0 0% 100%;
--popover-foreground: 222 28% 16%;
--primary: 217 52% 38%;
--primary-foreground: 210 40% 98%;
--secondary: 220 18% 92%;
--secondary-foreground: 222 28% 22%;
--muted: 36 22% 92%;
--muted-foreground: 220 12% 42%;
--accent: 168 42% 38%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 48%;
--destructive-foreground: 210 40% 98%;
--border: 220 14% 88%;
--input: 220 14% 88%;
--ring: 217 52% 38%;
--radius: 1rem;
/* Custom design tokens — tuned to sit cleanly on the new base */
--tag-financing: 215 48% 78%;
--tag-lifestyle: 162 36% 72%;
--tag-community: 32 42% 62%;
--tag-wellness: 278 32% 74%;
--tag-travel: 198 48% 72%;
--tag-creativity: 328 38% 76%;
--tag-growth: 48 46% 68%;
/* Polish design tokens */
--cream: 40 38% 92%;
--cream-foreground: 222 28% 16%;
--surface-elevated: 0 0% 99%;
--shadow-soft: 222 28% 16%;
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar tokens */
--sidebar-background: 220 16% 97%;
--sidebar-foreground: 222 28% 16%;
--sidebar-primary: 217 52% 38%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 220 18% 93%;
--sidebar-accent-foreground: 222 28% 22%;
--sidebar-border: 220 14% 88%;
--sidebar-ring: 217 52% 38%;
}
.dark {
--background: 222 24% 12%;
--foreground: 210 36% 96%;
--card: 222 22% 16%;
--card-foreground: 210 36% 96%;
--popover: 222 24% 12%;
--popover-foreground: 210 36% 96%;
--primary: 213 62% 58%;
--primary-foreground: 222 28% 12%;
--secondary: 217 18% 22%;
--secondary-foreground: 210 36% 96%;
--muted: 220 16% 20%;
--muted-foreground: 220 12% 64%;
--accent: 168 40% 44%;
--accent-foreground: 210 36% 98%;
--destructive: 0 63% 48%;
--destructive-foreground: 210 36% 98%;
--border: 217 16% 24%;
--input: 217 16% 24%;
--ring: 213 62% 58%;
/* Dark mode tag colors */
--tag-financing: 215 42% 42%;
--tag-lifestyle: 162 32% 40%;
--tag-community: 32 34% 42%;
--tag-wellness: 278 28% 46%;
--tag-travel: 198 42% 44%;
--tag-creativity: 328 32% 46%;
--tag-growth: 48 38% 44%;
/* Dark mode polish tokens */
--cream: 222 18% 22%;
--cream-foreground: 210 36% 96%;
--surface-elevated: 222 22% 18%;
--shadow-soft: 222 28% 6%;
/* Sidebar tokens */
--sidebar-background: 222 22% 14%;
--sidebar-foreground: 210 36% 96%;
--sidebar-primary: 213 62% 58%;
--sidebar-primary-foreground: 222 28% 12%;
--sidebar-accent: 217 18% 20%;
--sidebar-accent-foreground: 210 36% 96%;
--sidebar-border: 217 16% 24%;
--sidebar-ring: 213 62% 58%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
html {
scroll-behavior: smooth;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif tracking-tight;
}
}
@layer utilities {
.card-hover {
@apply transition-all duration-500 hover:scale-[1.02];
box-shadow: 0 4px 20px -4px hsl(var(--shadow-soft) / 0.1);
}
.card-hover:hover {
box-shadow: 0 20px 40px -10px hsl(var(--shadow-soft) / 0.15);
}
.pill-nav {
@apply rounded-full bg-[hsl(var(--surface-elevated))] backdrop-blur-lg border border-border/50;
}
.floating-button {
@apply w-12 h-12 rounded-full bg-[hsl(var(--cream)/0.9)] backdrop-blur-sm flex items-center justify-center text-[hsl(var(--cream-foreground))] hover:bg-[hsl(var(--cream))] hover:scale-110 transition-all duration-300;
box-shadow: 0 4px 12px -2px hsl(var(--shadow-soft) / 0.15);
}
/* Animation keyframes */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-slide-up {
animation: slideUp 0.6s ease-out;
}
.animate-slide-down {
animation: slideDown 0.6s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.5s ease-out;
}
/* Stagger animations */
.stagger-1 {
animation-delay: 0.1s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-2 {
animation-delay: 0.2s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-3 {
animation-delay: 0.3s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-4 {
animation-delay: 0.4s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-5 {
animation-delay: 0.5s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-6 {
animation-delay: 0.6s;
opacity: 0;
animation-fill-mode: forwards;
}
.tag-financing {
@apply bg-[hsl(var(--tag-financing))] text-primary;
}
.tag-lifestyle {
@apply bg-[hsl(var(--tag-lifestyle))] text-primary;
}
.tag-community {
@apply bg-[hsl(var(--tag-community))] text-primary;
}
.tag-wellness {
@apply bg-[hsl(var(--tag-wellness))] text-primary;
}
.tag-travel {
@apply bg-[hsl(var(--tag-travel))] text-primary;
}
.tag-creativity {
@apply bg-[hsl(var(--tag-creativity))] text-primary;
}
.tag-growth {
@apply bg-[hsl(var(--tag-growth))] text-primary;
}
}
@@ -0,0 +1,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
</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>
);
}
+98
View File
@@ -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,
},
];
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,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 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
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{card.description}</p>
</div>
</Link>
);
})}
</div>
</section>
);
})
)}
</div>
);
}
+126
View File
@@ -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 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>
);
}
+5
View File
@@ -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 phê duyệt / từ chối. Phê duyệt sẽ mở bảng điều khiển quản 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 đ 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 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"> 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 / 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>
);
}
+404
View File
@@ -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 đư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]"> 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 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> tệp .docx
khỏi kho. Thao tác này không thể hoàn tác. Đ tạm n thay xóa, hãy dùng nút 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"> 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"> 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>
);
}
+95
View File
@@ -0,0 +1,95 @@
import type { Config } from "tailwindcss";
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}", "../shared/src/**/*.{ts,tsx}"],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
serif: ['Merriweather', 'Georgia', 'serif'],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
+23
View File
@@ -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"]
}
+39
View File
@@ -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'] : [],
},
}));