sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
# Dev image for the publisher 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 are needed so the workspace install resolves.
|
||||
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_publisher ./frontend_publisher
|
||||
|
||||
EXPOSE 5176
|
||||
CMD ["sh", "-c", "npm install && npm run dev -w frontend_publisher -- --host 0.0.0.0 --port 5176"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Production image for the publisher SPA (frontend_publisher).
|
||||
# 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_publisher/Dockerfile.prod -t frontend_publisher .
|
||||
|
||||
# ---- build stage ----
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# Workspace manifests first (layer cache); all 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 publisher app + the shared kernel (consumed as source).
|
||||
COPY shared ./shared
|
||||
COPY frontend_publisher ./frontend_publisher
|
||||
|
||||
# Vite production build → /app/frontend_publisher/dist (minified, sourcemap:false).
|
||||
RUN npm run build -w frontend_publisher
|
||||
|
||||
# ---- runtime stage (static only) ----
|
||||
FROM nginx:1.27-alpine
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY frontend_publisher/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/frontend_publisher/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 · Công bố kết quả nghiên cứu</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,63 @@
|
||||
# Production SPA + API reverse proxy for the publisher app (frontend_publisher, port 8080).
|
||||
# Serves the minified Vite build only — no dev server, no /src, no sourcemaps. TLS terminates
|
||||
# on the host reverse proxy (Caddy/nginx); this listens HTTP inside the Docker network.
|
||||
|
||||
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;
|
||||
# A Content-Security-Policy is recommended but must enumerate the app's real sources.
|
||||
# The applicant PDF export currently pulls @react-pdf NotoSerif fonts from remote URLs —
|
||||
# self-host those first, then enable a locked-down policy, e.g.:
|
||||
# add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self'" always;
|
||||
|
||||
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.
|
||||
# Only `expires` here so the server-level security headers are still inherited.
|
||||
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,35 @@
|
||||
{
|
||||
"name": "frontend_publisher",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@ump/shared": "*",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"axios": "^1.13.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"sonner": "^1.7.4"
|
||||
},
|
||||
"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",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
@@ -0,0 +1,72 @@
|
||||
import { lazy, Suspense, type ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth, Toaster, ForgotPasswordPage, ResetPasswordPage, RegistrationWithOtp } from '@ump/shared';
|
||||
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { PublishablePapersList } from './pages/PublishablePapersList';
|
||||
import { PaperComposerPage } from './pages/PaperComposerPage';
|
||||
import { GovernancePage } from './pages/GovernancePage';
|
||||
import { ComingSoonPage } from './pages/ComingSoonPage';
|
||||
import { DashboardLayout } from './layouts/DashboardLayout';
|
||||
|
||||
// PDF-heavy (@react-pdf/renderer ~3 MB) — lazy so its chunk loads only on the preview route.
|
||||
const PaperPreviewPage = lazy(() =>
|
||||
import('./pages/PaperPreviewPage').then((m) => ({ default: m.PaperPreviewPage })),
|
||||
);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) return <div style={{ padding: 24 }}>Đang tải…</div>;
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
<Route path="/register" element={<RegistrationWithOtp variant="applicant" loginPath="/login" />} />
|
||||
|
||||
{/* Authed publisher shell: sidebar + header wrap the routed content. */}
|
||||
<Route
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DashboardLayout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
{/* Publish journey: publishable projects → compose a paper → human-approve & publish → preview/export. */}
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/papers" replace />} />
|
||||
<Route path="/dashboard/papers" element={<PublishablePapersList />} />
|
||||
<Route path="/dashboard/governance" element={<GovernancePage />} />
|
||||
{/* "compose" is a static segment so it outranks the dynamic :paperId route below. */}
|
||||
<Route path="/dashboard/papers/:projectId/compose" element={<PaperComposerPage />} />
|
||||
<Route
|
||||
path="/dashboard/papers/:projectId/:paperId"
|
||||
element={
|
||||
<Suspense fallback={<div style={{ padding: 24 }}>Đang tải…</div>}>
|
||||
<PaperPreviewPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
{/* Sidebar targets whose feature pages are not built yet → ComingSoon. */}
|
||||
<Route path="/dashboard/notifications" element={<ComingSoonPage />} />
|
||||
<Route path="/dashboard/help" element={<ComingSoonPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<Toaster />
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
|
||||
|
||||
export interface MenuItemDescriptor {
|
||||
title: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ApplicantSidebarMenuItemProps {
|
||||
item: MenuItemDescriptor;
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
export function ApplicantSidebarMenuItem({ item, pathname }: ApplicantSidebarMenuItemProps) {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.url;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={active} tooltip={item.title}>
|
||||
<Link to={item.url} className="flex mt-4 w-full items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Bell, BookOpenCheck, HelpCircle, LogOut, ScrollText } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@ump/shared";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarSeparator,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
ApplicantSidebarMenuItem,
|
||||
type MenuItemDescriptor,
|
||||
} from "@/components/applicant/ApplicantSidebarMenuItem";
|
||||
|
||||
const LOGO_SRC = "/logo.png";
|
||||
|
||||
// Publisher journey entry — publish papers from research results.
|
||||
const papersItem: MenuItemDescriptor = {
|
||||
title: "Công bố bài báo",
|
||||
icon: BookOpenCheck,
|
||||
url: "/dashboard/papers",
|
||||
};
|
||||
|
||||
// Governance — the operating philosophy that governs the publish workflow.
|
||||
const governanceItem: MenuItemDescriptor = {
|
||||
title: "Triết lý vận hành",
|
||||
icon: ScrollText,
|
||||
url: "/dashboard/governance",
|
||||
};
|
||||
|
||||
const systemItems: MenuItemDescriptor[] = [
|
||||
{ title: "Thông báo", icon: Bell, url: "/dashboard/notifications" },
|
||||
{ title: "Trợ giúp", icon: HelpCircle, url: "/dashboard/help" },
|
||||
];
|
||||
|
||||
export function ApplicantDashboardSidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { state } = useSidebar();
|
||||
const { logout } = useAuth();
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
// Papers item is active for any /dashboard/papers* route.
|
||||
const pathname = location.pathname.startsWith("/dashboard/papers")
|
||||
? papersItem.url
|
||||
: location.pathname;
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="h-16 justify-center border-b border-sidebar-border px-4">
|
||||
<Link to="/dashboard" className="flex items-center gap-2">
|
||||
<img
|
||||
src={LOGO_SRC}
|
||||
alt="Đại học Y Dược Thành phố Hồ Chí Minh"
|
||||
className="h-9 w-9 object-contain flex-shrink-0"
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="leading-tight">
|
||||
<div className="font-serif text-sm font-semibold text-sidebar-foreground">
|
||||
Đại học Y Dược
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">Thành phố Hồ Chí Minh</div>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<ApplicantSidebarMenuItem item={papersItem} pathname={pathname} />
|
||||
<ApplicantSidebarMenuItem item={governanceItem} pathname={location.pathname} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{systemItems.map((item) => (
|
||||
<ApplicantSidebarMenuItem key={item.url} item={item} pathname={location.pathname} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="border-t border-sidebar-border p-2">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={handleLogout}
|
||||
tooltip="Đăng xuất"
|
||||
className="text-destructive text-bg-black hover:text-destructive cursor-pointer"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Đăng xuất</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Sidebar primitive — faithful reproduction of fe0's shadcn sidebar
|
||||
* (fe0/src/components/ui/sidebar.tsx), trimmed for the @ump/shared kernel.
|
||||
*
|
||||
* Differences vs fe0 (kept intentionally bounded for this shell migration):
|
||||
* - cn / Button / Input / Separator come from @ump/shared (not local @/lib, @/components/ui).
|
||||
* - useIsMobile is inlined here (fe0 imports it from @/hooks/use-mobile).
|
||||
* - No Radix Tooltip dependency: SidebarMenuButton's `tooltip` prop renders via the
|
||||
* native `title` attribute (still shows the label on the collapsed icon rail).
|
||||
* - No mobile Sheet / Skeleton sub-deps: the desktop fixed rail renders at all widths.
|
||||
* Visual classNames + the --sidebar-* design tokens are preserved verbatim.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
|
||||
import { cn, Button, Input, Separator } from "@ump/shared";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar:state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
mql.addEventListener("change", onChange);
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
type SidebarContext = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContext | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const SidebarProvider = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContext>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn("group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
});
|
||||
SidebarProvider.displayName = "SidebarProvider";
|
||||
|
||||
const Sidebar = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}
|
||||
>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
|
||||
const { state } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Sidebar.displayName = "Sidebar";
|
||||
|
||||
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||
({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-7 w-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeft />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarTrigger.displayName = "SidebarTrigger";
|
||||
|
||||
const SidebarRail = React.forwardRef<HTMLButtonElement, React.ComponentProps<"button">>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
data-sidebar="rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 hover:after:bg-sidebar-border sm:flex",
|
||||
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarRail.displayName = "SidebarRail";
|
||||
|
||||
const SidebarInset = React.forwardRef<HTMLDivElement, React.ComponentProps<"main">>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<main
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex min-h-svh flex-1 flex-col bg-background",
|
||||
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarInset.displayName = "SidebarInset";
|
||||
|
||||
const SidebarInput = React.forwardRef<React.ElementRef<typeof Input>, React.ComponentProps<typeof Input>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
data-sidebar="input"
|
||||
className={cn(
|
||||
"h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarInput.displayName = "SidebarInput";
|
||||
|
||||
const SidebarHeader = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
|
||||
return <div ref={ref} data-sidebar="header" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
|
||||
});
|
||||
SidebarHeader.displayName = "SidebarHeader";
|
||||
|
||||
const SidebarFooter = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
|
||||
return <div ref={ref} data-sidebar="footer" className={cn("flex flex-col gap-2 p-2", className)} {...props} />;
|
||||
});
|
||||
SidebarFooter.displayName = "SidebarFooter";
|
||||
|
||||
const SidebarSeparator = React.forwardRef<React.ElementRef<typeof Separator>, React.ComponentProps<typeof Separator>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<Separator
|
||||
ref={ref}
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarSeparator.displayName = "SidebarSeparator";
|
||||
|
||||
const SidebarContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarContent.displayName = "SidebarContent";
|
||||
|
||||
const SidebarGroup = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarGroup.displayName = "SidebarGroup";
|
||||
|
||||
const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.ComponentProps<"div"> & { asChild?: boolean }>(
|
||||
({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
const SidebarGroupContent = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} data-sidebar="group-content" className={cn("w-full text-sm", className)} {...props} />
|
||||
),
|
||||
);
|
||||
SidebarGroupContent.displayName = "SidebarGroupContent";
|
||||
|
||||
const SidebarMenu = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(({ className, ...props }, ref) => (
|
||||
<ul ref={ref} data-sidebar="menu" className={cn("flex w-full min-w-0 flex-col gap-1", className)} {...props} />
|
||||
));
|
||||
SidebarMenu.displayName = "SidebarMenu";
|
||||
|
||||
const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} data-sidebar="menu-item" className={cn("group/menu-item relative", className)} {...props} />
|
||||
));
|
||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(({ asChild = false, isActive = false, variant = "default", size = "default", tooltip, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
title={tooltip}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 after:md:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
const SidebarMenuBadge = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenuBadge.displayName = "SidebarMenuBadge";
|
||||
|
||||
const SidebarMenuSub = React.forwardRef<HTMLUListElement, React.ComponentProps<"ul">>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
SidebarMenuSub.displayName = "SidebarMenuSub";
|
||||
|
||||
const SidebarMenuSubItem = React.forwardRef<HTMLLIElement, React.ComponentProps<"li">>(({ ...props }, ref) => (
|
||||
<li ref={ref} {...props} />
|
||||
));
|
||||
SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
|
||||
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Thin publisher API helpers over the existing research endpoints.
|
||||
*
|
||||
* Papers have no dedicated backend: they live in `ResearchProject.content.papers[]`, read via the
|
||||
* cockpit bundle and written via the merge endpoint (which records an audit row server-side, INV-6).
|
||||
* There is no per-paper endpoint, so saving is read-modify-write of the whole array.
|
||||
*/
|
||||
import {
|
||||
getCockpit,
|
||||
listMyProjects,
|
||||
updateProjectDetail,
|
||||
type CockpitBundle,
|
||||
type ResearchProject,
|
||||
} from '@ump/shared';
|
||||
|
||||
import { extractPapers, type Paper } from '../domain/paperModel';
|
||||
|
||||
/** Approved projects the investigator owns — the publishable research-result sources. */
|
||||
export async function listPublishableProjects(): Promise<ResearchProject[]> {
|
||||
const projects = await listMyProjects();
|
||||
return projects.filter((p) => p.status === 'approved');
|
||||
}
|
||||
|
||||
export interface ProjectResearch {
|
||||
bundle: CockpitBundle;
|
||||
papers: Paper[];
|
||||
}
|
||||
|
||||
/** Read a project's full research results (cockpit) plus its current papers. */
|
||||
export async function getProjectResearch(projectId: string): Promise<ProjectResearch> {
|
||||
const bundle = await getCockpit(projectId);
|
||||
return { bundle, papers: extractPapers(bundle.project.content) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the whole papers array into `content.papers[]` via the merge endpoint.
|
||||
* Returns the updated project.
|
||||
*/
|
||||
export function savePapers(projectId: string, papers: Paper[]): Promise<ResearchProject> {
|
||||
return updateProjectDetail(projectId, { papers });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { emptyPaper, publishPaper, type Paper } from './paperModel';
|
||||
import {
|
||||
OPERATING_PRINCIPLES,
|
||||
statusFromPapers,
|
||||
humanApprovalIntact,
|
||||
} from './governance';
|
||||
|
||||
function readyDraft(): Paper {
|
||||
return {
|
||||
...emptyPaper(),
|
||||
title: 'T',
|
||||
abstractVi: 'A',
|
||||
body: 'B',
|
||||
sources: [{ id: 's1', title: 'S', verifiedReal: true }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('governance', () => {
|
||||
it('exposes the four operating principles, one flagged danger (INV-4 gate)', () => {
|
||||
expect(OPERATING_PRINCIPLES).toHaveLength(4);
|
||||
expect(OPERATING_PRINCIPLES.filter((p) => p.tone === 'danger')).toHaveLength(1);
|
||||
expect(OPERATING_PRINCIPLES.map((p) => p.id)).toEqual(['inv-1', 'inv-2', 'inv-3', 'inv-4']);
|
||||
});
|
||||
|
||||
it('statusFromPapers counts published vs drafts and integrity-blocked drafts', () => {
|
||||
const blocked = emptyPaper(); // no title/abstract/body/sources → blocked
|
||||
const ready = readyDraft();
|
||||
const published = publishPaper(readyDraft(), { id: 'u1', name: 'TS. An' });
|
||||
|
||||
const s = statusFromPapers([blocked, ready, published]);
|
||||
expect(s.totalPapers).toBe(3);
|
||||
expect(s.published).toBe(1);
|
||||
expect(s.drafts).toBe(2);
|
||||
expect(s.draftsBlockedByIntegrity).toBe(1);
|
||||
expect(s.draftsReadyToPublish).toBe(1);
|
||||
expect(s.publishedWithApproval).toBe(1);
|
||||
});
|
||||
|
||||
it('humanApprovalIntact (INV-1) is true when every published paper has an approval', () => {
|
||||
const published = publishPaper(readyDraft(), { id: 'u1', name: 'TS. An' });
|
||||
expect(humanApprovalIntact(statusFromPapers([published]))).toBe(true);
|
||||
});
|
||||
|
||||
it('humanApprovalIntact is false if a published paper lacks an approval record', () => {
|
||||
const forged: Paper = { ...readyDraft(), status: 'published', approval: null };
|
||||
expect(humanApprovalIntact(statusFromPapers([forged]))).toBe(false);
|
||||
});
|
||||
|
||||
it('an empty corpus is trivially compliant', () => {
|
||||
const s = statusFromPapers([]);
|
||||
expect(s.totalPapers).toBe(0);
|
||||
expect(humanApprovalIntact(s)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Governance model for the publisher workflow — the "Triết lý vận hành" (operating philosophy).
|
||||
*
|
||||
* These are the spec's core invariants (academic-research-skills-spec.md §1) expressed as the
|
||||
* principles that govern publishing, each paired with how the publisher *actually* enforces it.
|
||||
* The compliance status is computed from the investigator's papers so the page is a live
|
||||
* governance dashboard, not just a poster.
|
||||
*/
|
||||
import { extractPapers, integrityIssues, type Paper } from './paperModel';
|
||||
import type { ResearchProject } from '@ump/shared';
|
||||
|
||||
/** Icon keys resolved to lucide components in the presentation layer (keeps this module pure). */
|
||||
export type PrincipleIcon = 'human' | 'bot' | 'sourced' | 'gate';
|
||||
|
||||
export interface GovernancePrinciple {
|
||||
id: 'inv-1' | 'inv-2' | 'inv-3' | 'inv-4';
|
||||
icon: PrincipleIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
/** danger ⇒ rendered with the red accent (the diagram's red "!"). */
|
||||
tone: 'default' | 'danger';
|
||||
/** How the publisher workflow enforces this principle. */
|
||||
enforcement: string;
|
||||
}
|
||||
|
||||
export const OPERATING_PRINCIPLES: GovernancePrinciple[] = [
|
||||
{
|
||||
id: 'inv-1',
|
||||
icon: 'human',
|
||||
title: 'Con người trong vòng kiểm soát (Human-in-the-loop)',
|
||||
description: 'AI đề xuất, con người quyết định. Mọi quyết định cuối cùng thuộc về nhà nghiên cứu.',
|
||||
tone: 'default',
|
||||
enforcement: 'Bài báo chỉ chuyển sang trạng thái “Đã xuất bản” qua cổng phê duyệt của con người.',
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
icon: 'bot',
|
||||
title: 'AI là trợ lý nghiên cứu, không thay thế nhà nghiên cứu',
|
||||
description: 'AI hỗ trợ soạn thảo và rà soát; tác giả chịu trách nhiệm chính là con người.',
|
||||
tone: 'default',
|
||||
enforcement: 'Người phê duyệt được ghi nhận là chủ sở hữu chịu trách nhiệm trên mỗi bài báo.',
|
||||
},
|
||||
{
|
||||
id: 'inv-3',
|
||||
icon: 'sourced',
|
||||
title: 'Mọi đầu ra cần nguồn, bằng chứng và kiểm chứng',
|
||||
description: 'Không luận điểm nào được công bố mà thiếu nguồn đã xác minh.',
|
||||
tone: 'default',
|
||||
enforcement: 'Cổng kiểm soát chặn xuất bản nếu thiếu nội dung hoặc còn nguồn chưa xác minh.',
|
||||
},
|
||||
{
|
||||
id: 'inv-4',
|
||||
icon: 'gate',
|
||||
title: 'Không chuyển giai đoạn nếu chưa qua checkpoint chất lượng',
|
||||
description: 'Mỗi giai đoạn bắt buộc phải vượt qua cổng kiểm soát trước khi tiến tới.',
|
||||
tone: 'danger',
|
||||
enforcement: 'Nút “Phê duyệt & Xuất bản” bị khoá khi bài báo chưa đạt cổng tính toàn vẹn.',
|
||||
},
|
||||
];
|
||||
|
||||
/** The dashed footer note in the diagram — the human's irreducible role. */
|
||||
export const HUMAN_ROLE_NOTE =
|
||||
'Vai trò con người: chọn câu hỏi, phương pháp, diễn giải kết quả, phê duyệt cuối.';
|
||||
|
||||
export interface GovernanceStatus {
|
||||
totalPapers: number;
|
||||
published: number;
|
||||
drafts: number;
|
||||
/** INV-1: published papers that carry a human-approval record (target: == published). */
|
||||
publishedWithApproval: number;
|
||||
/** INV-3: drafts currently blocked by the integrity gate (unsourced / incomplete). */
|
||||
draftsBlockedByIntegrity: number;
|
||||
/** Drafts that satisfy the integrity gate and are ready for the human-approval step. */
|
||||
draftsReadyToPublish: number;
|
||||
}
|
||||
|
||||
/** Aggregate a flat list of papers into a governance-compliance snapshot. */
|
||||
export function statusFromPapers(papers: Paper[]): GovernanceStatus {
|
||||
const published = papers.filter((p) => p.status === 'published');
|
||||
const drafts = papers.filter((p) => p.status !== 'published');
|
||||
return {
|
||||
totalPapers: papers.length,
|
||||
published: published.length,
|
||||
drafts: drafts.length,
|
||||
publishedWithApproval: published.filter((p) => !!p.approval).length,
|
||||
draftsBlockedByIntegrity: drafts.filter((p) => integrityIssues(p).length > 0).length,
|
||||
draftsReadyToPublish: drafts.filter((p) => integrityIssues(p).length === 0).length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten every paper across the investigator's projects, then summarize. */
|
||||
export function statusFromProjects(projects: ResearchProject[]): GovernanceStatus {
|
||||
const all = projects.flatMap((p) => extractPapers(p.content));
|
||||
return statusFromPapers(all);
|
||||
}
|
||||
|
||||
/** INV-1 holds iff every published paper carries a human-approval record. */
|
||||
export function humanApprovalIntact(status: GovernanceStatus): boolean {
|
||||
return status.published === status.publishedWithApproval;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
emptyPaper,
|
||||
extractPapers,
|
||||
upsertPaper,
|
||||
findPaper,
|
||||
integrityIssues,
|
||||
canPublish,
|
||||
publishPaper,
|
||||
touchPaper,
|
||||
type Paper,
|
||||
} from './paperModel';
|
||||
|
||||
/** A paper that satisfies the integrity gate. */
|
||||
function readyPaper(): Paper {
|
||||
return {
|
||||
...emptyPaper(),
|
||||
title: 'Một nghiên cứu',
|
||||
abstractVi: 'Tóm tắt.',
|
||||
body: 'Nội dung.',
|
||||
sources: [{ id: 's1', title: 'Nguồn A', verifiedReal: true }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('paperModel', () => {
|
||||
it('extractPapers returns [] for missing/invalid content', () => {
|
||||
expect(extractPapers(undefined)).toEqual([]);
|
||||
expect(extractPapers(null)).toEqual([]);
|
||||
expect(extractPapers({})).toEqual([]);
|
||||
expect(extractPapers({ papers: 'nope' })).toEqual([]);
|
||||
expect(extractPapers({ papers: [{ noId: true }] })).toEqual([]);
|
||||
});
|
||||
|
||||
it('extractPapers keeps well-formed paper objects', () => {
|
||||
const p = emptyPaper();
|
||||
expect(extractPapers({ papers: [p] })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('upsertPaper appends a new paper and replaces an existing one by id', () => {
|
||||
const a = emptyPaper();
|
||||
const list = upsertPaper([], a);
|
||||
expect(list).toHaveLength(1);
|
||||
const changed = { ...a, title: 'changed' };
|
||||
const list2 = upsertPaper(list, changed);
|
||||
expect(list2).toHaveLength(1);
|
||||
expect(findPaper(list2, a.id)?.title).toBe('changed');
|
||||
});
|
||||
|
||||
it('integrityIssues (INV-3) flags every missing requirement on an empty paper', () => {
|
||||
const issues = integrityIssues(emptyPaper());
|
||||
expect(issues.length).toBeGreaterThanOrEqual(4);
|
||||
expect(canPublish(emptyPaper())).toBe(false);
|
||||
});
|
||||
|
||||
it('a complete paper with verified sources passes the gate', () => {
|
||||
const p = readyPaper();
|
||||
expect(integrityIssues(p)).toEqual([]);
|
||||
expect(canPublish(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('an unverified source blocks publication', () => {
|
||||
const p: Paper = { ...readyPaper(), sources: [{ id: 's1', title: 'A', verifiedReal: false }] };
|
||||
expect(canPublish(p)).toBe(false);
|
||||
expect(integrityIssues(p).some((m) => m.includes('xác minh'))).toBe(true);
|
||||
});
|
||||
|
||||
it('publishPaper (INV-1) records the human approval and stamps published status', () => {
|
||||
const p = publishPaper(readyPaper(), { id: 'u1', name: 'TS. An' }, 'Đồng ý');
|
||||
expect(p.status).toBe('published');
|
||||
expect(p.approval?.approvedByUserId).toBe('u1');
|
||||
expect(p.approval?.approvedByName).toBe('TS. An');
|
||||
expect(p.approval?.note).toBe('Đồng ý');
|
||||
expect(p.publishedAt).toBeTruthy();
|
||||
expect(p.editHistory[p.editHistory.length - 1]?.action).toContain('xuất bản');
|
||||
});
|
||||
|
||||
it('publishPaper refuses when the integrity gate is not satisfied', () => {
|
||||
expect(() => publishPaper(emptyPaper(), { id: 'u1', name: 'TS. An' })).toThrow();
|
||||
});
|
||||
|
||||
it('an already-published paper cannot be re-published through the gate', () => {
|
||||
const p = publishPaper(readyPaper(), { id: 'u1', name: 'TS. An' });
|
||||
expect(canPublish(p)).toBe(false);
|
||||
});
|
||||
|
||||
it('touchPaper appends to the edit history (INV-6)', () => {
|
||||
const before = readyPaper();
|
||||
const after = touchPaper(before, 'Lưu bản thảo');
|
||||
expect(after.editHistory.length).toBe(before.editHistory.length + 1);
|
||||
expect(after.editHistory[after.editHistory.length - 1]?.action).toBe('Lưu bản thảo');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Paper domain model for the publisher.
|
||||
*
|
||||
* A "paper" is the publishable artifact an investigator produces from a research project's
|
||||
* results. Papers are persisted (no migration) inside `ResearchProject.content.papers[]` and
|
||||
* written via the existing `PUT /projects/{id}/detail` merge endpoint.
|
||||
*
|
||||
* The integrity gate (INV-3 of the academic-research spec — "everything is sourced") and the
|
||||
* human-approval publish event (INV-1 — "human in the loop") are modelled here as pure functions
|
||||
* so they are unit-testable and cannot be silently bypassed by the UI.
|
||||
*/
|
||||
|
||||
export type PaperStatus = 'draft' | 'published';
|
||||
|
||||
export interface SourceRef {
|
||||
id: string;
|
||||
title: string;
|
||||
authors?: string;
|
||||
/** DOI or URL of the source. */
|
||||
doiOrUrl?: string;
|
||||
/** The human has confirmed this reference actually exists (not hallucinated). */
|
||||
verifiedReal: boolean;
|
||||
}
|
||||
|
||||
export interface PaperApproval {
|
||||
approvedByUserId: string;
|
||||
approvedByName: string;
|
||||
/** ISO timestamp of the human-approval event. */
|
||||
approvedAt: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PaperEdit {
|
||||
/** ISO timestamp. */
|
||||
at: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface Paper {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
abstractVi: string;
|
||||
abstractEn?: string;
|
||||
body: string;
|
||||
sources: SourceRef[];
|
||||
status: PaperStatus;
|
||||
approval?: PaperApproval | null;
|
||||
publishedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** Full edit history (INV-6 — traceability). */
|
||||
editHistory: PaperEdit[];
|
||||
}
|
||||
|
||||
const nowIso = (): string => new Date().toISOString();
|
||||
|
||||
/** Browser-native UUID, with a non-secure-context fallback. */
|
||||
export function newId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof (crypto as Crypto).randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function emptyPaper(): Paper {
|
||||
const ts = nowIso();
|
||||
return {
|
||||
id: newId(),
|
||||
title: '',
|
||||
titleEn: '',
|
||||
abstractVi: '',
|
||||
abstractEn: '',
|
||||
body: '',
|
||||
sources: [],
|
||||
status: 'draft',
|
||||
approval: null,
|
||||
publishedAt: null,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
editHistory: [{ at: ts, action: 'Tạo bản thảo' }],
|
||||
};
|
||||
}
|
||||
|
||||
export function emptySource(): SourceRef {
|
||||
return { id: newId(), title: '', authors: '', doiOrUrl: '', verifiedReal: false };
|
||||
}
|
||||
|
||||
/** Safely read the papers array out of a project's `content` JSONB. */
|
||||
export function extractPapers(content: Record<string, unknown> | null | undefined): Paper[] {
|
||||
const raw = (content ?? {})['papers'];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter(
|
||||
(p): p is Paper => !!p && typeof p === 'object' && typeof (p as Paper).id === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
export function findPaper(papers: Paper[], paperId: string): Paper | undefined {
|
||||
return papers.find((p) => p.id === paperId);
|
||||
}
|
||||
|
||||
/** Replace the paper with the same id, or append it. Returns a new array (immutable). */
|
||||
export function upsertPaper(papers: Paper[], paper: Paper): Paper[] {
|
||||
const idx = papers.findIndex((p) => p.id === paper.id);
|
||||
if (idx === -1) return [...papers, paper];
|
||||
const next = papers.slice();
|
||||
next[idx] = paper;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Integrity gate (INV-3): the human-readable reasons a paper may NOT yet be published.
|
||||
* An empty array ⇒ the paper satisfies the integrity requirements.
|
||||
*/
|
||||
export function integrityIssues(paper: Paper): string[] {
|
||||
const issues: string[] = [];
|
||||
if (!paper.title.trim()) issues.push('Thiếu tiêu đề bài báo.');
|
||||
if (!paper.abstractVi.trim()) issues.push('Thiếu tóm tắt (tiếng Việt).');
|
||||
if (!paper.body.trim()) issues.push('Nội dung bài báo còn trống.');
|
||||
if (paper.sources.length === 0) {
|
||||
issues.push('Chưa có nguồn tham khảo nào — mọi luận điểm phải có nguồn.');
|
||||
}
|
||||
const unverified = paper.sources.filter((s) => !s.verifiedReal).length;
|
||||
if (unverified > 0) issues.push(`${unverified} nguồn chưa được xác minh là có thật.`);
|
||||
return issues;
|
||||
}
|
||||
|
||||
/** A paper may be published only if it is still a draft and the integrity gate is satisfied. */
|
||||
export function canPublish(paper: Paper): boolean {
|
||||
return paper.status !== 'published' && integrityIssues(paper).length === 0;
|
||||
}
|
||||
|
||||
/** Touch `updatedAt` and append an edit-history entry (INV-6). */
|
||||
export function touchPaper(paper: Paper, action: string): Paper {
|
||||
const at = nowIso();
|
||||
return { ...paper, updatedAt: at, editHistory: [...paper.editHistory, { at, action }] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the human-approval publish event (INV-1). Throws if the integrity gate is not satisfied,
|
||||
* so a paper can never reach `published` except through an explicit, gate-passing human action.
|
||||
*/
|
||||
export function publishPaper(
|
||||
paper: Paper,
|
||||
approver: { id: string; name: string },
|
||||
note?: string,
|
||||
): Paper {
|
||||
if (!canPublish(paper)) {
|
||||
throw new Error('Không thể xuất bản: bài báo chưa đạt cổng kiểm soát tính toàn vẹn.');
|
||||
}
|
||||
const at = nowIso();
|
||||
return {
|
||||
...paper,
|
||||
status: 'published',
|
||||
approval: {
|
||||
approvedByUserId: approver.id,
|
||||
approvedByName: approver.name,
|
||||
approvedAt: at,
|
||||
note: note?.trim() || undefined,
|
||||
},
|
||||
publishedAt: at,
|
||||
updatedAt: at,
|
||||
editHistory: [...paper.editHistory, { at, action: `Phê duyệt & xuất bản bởi ${approver.name}` }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Document, Font, Page, StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||
|
||||
import type { Paper } from '../domain/paperModel';
|
||||
|
||||
/**
|
||||
* Register a Unicode TTF so Vietnamese diacritics render (react-pdf's built-in Helvetica does not
|
||||
* cover them). The Roboto TTFs are SELF-HOSTED under public/fonts so PDF export also works in
|
||||
* air-gapped production deploys (no CDN fetch at render time). If glyphs ever render as blanks,
|
||||
* replace the files with a Noto-with-Vietnamese TTF — registration is the only place it is configured.
|
||||
*/
|
||||
Font.register({
|
||||
family: 'Roboto',
|
||||
fonts: [
|
||||
{ src: '/fonts/roboto-regular.ttf' },
|
||||
{ src: '/fonts/roboto-bold.ttf', fontWeight: 'bold' },
|
||||
],
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { fontFamily: 'Roboto', fontSize: 11, lineHeight: 1.5, padding: 48, color: '#1f2937' },
|
||||
title: { fontSize: 18, fontWeight: 'bold', marginBottom: 4 },
|
||||
titleEn: { fontSize: 12, color: '#6b7280', marginBottom: 12 },
|
||||
meta: { fontSize: 9, color: '#6b7280', marginBottom: 16 },
|
||||
h2: { fontSize: 12, fontWeight: 'bold', marginTop: 14, marginBottom: 4 },
|
||||
body: { textAlign: 'justify' },
|
||||
source: { marginBottom: 3 },
|
||||
badge: { fontSize: 9, color: '#047857', marginTop: 16 },
|
||||
});
|
||||
|
||||
export function PaperPdfDocument({ paper, projectTitle }: { paper: Paper; projectTitle?: string }) {
|
||||
return (
|
||||
<Document title={paper.title || 'Bài báo'} author={paper.approval?.approvedByName}>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<Text style={styles.title}>{paper.title || '(Chưa đặt tiêu đề)'}</Text>
|
||||
{paper.titleEn ? <Text style={styles.titleEn}>{paper.titleEn}</Text> : null}
|
||||
|
||||
<Text style={styles.meta}>
|
||||
{projectTitle ? `Đề tài: ${projectTitle}` : ''}
|
||||
{paper.publishedAt
|
||||
? ` · Xuất bản: ${new Date(paper.publishedAt).toLocaleDateString('vi-VN')}`
|
||||
: ' · Bản thảo'}
|
||||
{paper.approval ? ` · Phê duyệt: ${paper.approval.approvedByName}` : ''}
|
||||
</Text>
|
||||
|
||||
{paper.abstractVi ? (
|
||||
<View>
|
||||
<Text style={styles.h2}>Tóm tắt</Text>
|
||||
<Text style={styles.body}>{paper.abstractVi}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{paper.abstractEn ? (
|
||||
<View>
|
||||
<Text style={styles.h2}>Abstract</Text>
|
||||
<Text style={styles.body}>{paper.abstractEn}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{paper.body ? (
|
||||
<View>
|
||||
<Text style={styles.h2}>Nội dung</Text>
|
||||
<Text style={styles.body}>{paper.body}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{paper.sources.length > 0 ? (
|
||||
<View>
|
||||
<Text style={styles.h2}>Tài liệu tham khảo</Text>
|
||||
{paper.sources.map((s, i) => (
|
||||
<Text key={s.id} style={styles.source}>
|
||||
[{i + 1}] {s.title}
|
||||
{s.authors ? ` — ${s.authors}` : ''}
|
||||
{s.doiOrUrl ? ` (${s.doiOrUrl})` : ''}
|
||||
{s.verifiedReal ? ' ✓' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{paper.status === 'published' && paper.approval ? (
|
||||
<Text style={styles.badge}>
|
||||
Đã phê duyệt & xuất bản bởi {paper.approval.approvedByName} —{' '}
|
||||
{new Date(paper.approval.approvedAt).toLocaleString('vi-VN')}
|
||||
</Text>
|
||||
) : null}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { AlertTriangle, CheckCircle2, FileClock, Link2, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Badge, Card, CardContent, Separator } from '@ump/shared';
|
||||
|
||||
import { integrityIssues, type Paper } from '../domain/paperModel';
|
||||
|
||||
/**
|
||||
* INV-3 / INV-6 surface: the integrity verdict (sourced? complete?), the source list with
|
||||
* verified-real flags, and the edit history. Mirrors the spec's "Final Integrity Report" +
|
||||
* "ProvenanceTrace" for the no-backend v1.
|
||||
*/
|
||||
export function ProvenanceIntegrityPanel({ paper }: { paper: Paper }) {
|
||||
const issues = integrityIssues(paper);
|
||||
const clean = issues.length === 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Integrity verdict */}
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-serif text-sm font-semibold">Kiểm soát tính toàn vẹn</h3>
|
||||
</div>
|
||||
{clean ? (
|
||||
<div className="flex items-start gap-2 rounded-md bg-emerald-50 p-3 text-sm text-emerald-700">
|
||||
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>Đạt — bài báo có nguồn đầy đủ và sẵn sàng để xuất bản.</span>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{issues.map((msg) => (
|
||||
<li key={msg} className="flex items-start gap-2 text-sm text-amber-700">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{msg}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Provenance — sources */}
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-serif text-sm font-semibold">Truy vết nguồn</h3>
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
{paper.sources.length} nguồn
|
||||
</Badge>
|
||||
</div>
|
||||
{paper.sources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Chưa có nguồn nào.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{paper.sources.map((s) => (
|
||||
<li key={s.id} className="rounded-md border border-border p-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-foreground">{s.title || '(chưa đặt tên)'}</span>
|
||||
{s.verifiedReal ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700">Đã xác minh</Badge>
|
||||
) : (
|
||||
<Badge className="bg-amber-100 text-amber-700">Chưa xác minh</Badge>
|
||||
)}
|
||||
</div>
|
||||
{s.authors ? <div className="text-xs text-muted-foreground">{s.authors}</div> : null}
|
||||
{s.doiOrUrl ? (
|
||||
<div className="truncate text-xs text-muted-foreground">{s.doiOrUrl}</div>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit history */}
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileClock className="h-4 w-4 text-primary" />
|
||||
<h3 className="font-serif text-sm font-semibold">Lịch sử chỉnh sửa</h3>
|
||||
</div>
|
||||
<Separator />
|
||||
<ol className="space-y-1.5">
|
||||
{paper.editHistory
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((e, i) => (
|
||||
<li key={`${e.at}-${i}`} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">{new Date(e.at).toLocaleString('vi-VN')}</span>
|
||||
<span className="text-foreground">{e.action}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import { ShieldCheck, Loader2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Textarea,
|
||||
} from '@ump/shared';
|
||||
|
||||
import { canPublish, integrityIssues, type Paper } from '../domain/paperModel';
|
||||
|
||||
/**
|
||||
* INV-1 human-approval gate. A paper can only become `published` by the owner clicking through
|
||||
* this explicit confirmation; the action is disabled while the integrity gate (INV-3) is unmet.
|
||||
*/
|
||||
export function PublishGateDialog({
|
||||
paper,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
publishing,
|
||||
}: {
|
||||
paper: Paper;
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onConfirm: (note: string) => void;
|
||||
publishing: boolean;
|
||||
}) {
|
||||
const [note, setNote] = useState('');
|
||||
const issues = integrityIssues(paper);
|
||||
const ready = canPublish(paper);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
Phê duyệt & xuất bản bài báo
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Đây là bước phê duyệt của con người (INV-1). Hệ thống không tự xuất bản — bạn chịu trách
|
||||
nhiệm về nội dung được công bố.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{ready ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md bg-emerald-50 p-3 text-sm text-emerald-700">
|
||||
Bài báo đã đạt cổng kiểm soát tính toàn vẹn. Xác nhận để công bố.
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="approval-note">Ghi chú phê duyệt (tuỳ chọn)</Label>
|
||||
<Textarea
|
||||
id="approval-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Ví dụ: Đã rà soát nguồn và số liệu."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chưa thể xuất bản — vui lòng xử lý các vấn đề sau:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-amber-700">
|
||||
{issues.map((m) => (
|
||||
<li key={m}>{m}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={publishing}>
|
||||
Huỷ
|
||||
</Button>
|
||||
<Button onClick={() => onConfirm(note)} disabled={!ready || publishing}>
|
||||
{publishing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
Phê duyệt & Xuất bản
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
||||
All colors MUST be HSL.
|
||||
*/
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Warm canvas + cool institutional primary — clearer layers than flat gray-on-cream */
|
||||
--background: 38 32% 96%;
|
||||
--foreground: 222 28% 16%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 28% 16%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 28% 16%;
|
||||
|
||||
--primary: 217 52% 38%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
|
||||
--secondary: 220 18% 92%;
|
||||
--secondary-foreground: 222 28% 22%;
|
||||
|
||||
--muted: 36 22% 92%;
|
||||
--muted-foreground: 220 12% 42%;
|
||||
|
||||
--accent: 168 42% 38%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
|
||||
--destructive: 0 72% 48%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: 220 14% 88%;
|
||||
--input: 220 14% 88%;
|
||||
--ring: 217 52% 38%;
|
||||
|
||||
--radius: 1rem;
|
||||
|
||||
/* Custom design tokens — tuned to sit cleanly on the new base */
|
||||
--tag-financing: 215 48% 78%;
|
||||
--tag-lifestyle: 162 36% 72%;
|
||||
--tag-community: 32 42% 62%;
|
||||
--tag-wellness: 278 32% 74%;
|
||||
--tag-travel: 198 48% 72%;
|
||||
--tag-creativity: 328 38% 76%;
|
||||
--tag-growth: 48 46% 68%;
|
||||
|
||||
/* Polish design tokens */
|
||||
--cream: 40 38% 92%;
|
||||
--cream-foreground: 222 28% 16%;
|
||||
--surface-elevated: 0 0% 99%;
|
||||
--shadow-soft: 222 28% 16%;
|
||||
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Sidebar tokens */
|
||||
--sidebar-background: 220 16% 97%;
|
||||
--sidebar-foreground: 222 28% 16%;
|
||||
--sidebar-primary: 217 52% 38%;
|
||||
--sidebar-primary-foreground: 210 40% 98%;
|
||||
--sidebar-accent: 220 18% 93%;
|
||||
--sidebar-accent-foreground: 222 28% 22%;
|
||||
--sidebar-border: 220 14% 88%;
|
||||
--sidebar-ring: 217 52% 38%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 24% 12%;
|
||||
--foreground: 210 36% 96%;
|
||||
|
||||
--card: 222 22% 16%;
|
||||
--card-foreground: 210 36% 96%;
|
||||
|
||||
--popover: 222 24% 12%;
|
||||
--popover-foreground: 210 36% 96%;
|
||||
|
||||
--primary: 213 62% 58%;
|
||||
--primary-foreground: 222 28% 12%;
|
||||
|
||||
--secondary: 217 18% 22%;
|
||||
--secondary-foreground: 210 36% 96%;
|
||||
|
||||
--muted: 220 16% 20%;
|
||||
--muted-foreground: 220 12% 64%;
|
||||
|
||||
--accent: 168 40% 44%;
|
||||
--accent-foreground: 210 36% 98%;
|
||||
|
||||
--destructive: 0 63% 48%;
|
||||
--destructive-foreground: 210 36% 98%;
|
||||
|
||||
--border: 217 16% 24%;
|
||||
--input: 217 16% 24%;
|
||||
--ring: 213 62% 58%;
|
||||
|
||||
/* Dark mode tag colors */
|
||||
--tag-financing: 215 42% 42%;
|
||||
--tag-lifestyle: 162 32% 40%;
|
||||
--tag-community: 32 34% 42%;
|
||||
--tag-wellness: 278 28% 46%;
|
||||
--tag-travel: 198 42% 44%;
|
||||
--tag-creativity: 328 32% 46%;
|
||||
--tag-growth: 48 38% 44%;
|
||||
|
||||
/* Dark mode polish tokens */
|
||||
--cream: 222 18% 22%;
|
||||
--cream-foreground: 210 36% 96%;
|
||||
--surface-elevated: 222 22% 18%;
|
||||
--shadow-soft: 222 28% 6%;
|
||||
|
||||
/* Sidebar tokens */
|
||||
--sidebar-background: 222 22% 14%;
|
||||
--sidebar-foreground: 210 36% 96%;
|
||||
--sidebar-primary: 213 62% 58%;
|
||||
--sidebar-primary-foreground: 222 28% 12%;
|
||||
--sidebar-accent: 217 18% 20%;
|
||||
--sidebar-accent-foreground: 210 36% 96%;
|
||||
--sidebar-border: 217 16% 24%;
|
||||
--sidebar-ring: 213 62% 58%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans antialiased;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-serif tracking-tight;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.card-hover {
|
||||
@apply transition-all duration-500 hover:scale-[1.02];
|
||||
box-shadow: 0 4px 20px -4px hsl(var(--shadow-soft) / 0.1);
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
box-shadow: 0 20px 40px -10px hsl(var(--shadow-soft) / 0.15);
|
||||
}
|
||||
|
||||
.pill-nav {
|
||||
@apply rounded-full bg-[hsl(var(--surface-elevated))] backdrop-blur-lg border border-border/50;
|
||||
}
|
||||
|
||||
.floating-button {
|
||||
@apply w-12 h-12 rounded-full bg-[hsl(var(--cream)/0.9)] backdrop-blur-sm flex items-center justify-center text-[hsl(var(--cream-foreground))] hover:bg-[hsl(var(--cream))] hover:scale-110 transition-all duration-300;
|
||||
box-shadow: 0 4px 12px -2px hsl(var(--shadow-soft) / 0.15);
|
||||
}
|
||||
|
||||
/* Animation keyframes */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Stagger animations */
|
||||
.stagger-1 {
|
||||
animation-delay: 0.1s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-2 {
|
||||
animation-delay: 0.2s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-3 {
|
||||
animation-delay: 0.3s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-4 {
|
||||
animation-delay: 0.4s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-5 {
|
||||
animation-delay: 0.5s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.stagger-6 {
|
||||
animation-delay: 0.6s;
|
||||
opacity: 0;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.tag-financing {
|
||||
@apply bg-[hsl(var(--tag-financing))] text-primary;
|
||||
}
|
||||
|
||||
.tag-lifestyle {
|
||||
@apply bg-[hsl(var(--tag-lifestyle))] text-primary;
|
||||
}
|
||||
|
||||
.tag-community {
|
||||
@apply bg-[hsl(var(--tag-community))] text-primary;
|
||||
}
|
||||
|
||||
.tag-wellness {
|
||||
@apply bg-[hsl(var(--tag-wellness))] text-primary;
|
||||
}
|
||||
|
||||
.tag-travel {
|
||||
@apply bg-[hsl(var(--tag-travel))] text-primary;
|
||||
}
|
||||
|
||||
.tag-creativity {
|
||||
@apply bg-[hsl(var(--tag-creativity))] text-primary;
|
||||
}
|
||||
|
||||
.tag-growth {
|
||||
@apply bg-[hsl(var(--tag-growth))] text-primary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
import { Input, useAuth, getRoleDisplayName, type Role } from "@ump/shared";
|
||||
import { SidebarProvider, SidebarInset, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { ApplicantDashboardSidebar } from "@/components/applicant/DashboardSidebar";
|
||||
|
||||
/** Initials from a full name — first + last word (e.g. "Thinh Hong Lam" → "TL"). */
|
||||
function initialsOf(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
const last = parts.length > 1 ? parts[parts.length - 1][0] : "";
|
||||
return (parts[0][0] + last).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Applicant dashboard shell — sidebar + header + routed content (<Outlet/>).
|
||||
*
|
||||
* Mirrors fe0/src/layouts/DashboardLayout.tsx, but hardcodes the applicant sidebar
|
||||
* (this is the applicant app — fe0 swapped admin-vs-applicant at runtime). The header
|
||||
* carries the SidebarTrigger + search and the current user's name/roles (moved here
|
||||
* from the sidebar). fe0's NotificationBell / UserMenu are deferred with the deep feature pages.
|
||||
*/
|
||||
export function DashboardLayout() {
|
||||
const { user, roles } = useAuth();
|
||||
const roleLabel = roles.map((r: Role) => getRoleDisplayName(r)).join(", ");
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="flex overflow-hidden h-screen w-full">
|
||||
<ApplicantDashboardSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 absolute top-0 left-0 right-0 items-center gap-4 border-b border-border bg-card px-4">
|
||||
<SidebarTrigger />
|
||||
<div className="flex-1">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input type="search" placeholder="Tìm kiếm..." className="pl-9 bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
{user && (user.name || roleLabel) && (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="hidden text-right leading-tight sm:block">
|
||||
<p className="truncate text-sm font-medium text-foreground">{user.name}</p>
|
||||
{roleLabel && (
|
||||
<p className="truncate text-xs text-muted-foreground">{roleLabel}</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{initialsOf(user.name || "?")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto p-6 mt-12">
|
||||
<Outlet />
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) throw new Error('Root element #root not found');
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Construction } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@ump/shared";
|
||||
|
||||
/**
|
||||
* Placeholder for applicant sidebar targets whose feature pages have not been
|
||||
* migrated into frontend_user yet (initiative draft, profile, notifications, …).
|
||||
* Styled with the shared design tokens so the shell looks complete and nothing 404s.
|
||||
*/
|
||||
export function ComingSoonPage() {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<Card className="w-full max-w-md text-center">
|
||||
<CardHeader>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Construction className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<CardTitle className="font-serif">Tính năng đang phát triển</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chức năng này sẽ sớm được hoàn thiện. Vui lòng quay lại sau.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
FileSearch,
|
||||
ShieldAlert,
|
||||
UserCheck,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Badge, Card, CardContent, cn } from '@ump/shared';
|
||||
|
||||
import { listPublishableProjects } from '@/features/papers/api/papersApi';
|
||||
import {
|
||||
HUMAN_ROLE_NOTE,
|
||||
OPERATING_PRINCIPLES,
|
||||
humanApprovalIntact,
|
||||
statusFromProjects,
|
||||
type GovernancePrinciple,
|
||||
} from '@/features/papers/domain/governance';
|
||||
|
||||
const ICONS: Record<GovernancePrinciple['icon'], LucideIcon> = {
|
||||
human: UserCheck,
|
||||
bot: Bot,
|
||||
sourced: FileSearch,
|
||||
gate: ShieldAlert,
|
||||
};
|
||||
|
||||
function PrincipleCard({ principle }: { principle: GovernancePrinciple }) {
|
||||
const Icon = ICONS[principle.icon];
|
||||
const danger = principle.tone === 'danger';
|
||||
return (
|
||||
<Card className="border-blue-100">
|
||||
<CardContent className="flex gap-3 p-4">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
|
||||
danger ? 'bg-rose-50 text-rose-600' : 'bg-blue-50 text-blue-700',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-serif text-sm font-semibold text-foreground">{principle.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{principle.description}</p>
|
||||
<p className="text-xs text-blue-700">
|
||||
<span className="font-medium">Trong hệ thống: </span>
|
||||
{principle.enforcement}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatTile({ label, value, tone }: { label: string; value: number; tone?: 'danger' | 'ok' }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
'text-2xl font-semibold tabular-nums',
|
||||
tone === 'danger' ? 'text-rose-600' : tone === 'ok' ? 'text-emerald-600' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Governance module — the publisher's operating philosophy ("Triết lý vận hành") plus a live
|
||||
* compliance snapshot computed from the investigator's papers.
|
||||
*/
|
||||
export function GovernancePage() {
|
||||
const { data: projects } = useQuery({
|
||||
queryKey: ['publisher', 'publishable-projects'],
|
||||
queryFn: listPublishableProjects,
|
||||
});
|
||||
|
||||
const status = statusFromProjects(projects ?? []);
|
||||
const inv1Ok = humanApprovalIntact(status);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-5">
|
||||
{/* Header band — mirrors the diagram's blue "TRIẾT LÝ VẬN HÀNH" header. */}
|
||||
<div className="flex items-center gap-3 rounded-xl bg-blue-800 px-5 py-4 text-white">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/15">
|
||||
<Brain className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-serif text-lg font-bold uppercase tracking-wide">Triết lý vận hành</h1>
|
||||
<p className="text-sm text-blue-100">Nguyên tắc quản trị quy trình công bố</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Principles */}
|
||||
<div className="space-y-3">
|
||||
{OPERATING_PRINCIPLES.map((p) => (
|
||||
<PrincipleCard key={p.id} principle={p} />
|
||||
))}
|
||||
|
||||
{/* Human-role footer — the diagram's dashed note. */}
|
||||
<div className="flex items-start gap-3 rounded-xl border-2 border-dashed border-blue-200 bg-blue-50/40 p-4">
|
||||
<UserCheck className="mt-0.5 h-5 w-5 shrink-0 text-blue-700" />
|
||||
<p className="text-sm italic text-blue-900">{HUMAN_ROLE_NOTE}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live compliance snapshot — ties the philosophy to the actual papers. */}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-serif text-base font-semibold text-foreground">Tình trạng tuân thủ</h2>
|
||||
{inv1Ok ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700">
|
||||
<CheckCircle2 className="mr-1 h-3.5 w-3.5" /> Human-in-the-loop đảm bảo
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-rose-100 text-rose-700">
|
||||
<ShieldAlert className="mr-1 h-3.5 w-3.5" /> Có bài xuất bản thiếu phê duyệt
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatTile label="Tổng số bài báo" value={status.totalPapers} />
|
||||
<StatTile label="Đã xuất bản" value={status.published} tone="ok" />
|
||||
<StatTile
|
||||
label="Bản thảo bị chặn (thiếu nguồn)"
|
||||
value={status.draftsBlockedByIntegrity}
|
||||
tone={status.draftsBlockedByIntegrity > 0 ? 'danger' : undefined}
|
||||
/>
|
||||
<StatTile label="Sẵn sàng phê duyệt" value={status.draftsReadyToPublish} />
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.published === status.publishedWithApproval
|
||||
? `Tất cả ${status.published} bài báo đã xuất bản đều có hồ sơ phê duyệt của con người (INV-1).`
|
||||
: `Cảnh báo: ${status.published - status.publishedWithApproval} bài xuất bản thiếu hồ sơ phê duyệt.`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoginRegisterCard } from '@ump/shared';
|
||||
|
||||
export function LoginPage() {
|
||||
return <LoginRegisterCard registerPath="/register" registerLabel="Đăng ký" />;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams, Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Plus, Save, Send, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Input,
|
||||
Label,
|
||||
Separator,
|
||||
Textarea,
|
||||
useAuth,
|
||||
} from '@ump/shared';
|
||||
|
||||
import { getProjectResearch, savePapers } from '@/features/papers/api/papersApi';
|
||||
import {
|
||||
emptyPaper,
|
||||
emptySource,
|
||||
findPaper,
|
||||
publishPaper,
|
||||
touchPaper,
|
||||
upsertPaper,
|
||||
type Paper,
|
||||
} from '@/features/papers/domain/paperModel';
|
||||
import { ProvenanceIntegrityPanel } from '@/features/papers/presentation/ProvenanceIntegrityPanel';
|
||||
import { PublishGateDialog } from '@/features/papers/presentation/PublishGateDialog';
|
||||
|
||||
export function PaperComposerPage() {
|
||||
const { projectId = '' } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const paperId = searchParams.get('paperId');
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['publisher', 'project-research', projectId],
|
||||
queryFn: () => getProjectResearch(projectId),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
|
||||
const [paper, setPaper] = useState<Paper | null>(null);
|
||||
const [gateOpen, setGateOpen] = useState(false);
|
||||
|
||||
// Initialise the editing paper once research data arrives (existing paper or a fresh draft).
|
||||
useEffect(() => {
|
||||
if (!data || paper) return;
|
||||
const existing = paperId ? findPaper(data.papers, paperId) : undefined;
|
||||
setPaper(existing ?? emptyPaper());
|
||||
}, [data, paper, paperId]);
|
||||
|
||||
const allPapers = data?.papers ?? [];
|
||||
|
||||
// Research results available to cite (from the cockpit bundle).
|
||||
const research = useMemo(() => {
|
||||
const b = data?.bundle;
|
||||
return {
|
||||
models: b?.models ?? [],
|
||||
datasets: b?.datasets ?? [],
|
||||
milestones: b?.milestones ?? [],
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (papers: Paper[]) => savePapers(projectId, papers),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['publisher'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !paper) return <p className="p-6 text-sm text-muted-foreground">Đang tải…</p>;
|
||||
if (isError) return <p className="p-6 text-sm text-destructive">Không tải được dữ liệu đề tài.</p>;
|
||||
|
||||
const set = <K extends keyof Paper>(key: K, value: Paper[K]) =>
|
||||
setPaper((p) => (p ? { ...p, [key]: value } : p));
|
||||
|
||||
const addSource = () => set('sources', [...paper.sources, emptySource()]);
|
||||
const removeSource = (id: string) =>
|
||||
set(
|
||||
'sources',
|
||||
paper.sources.filter((s) => s.id !== id),
|
||||
);
|
||||
const patchSource = (id: string, patch: Partial<Paper['sources'][number]>) =>
|
||||
set(
|
||||
'sources',
|
||||
paper.sources.map((s) => (s.id === id ? { ...s, ...patch } : s)),
|
||||
);
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
const next = touchPaper(paper, 'Lưu bản thảo');
|
||||
try {
|
||||
await saveMutation.mutateAsync(upsertPaper(allPapers, next));
|
||||
setPaper(next);
|
||||
toast.success('Đã lưu bản thảo.');
|
||||
} catch {
|
||||
toast.error('Lưu bản thảo thất bại.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (note: string) => {
|
||||
if (!user) {
|
||||
toast.error('Phiên đăng nhập không hợp lệ.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const published = publishPaper(paper, { id: user.id, name: user.name }, note);
|
||||
await saveMutation.mutateAsync(upsertPaper(allPapers, published));
|
||||
setPaper(published);
|
||||
setGateOpen(false);
|
||||
toast.success('Đã xuất bản bài báo.');
|
||||
navigate(`/dashboard/papers/${projectId}/${published.id}`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Xuất bản thất bại.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild variant="ghost" size="sm" className="h-8">
|
||||
<Link to="/dashboard/papers">
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Danh sách
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleSaveDraft} disabled={saveMutation.isPending}>
|
||||
<Save className="mr-1.5 h-4 w-4" /> Lưu bản thảo
|
||||
</Button>
|
||||
<Button onClick={() => setGateOpen(true)}>
|
||||
<Send className="mr-1.5 h-4 w-4" /> Xuất bản…
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* Editor */}
|
||||
<div className="space-y-4 lg:col-span-2">
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="title">Tiêu đề (tiếng Việt)</Label>
|
||||
<Input id="title" value={paper.title} onChange={(e) => set('title', e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="titleEn">Title (English)</Label>
|
||||
<Input
|
||||
id="titleEn"
|
||||
value={paper.titleEn ?? ''}
|
||||
onChange={(e) => set('titleEn', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="absVi">Tóm tắt (tiếng Việt)</Label>
|
||||
<Textarea
|
||||
id="absVi"
|
||||
rows={4}
|
||||
value={paper.abstractVi}
|
||||
onChange={(e) => set('abstractVi', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="absEn">Abstract (English)</Label>
|
||||
<Textarea
|
||||
id="absEn"
|
||||
rows={4}
|
||||
value={paper.abstractEn ?? ''}
|
||||
onChange={(e) => set('abstractEn', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="body">Nội dung</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
rows={14}
|
||||
value={paper.body}
|
||||
onChange={(e) => set('body', e.target.value)}
|
||||
placeholder="Mở đầu, phương pháp, kết quả, bàn luận, kết luận…"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sources editor */}
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-serif text-sm font-semibold">Nguồn tham khảo</h3>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={addSource}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Thêm nguồn
|
||||
</Button>
|
||||
</div>
|
||||
{paper.sources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chưa có nguồn. Mọi luận điểm cần có nguồn trước khi xuất bản (INV-3).
|
||||
</p>
|
||||
) : (
|
||||
paper.sources.map((s) => (
|
||||
<div key={s.id} className="space-y-2 rounded-md border border-border p-3">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Tiêu đề nguồn"
|
||||
value={s.title}
|
||||
onChange={(e) => patchSource(s.id, { title: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Tác giả"
|
||||
value={s.authors ?? ''}
|
||||
onChange={(e) => patchSource(s.id, { authors: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="DOI hoặc URL"
|
||||
value={s.doiOrUrl ?? ''}
|
||||
onChange={(e) => patchSource(s.id, { doiOrUrl: e.target.value })}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={s.verifiedReal}
|
||||
onCheckedChange={(v) => patchSource(s.id, { verifiedReal: v === true })}
|
||||
/>
|
||||
Đã xác minh nguồn có thật
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-destructive"
|
||||
onClick={() => removeSource(s.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right column: research results + integrity */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
<h3 className="font-serif text-sm font-semibold">Kết quả nghiên cứu</h3>
|
||||
<Separator />
|
||||
<ResearchList label="Mô hình" rows={research.models} field="name" />
|
||||
<ResearchList label="Bộ dữ liệu" rows={research.datasets} field="name" />
|
||||
<ResearchList label="Tiến độ" rows={research.milestones} field="title" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ProvenanceIntegrityPanel paper={paper} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PublishGateDialog
|
||||
paper={paper}
|
||||
open={gateOpen}
|
||||
onOpenChange={setGateOpen}
|
||||
onConfirm={handlePublish}
|
||||
publishing={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResearchList({
|
||||
label,
|
||||
rows,
|
||||
field,
|
||||
}: {
|
||||
label: string;
|
||||
rows: Array<Record<string, unknown>>;
|
||||
field: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{label} ({rows.length})
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{rows.slice(0, 6).map((r, i) => (
|
||||
<li key={(r.id as string) ?? i} className="truncate text-sm text-foreground">
|
||||
{String(r[field] ?? r.title ?? r.name ?? '—')}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { PDFDownloadLink } from '@react-pdf/renderer';
|
||||
import { ArrowLeft, Download, Pencil, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { Badge, Button, Card, CardContent, Separator, listProjectAudit } from '@ump/shared';
|
||||
|
||||
import { getProjectResearch } from '@/features/papers/api/papersApi';
|
||||
import { findPaper } from '@/features/papers/domain/paperModel';
|
||||
import { PaperPdfDocument } from '@/features/papers/presentation/PaperPdfDocument';
|
||||
|
||||
export function PaperPreviewPage() {
|
||||
const { projectId = '', paperId = '' } = useParams();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['publisher', 'project-research', projectId],
|
||||
queryFn: () => getProjectResearch(projectId),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
|
||||
const { data: audit } = useQuery({
|
||||
queryKey: ['publisher', 'audit', projectId],
|
||||
queryFn: () => listProjectAudit(projectId),
|
||||
enabled: !!projectId,
|
||||
});
|
||||
|
||||
if (isLoading) return <p className="p-6 text-sm text-muted-foreground">Đang tải…</p>;
|
||||
if (isError || !data) return <p className="p-6 text-sm text-destructive">Không tải được dữ liệu.</p>;
|
||||
|
||||
const paper = findPaper(data.papers, paperId);
|
||||
if (!paper) return <p className="p-6 text-sm text-destructive">Không tìm thấy bài báo.</p>;
|
||||
|
||||
const projectTitle = data.bundle.project.title;
|
||||
const pdfName = `${(paper.title || 'bai-bao').slice(0, 60).replace(/\s+/g, '-')}.pdf`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild variant="ghost" size="sm" className="h-8">
|
||||
<Link to="/dashboard/papers">
|
||||
<ArrowLeft className="mr-1.5 h-4 w-4" /> Danh sách
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm" className="h-8">
|
||||
<Link to={`/dashboard/papers/${projectId}/compose?paperId=${paper.id}`}>
|
||||
<Pencil className="mr-1.5 h-4 w-4" /> Sửa
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="h-8">
|
||||
<PDFDownloadLink
|
||||
document={<PaperPdfDocument paper={paper} projectTitle={projectTitle} />}
|
||||
fileName={pdfName}
|
||||
>
|
||||
{({ loading }) => (
|
||||
<span className="inline-flex items-center">
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
{loading ? 'Đang tạo PDF…' : 'Tải PDF'}
|
||||
</span>
|
||||
)}
|
||||
</PDFDownloadLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Approval banner (INV-1) */}
|
||||
{paper.status === 'published' && paper.approval ? (
|
||||
<div className="flex items-start gap-2 rounded-md bg-emerald-50 p-3 text-sm text-emerald-700">
|
||||
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Đã phê duyệt & xuất bản bởi <strong>{paper.approval.approvedByName}</strong> —{' '}
|
||||
{new Date(paper.approval.approvedAt).toLocaleString('vi-VN')}
|
||||
{paper.approval.note ? ` · “${paper.approval.note}”` : ''}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Badge className="bg-slate-100 text-slate-600">Bản thảo (chưa xuất bản)</Badge>
|
||||
)}
|
||||
|
||||
{/* Rendered paper */}
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-8">
|
||||
<header>
|
||||
<h1 className="font-serif text-2xl font-semibold text-foreground">
|
||||
{paper.title || '(Chưa đặt tiêu đề)'}
|
||||
</h1>
|
||||
{paper.titleEn ? <p className="text-muted-foreground">{paper.titleEn}</p> : null}
|
||||
<p className="mt-1 text-xs text-muted-foreground">Đề tài: {projectTitle}</p>
|
||||
</header>
|
||||
|
||||
{paper.abstractVi ? (
|
||||
<section>
|
||||
<h2 className="font-serif text-sm font-semibold">Tóm tắt</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-foreground">{paper.abstractVi}</p>
|
||||
</section>
|
||||
) : null}
|
||||
{paper.abstractEn ? (
|
||||
<section>
|
||||
<h2 className="font-serif text-sm font-semibold">Abstract</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-foreground">{paper.abstractEn}</p>
|
||||
</section>
|
||||
) : null}
|
||||
{paper.body ? (
|
||||
<section>
|
||||
<h2 className="font-serif text-sm font-semibold">Nội dung</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-foreground">{paper.body}</p>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{paper.sources.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="font-serif text-sm font-semibold">Tài liệu tham khảo</h2>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-sm text-foreground">
|
||||
{paper.sources.map((s) => (
|
||||
<li key={s.id}>
|
||||
{s.title}
|
||||
{s.authors ? ` — ${s.authors}` : ''}
|
||||
{s.doiOrUrl ? ` (${s.doiOrUrl})` : ''}
|
||||
{s.verifiedReal ? ' ✓' : ''}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Process / provenance log (INV-6) */}
|
||||
<Card>
|
||||
<CardContent className="space-y-2 p-5">
|
||||
<h2 className="font-serif text-sm font-semibold">Nhật ký quá trình</h2>
|
||||
<Separator />
|
||||
<ol className="space-y-1.5">
|
||||
{(audit ?? []).slice(0, 12).map((a) => (
|
||||
<li key={a.id} className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{a.occurredAt ? new Date(a.occurredAt).toLocaleString('vi-VN') : ''}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{a.actorName}: {a.action} {a.subject}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{(audit ?? []).length === 0 && (
|
||||
<li className="text-xs text-muted-foreground">Chưa có hoạt động.</li>
|
||||
)}
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowUpRight, BookOpenCheck, FileText, FlaskConical, Plus } from 'lucide-react';
|
||||
|
||||
import { Badge, Button, Card, CardContent, cn } from '@ump/shared';
|
||||
|
||||
import { listPublishableProjects } from '@/features/papers/api/papersApi';
|
||||
import { extractPapers, type Paper } from '@/features/papers/domain/paperModel';
|
||||
|
||||
type Tab = 'projects' | 'published';
|
||||
|
||||
function PaperStatusBadge({ status }: { status: Paper['status'] }) {
|
||||
return status === 'published' ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700">Đã xuất bản</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-100 text-slate-600">Bản thảo</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Landing page. Lists the investigator's APPROVED research projects (the publishable result
|
||||
* sources) with their papers, plus a cross-project "Đã xuất bản" view.
|
||||
*/
|
||||
export function PublishablePapersList() {
|
||||
const [tab, setTab] = useState<Tab>('projects');
|
||||
|
||||
const { data: projects, isLoading, isError } = useQuery({
|
||||
queryKey: ['publisher', 'publishable-projects'],
|
||||
queryFn: listPublishableProjects,
|
||||
});
|
||||
|
||||
const publishedRows = useMemo(() => {
|
||||
const rows: Array<{ projectId: string; projectTitle: string; paper: Paper }> = [];
|
||||
for (const p of projects ?? []) {
|
||||
for (const paper of extractPapers(p.content)) {
|
||||
if (paper.status === 'published') {
|
||||
rows.push({ projectId: p.id, projectTitle: p.title, paper });
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => (b.paper.publishedAt ?? '').localeCompare(a.paper.publishedAt ?? ''));
|
||||
}, [projects]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BookOpenCheck className="h-6 w-6 shrink-0 text-primary" />
|
||||
<div>
|
||||
<h1 className="font-serif text-2xl font-semibold text-foreground">Công bố bài báo</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Soạn và xuất bản bài báo từ kết quả nghiên cứu của các đề tài đã được phê duyệt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-border pb-2">
|
||||
{([
|
||||
{ key: 'projects', label: 'Đề tài có thể công bố' },
|
||||
{ key: 'published', label: `Đã xuất bản (${publishedRows.length})` },
|
||||
] as Array<{ key: Tab; label: string }>).map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-1.5 text-sm font-medium transition',
|
||||
tab === t.key ? 'bg-foreground text-background' : 'text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading && <p className="text-sm text-muted-foreground">Đang tải…</p>}
|
||||
{isError && <p className="text-sm text-destructive">Không tải được danh sách đề tài.</p>}
|
||||
|
||||
{/* Projects tab */}
|
||||
{tab === 'projects' && projects && projects.length === 0 && (
|
||||
<Card className="text-center">
|
||||
<CardContent className="py-12">
|
||||
<FlaskConical className="mx-auto mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Chưa có đề tài nào được phê duyệt. Chỉ đề tài đã phê duyệt mới có thể công bố bài báo.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === 'projects' &&
|
||||
(projects ?? []).map((project) => {
|
||||
const papers = extractPapers(project.content);
|
||||
return (
|
||||
<Card key={project.id}>
|
||||
<CardContent className="space-y-3 p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="h-4 w-4 text-primary" />
|
||||
<h2 className="font-serif text-lg font-semibold text-foreground">
|
||||
{project.title || '(Chưa đặt tên đề tài)'}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.code ? `${project.code} · ` : ''}
|
||||
{project.level} · {papers.length} bài báo
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild size="sm">
|
||||
<Link to={`/dashboard/papers/${project.id}/compose`}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Soạn bài báo mới
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{papers.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{papers.map((paper) => (
|
||||
<li
|
||||
key={paper.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{paper.title || '(Chưa đặt tiêu đề)'}
|
||||
</span>
|
||||
<PaperStatusBadge status={paper.status} />
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{paper.status === 'published' && (
|
||||
<Button asChild variant="ghost" size="sm" className="h-8">
|
||||
<Link to={`/dashboard/papers/${project.id}/${paper.id}`}>
|
||||
Xem <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline" size="sm" className="h-8">
|
||||
<Link to={`/dashboard/papers/${project.id}/compose?paperId=${paper.id}`}>
|
||||
{paper.status === 'published' ? 'Sửa' : 'Tiếp tục soạn'}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Published tab */}
|
||||
{tab === 'published' && (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
{publishedRows.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
Chưa có bài báo nào được xuất bản.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
{publishedRows.map(({ projectId, projectTitle, paper }) => (
|
||||
<li key={paper.id} className="flex items-center justify-between gap-3 py-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{paper.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{projectTitle} ·{' '}
|
||||
{paper.publishedAt
|
||||
? new Date(paper.publishedAt).toLocaleDateString('vi-VN')
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="ghost" size="sm" className="h-8 shrink-0">
|
||||
<Link to={`/dashboard/papers/${projectId}/${paper.id}`}>
|
||||
Xem <ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,24 @@
|
||||
{
|
||||
"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/viewer": ["../shared/src/components/viewer/index.ts"],
|
||||
"@ump/shared": ["../shared/src/index.ts"]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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: 5176,
|
||||
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: {
|
||||
// Array form so match order is explicit: the viewer subpath must be tried
|
||||
// before the bare '@ump/shared' entry (which prefix-matches '@ump/shared/…').
|
||||
// Consume the shared kernel as source (no build step) — Vite transpiles it.
|
||||
alias: [
|
||||
{
|
||||
find: '@ump/shared/viewer',
|
||||
replacement: path.resolve(__dirname, '../shared/src/components/viewer/index.ts'),
|
||||
},
|
||||
{ find: '@ump/shared', replacement: path.resolve(__dirname, '../shared/src/index.ts') },
|
||||
{ find: '@', replacement: path.resolve(__dirname, './src') },
|
||||
],
|
||||
},
|
||||
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'] : [],
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Pure-logic unit tests for the data-import domain layer (features/data-import/domain/**).
|
||||
// `node` env is enough — the domain is framework-free and uses only Web globals (no DOM,
|
||||
// no React, no axios). Mirrors shared/vitest.config.ts so `npm test -w frontend_publisher`
|
||||
// behaves like the rest of the monorepo.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user