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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+72
View File
@@ -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 &amp; 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 nguồn đy đ 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 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 &amp; xuất bản bài báo
</DialogTitle>
<DialogDescription>
Đây 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ử 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 &amp; Xuất bản
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+285
View File
@@ -0,0 +1,285 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
@layer base {
:root {
/* Warm canvas + cool institutional primary — clearer layers than flat gray-on-cream */
--background: 38 32% 96%;
--foreground: 222 28% 16%;
--card: 0 0% 100%;
--card-foreground: 222 28% 16%;
--popover: 0 0% 100%;
--popover-foreground: 222 28% 16%;
--primary: 217 52% 38%;
--primary-foreground: 210 40% 98%;
--secondary: 220 18% 92%;
--secondary-foreground: 222 28% 22%;
--muted: 36 22% 92%;
--muted-foreground: 220 12% 42%;
--accent: 168 42% 38%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72% 48%;
--destructive-foreground: 210 40% 98%;
--border: 220 14% 88%;
--input: 220 14% 88%;
--ring: 217 52% 38%;
--radius: 1rem;
/* Custom design tokens — tuned to sit cleanly on the new base */
--tag-financing: 215 48% 78%;
--tag-lifestyle: 162 36% 72%;
--tag-community: 32 42% 62%;
--tag-wellness: 278 32% 74%;
--tag-travel: 198 48% 72%;
--tag-creativity: 328 38% 76%;
--tag-growth: 48 46% 68%;
/* Polish design tokens */
--cream: 40 38% 92%;
--cream-foreground: 222 28% 16%;
--surface-elevated: 0 0% 99%;
--shadow-soft: 222 28% 16%;
--transition-smooth: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar tokens */
--sidebar-background: 220 16% 97%;
--sidebar-foreground: 222 28% 16%;
--sidebar-primary: 217 52% 38%;
--sidebar-primary-foreground: 210 40% 98%;
--sidebar-accent: 220 18% 93%;
--sidebar-accent-foreground: 222 28% 22%;
--sidebar-border: 220 14% 88%;
--sidebar-ring: 217 52% 38%;
}
.dark {
--background: 222 24% 12%;
--foreground: 210 36% 96%;
--card: 222 22% 16%;
--card-foreground: 210 36% 96%;
--popover: 222 24% 12%;
--popover-foreground: 210 36% 96%;
--primary: 213 62% 58%;
--primary-foreground: 222 28% 12%;
--secondary: 217 18% 22%;
--secondary-foreground: 210 36% 96%;
--muted: 220 16% 20%;
--muted-foreground: 220 12% 64%;
--accent: 168 40% 44%;
--accent-foreground: 210 36% 98%;
--destructive: 0 63% 48%;
--destructive-foreground: 210 36% 98%;
--border: 217 16% 24%;
--input: 217 16% 24%;
--ring: 213 62% 58%;
/* Dark mode tag colors */
--tag-financing: 215 42% 42%;
--tag-lifestyle: 162 32% 40%;
--tag-community: 32 34% 42%;
--tag-wellness: 278 28% 46%;
--tag-travel: 198 42% 44%;
--tag-creativity: 328 32% 46%;
--tag-growth: 48 38% 44%;
/* Dark mode polish tokens */
--cream: 222 18% 22%;
--cream-foreground: 210 36% 96%;
--surface-elevated: 222 22% 18%;
--shadow-soft: 222 28% 6%;
/* Sidebar tokens */
--sidebar-background: 222 22% 14%;
--sidebar-foreground: 210 36% 96%;
--sidebar-primary: 213 62% 58%;
--sidebar-primary-foreground: 222 28% 12%;
--sidebar-accent: 217 18% 20%;
--sidebar-accent-foreground: 210 36% 96%;
--sidebar-border: 217 16% 24%;
--sidebar-ring: 213 62% 58%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans antialiased;
}
html {
scroll-behavior: smooth;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif tracking-tight;
}
}
@layer utilities {
.card-hover {
@apply transition-all duration-500 hover:scale-[1.02];
box-shadow: 0 4px 20px -4px hsl(var(--shadow-soft) / 0.1);
}
.card-hover:hover {
box-shadow: 0 20px 40px -10px hsl(var(--shadow-soft) / 0.15);
}
.pill-nav {
@apply rounded-full bg-[hsl(var(--surface-elevated))] backdrop-blur-lg border border-border/50;
}
.floating-button {
@apply w-12 h-12 rounded-full bg-[hsl(var(--cream)/0.9)] backdrop-blur-sm flex items-center justify-center text-[hsl(var(--cream-foreground))] hover:bg-[hsl(var(--cream))] hover:scale-110 transition-all duration-300;
box-shadow: 0 4px 12px -2px hsl(var(--shadow-soft) / 0.15);
}
/* Animation keyframes */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-slide-up {
animation: slideUp 0.6s ease-out;
}
.animate-slide-down {
animation: slideDown 0.6s ease-out;
}
.animate-scale-in {
animation: scaleIn 0.5s ease-out;
}
/* Stagger animations */
.stagger-1 {
animation-delay: 0.1s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-2 {
animation-delay: 0.2s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-3 {
animation-delay: 0.3s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-4 {
animation-delay: 0.4s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-5 {
animation-delay: 0.5s;
opacity: 0;
animation-fill-mode: forwards;
}
.stagger-6 {
animation-delay: 0.6s;
opacity: 0;
animation-fill-mode: forwards;
}
.tag-financing {
@apply bg-[hsl(var(--tag-financing))] text-primary;
}
.tag-lifestyle {
@apply bg-[hsl(var(--tag-lifestyle))] text-primary;
}
.tag-community {
@apply bg-[hsl(var(--tag-community))] text-primary;
}
.tag-wellness {
@apply bg-[hsl(var(--tag-wellness))] text-primary;
}
.tag-travel {
@apply bg-[hsl(var(--tag-travel))] text-primary;
}
.tag-creativity {
@apply bg-[hsl(var(--tag-creativity))] text-primary;
}
.tag-growth {
@apply bg-[hsl(var(--tag-growth))] text-primary;
}
}
@@ -0,0 +1,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>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const rootEl = document.getElementById('root');
if (!rootEl) throw new Error('Root element #root not found');
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,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 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" /> 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 nguồn. Mọi luận điểm cần 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 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 &amp; 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 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 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 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 đ tài nào đưc phê duyệt. Chỉ đ tài đã phê duyệt mới 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 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>
);
}