R29-1 R29-6: 重构海音达工作台壳层与高频页面
这个提交包含在:
+37
-12
@@ -143,6 +143,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
const [state, setState] = useState<AuthState>(EMPTY_STATE);
|
||||
const [offlineAccessMode, setOfflineAccessMode] = useState(false);
|
||||
const tokenRef = useRef<string | null>(null);
|
||||
const bootstrapPromiseRef = useRef<Promise<string | null> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
tokenRef.current = state.accessToken;
|
||||
@@ -195,18 +196,28 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = await refreshSession();
|
||||
const me = await fetchCurrentEmployee(refreshed.accessToken);
|
||||
const mustChangePassword = readMustChangePasswordFromStorage();
|
||||
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
|
||||
} catch {
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
return;
|
||||
}
|
||||
clearSession();
|
||||
if (!bootstrapPromiseRef.current) {
|
||||
bootstrapPromiseRef.current = (async () => {
|
||||
try {
|
||||
const refreshed = await refreshSession();
|
||||
const me = await fetchCurrentEmployee(refreshed.accessToken);
|
||||
const mustChangePassword = readMustChangePasswordFromStorage();
|
||||
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
|
||||
return refreshed.accessToken;
|
||||
} catch {
|
||||
if (isWindowAvailable() && window.navigator.onLine === false && offlineSnapshot) {
|
||||
activateOfflineSnapshot(offlineSnapshot);
|
||||
return null;
|
||||
}
|
||||
clearSession();
|
||||
return null;
|
||||
} finally {
|
||||
bootstrapPromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
await bootstrapPromiseRef.current;
|
||||
}, [activateOfflineSnapshot, clearSession, commitAuthenticatedUser]);
|
||||
|
||||
const login = useCallback(
|
||||
@@ -241,8 +252,22 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
configureHttpAuth({
|
||||
getAccessToken: () => tokenRef.current,
|
||||
refreshAccessToken: async () => {
|
||||
if (bootstrapPromiseRef.current) {
|
||||
const bootstrappedToken = await bootstrapPromiseRef.current;
|
||||
if (bootstrappedToken) {
|
||||
return bootstrappedToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshed = await refreshSession();
|
||||
if (!state.me) {
|
||||
const me = await fetchCurrentEmployee(refreshed.accessToken);
|
||||
const mustChangePassword = readMustChangePasswordFromStorage();
|
||||
commitAuthenticatedUser(refreshed.accessToken, me, mustChangePassword);
|
||||
return refreshed.accessToken;
|
||||
}
|
||||
|
||||
tokenRef.current = refreshed.accessToken;
|
||||
setOfflineAccessMode(false);
|
||||
setState((previous) => ({
|
||||
@@ -258,7 +283,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
||||
},
|
||||
clearSession,
|
||||
});
|
||||
}, [clearSession]);
|
||||
}, [clearSession, commitAuthenticatedUser, state.me]);
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrapByRefresh();
|
||||
|
||||
@@ -4,14 +4,16 @@ type EmptyStateCardProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
footer?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export function EmptyStateCard({ title, description, footer }: EmptyStateCardProps) {
|
||||
export function EmptyStateCard({ title, description, footer, icon }: EmptyStateCardProps) {
|
||||
return (
|
||||
<section className="card-std p-8">
|
||||
<h3 className="card-title">{title}</h3>
|
||||
<p className="card-desc">{description}</p>
|
||||
{footer ? <div className="mt-4">{footer}</div> : null}
|
||||
<section className="card-std p-8 text-center">
|
||||
{icon ? <div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-2xl bg-zinc-100 text-zinc-500">{icon}</div> : null}
|
||||
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
|
||||
<p className="card-desc mx-auto max-w-xl">{description}</p>
|
||||
{footer ? <div className="mt-5 flex justify-center">{footer}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
eyebrow?: string;
|
||||
meta?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
export function PageHeader({ title, subtitle, actions, eyebrow, meta, className }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight text-zinc-900">{title}</h2>
|
||||
{subtitle ? <p className="mt-1 text-sm font-medium text-zinc-500">{subtitle}</p> : null}
|
||||
<header className={cn("flex flex-wrap items-start justify-between gap-4", className)}>
|
||||
<div className="min-w-0 max-w-3xl">
|
||||
{eyebrow ? <div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">{eyebrow}</div> : null}
|
||||
<h2 className="mt-1 text-[2rem] font-semibold tracking-tight text-zinc-900">{title}</h2>
|
||||
{subtitle ? <p className="mt-2 text-sm leading-6 text-zinc-600">{subtitle}</p> : null}
|
||||
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchSystemNavigation, type NavigationItem } from "../../api/workspace";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../../auth/permissions";
|
||||
import {
|
||||
ADMIN_NAV_ORDER,
|
||||
BACKUP_NAV_ORDER,
|
||||
MAIN_NAV_ORDER,
|
||||
} from "../../features/navigation/schema";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export type NavItem = {
|
||||
@@ -38,15 +43,11 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/invoice-pool", label: "票据中心", icon: "receipt_long", anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"] },
|
||||
{ to: "/inventory-checks", label: "资产盘点", icon: "inventory", anyOf: ["asset.inventory"] },
|
||||
{ to: "/system/cutover", label: "切换控制台", icon: "swap_calls", anyOf: ["system.settings"] },
|
||||
{ to: "/system/integrations/erp", label: "ERP 集成", icon: "hub", anyOf: ["integration.manage"] },
|
||||
{ to: "/system/integrations/erp", label: "外部系统桥接", icon: "hub", anyOf: ["integration.manage"] },
|
||||
{ to: "/dashboard", label: "仪表盘", icon: "dashboard", anyOf: ["report.dashboard.view"] },
|
||||
{ to: "/settings/profile", label: "个人设置", icon: "manage_accounts" },
|
||||
];
|
||||
|
||||
const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
||||
const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
|
||||
const LEGACY_ACCESSIBLE_LABELS: Record<string, string> = {
|
||||
"/reimbursements": "我的报销",
|
||||
"/workflow/tasks": "待我处理",
|
||||
@@ -61,38 +62,56 @@ type ResolvedNavItem = {
|
||||
};
|
||||
|
||||
function renderNavItem(item: ResolvedNavItem, onNavigate?: () => void) {
|
||||
const accessibleLabel = LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.key ?? item.href}
|
||||
to={item.href}
|
||||
aria-label={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
title={LEGACY_ACCESSIBLE_LABELS[item.href] ?? item.label}
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
onClick={() => onNavigate?.()}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
"group flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-zinc-100 font-bold text-zinc-900"
|
||||
: "font-medium text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
? "bg-zinc-900 text-white shadow-sm"
|
||||
: "text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-9 items-center justify-center rounded-xl border text-[20px] transition-colors",
|
||||
isActive
|
||||
? "border-white/15 bg-white/10 text-white"
|
||||
: "border-zinc-200 bg-white text-zinc-500 group-hover:border-zinc-300 group-hover:text-zinc-900",
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">{item.icon}</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSection(title: string, items: ResolvedNavItem[], onNavigate?: () => void) {
|
||||
function renderSection(title: string, description: string, items: ResolvedNavItem[], onNavigate?: () => void) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="px-3 pt-2 text-[11px] font-semibold uppercase tracking-wide text-zinc-400">{title}</div>
|
||||
{items.map((item) => renderNavItem(item, onNavigate))}
|
||||
</div>
|
||||
<section className="space-y-2">
|
||||
<div className="px-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">{title}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{description}</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">{items.map((item) => renderNavItem(item, onNavigate))}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,14 +121,15 @@ function renderBackupSection(items: ResolvedNavItem[], onNavigate?: () => void)
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="rounded-xl border border-zinc-200 bg-zinc-50/80 px-2 py-2">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between px-2 py-1 text-sm font-semibold text-zinc-700">
|
||||
<span>备用模块</span>
|
||||
<details className="rounded-2xl border border-zinc-200 bg-zinc-50/80 px-3 py-3">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 rounded-xl px-2 py-1.5 text-sm font-semibold text-zinc-700">
|
||||
<div>
|
||||
<div>备用模块</div>
|
||||
<div className="mt-0.5 text-xs font-normal text-zinc-500">默认收起,不干扰主路径</div>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-zinc-400">按需展开</span>
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1 pb-1">
|
||||
{items.map((item) => renderNavItem(item, onNavigate))}
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5 pb-1">{items.map((item) => renderNavItem(item, onNavigate))}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +150,7 @@ function buildFallbackItems(permissions: string[]) {
|
||||
function normalizeSections(sections: { main: NavigationItem[]; admin: NavigationItem[]; backup: NavigationItem[] }) {
|
||||
const allItems = [...sections.main, ...sections.admin, ...sections.backup];
|
||||
const byHref = new Map<string, ResolvedNavItem>();
|
||||
|
||||
for (const item of allItems) {
|
||||
if (!byHref.has(item.href)) {
|
||||
byHref.set(item.href, item);
|
||||
@@ -137,38 +158,45 @@ function normalizeSections(sections: { main: NavigationItem[]; admin: Navigation
|
||||
}
|
||||
|
||||
const used = new Set<string>();
|
||||
const take = (hrefs: string[]) => hrefs
|
||||
.map((href) => {
|
||||
const item = byHref.get(href);
|
||||
if (item) {
|
||||
used.add(href);
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is ResolvedNavItem => Boolean(item));
|
||||
const take = (hrefs: string[]) =>
|
||||
hrefs
|
||||
.map((href) => {
|
||||
const item = byHref.get(href);
|
||||
if (item) {
|
||||
used.add(href);
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is ResolvedNavItem => Boolean(item));
|
||||
|
||||
const main = take(MAIN_NAV_ORDER);
|
||||
const admin = take(ADMIN_NAV_ORDER);
|
||||
const backup = [
|
||||
...take(BACKUP_NAV_ORDER),
|
||||
...allItems.filter((item) => !used.has(item.href)).map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
|
||||
];
|
||||
|
||||
return { main, admin, backup };
|
||||
return {
|
||||
main: take(MAIN_NAV_ORDER),
|
||||
admin: take(ADMIN_NAV_ORDER),
|
||||
backup: [
|
||||
...take(BACKUP_NAV_ORDER),
|
||||
...allItems
|
||||
.filter((item) => !used.has(item.href))
|
||||
.map((item) => ({ key: item.key, href: item.href, label: item.label, icon: item.icon })),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { permissions } = useAuth();
|
||||
const navigationQuery = useQuery({ queryKey: ["system-navigation"], queryFn: fetchSystemNavigation, staleTime: 60_000 });
|
||||
const navigationQuery = useQuery({
|
||||
queryKey: ["system-navigation"],
|
||||
queryFn: fetchSystemNavigation,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const sections = navigationQuery.data?.sections
|
||||
? normalizeSections(navigationQuery.data.sections)
|
||||
: buildFallbackItems(permissions);
|
||||
|
||||
return (
|
||||
<nav className="space-y-4">
|
||||
{renderSection("主导航", sections.main, onNavigate)}
|
||||
{renderSection("管理入口", sections.admin, onNavigate)}
|
||||
<nav className="space-y-5">
|
||||
{renderSection("主导航", "优先处理今日工作与高频协同。", sections.main, onNavigate)}
|
||||
{renderSection("管理入口", "仅管理员与负责人使用。", sections.admin, onNavigate)}
|
||||
{renderBackupSection(sections.backup, onNavigate)}
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { AppBrand } from "../shell/AppBrand";
|
||||
import { NavLinks } from "./NavLinks";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-64 overflow-hidden border-r border-zinc-200 bg-white lg:flex lg:flex-col">
|
||||
<div className="border-b border-zinc-200 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-zinc-900 text-lg font-bold text-white">H</div>
|
||||
<div>
|
||||
<div className="font-bold tracking-tight text-zinc-900">海音达工作台</div>
|
||||
<div className="text-xs text-zinc-500">企业微信入口 + OA 主平台</div>
|
||||
</div>
|
||||
<aside className="fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-zinc-200 bg-white/95 backdrop-blur lg:flex lg:flex-col">
|
||||
<div className="border-b border-zinc-200 px-6 py-6">
|
||||
<AppBrand />
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
|
||||
<div className="font-medium text-zinc-900">统一工作台</div>
|
||||
<div className="mt-1">先看今天要做什么,再进入具体模块处理。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5">
|
||||
<NavLinks />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchNotifications } from "../../api/notifications";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { resolvePageMeta } from "../../features/navigation/schema";
|
||||
import { isWecomWebView } from "../../lib/runtimeContext";
|
||||
import { GlobalSearchDialog } from "../GlobalSearchDialog";
|
||||
import { AppBrand } from "../shell/AppBrand";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTrigger } from "../ui/dialog";
|
||||
import {
|
||||
@@ -18,14 +20,7 @@ import { NavLinks } from "./NavLinks";
|
||||
|
||||
function MenuIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg aria-hidden="true" fill="none" height="20" viewBox="0 0 24 24" width="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
@@ -33,14 +28,7 @@ function MenuIcon() {
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg aria-hidden="true" fill="none" height="20" viewBox="0 0 24 24" width="20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15 17H9m9-2V11a6 6 0 10-12 0v4l-2 2h16l-2-2z"
|
||||
stroke="currentColor"
|
||||
@@ -54,9 +42,11 @@ function BellIcon() {
|
||||
|
||||
export function Topbar() {
|
||||
const { me, logout } = useAuth();
|
||||
const location = useLocation();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const wecomEntry = isWecomWebView();
|
||||
const pageMeta = resolvePageMeta(location.pathname);
|
||||
|
||||
const unreadQuery = useQuery({
|
||||
enabled: Boolean(me),
|
||||
@@ -70,9 +60,9 @@ export function Topbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-10 border-b border-zinc-200 bg-white px-4 py-3 sm:px-8">
|
||||
<header className="sticky top-0 z-10 border-b border-zinc-200/80 bg-white/90 px-4 py-4 backdrop-blur sm:px-8">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单" type="button">
|
||||
@@ -80,13 +70,10 @@ export function Topbar() {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="left-0 top-0 h-full w-72 max-w-[80vw] rounded-none rounded-r-xl p-0">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">主业务平台</p>
|
||||
<p className="mt-1 text-sm font-bold text-zinc-900">海音达工作台</p>
|
||||
</div>
|
||||
<DialogContent className="left-0 top-0 h-full w-80 max-w-[84vw] rounded-none rounded-r-3xl border-r border-zinc-200 p-0">
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex items-start justify-between border-b border-zinc-200 px-5 py-5">
|
||||
<AppBrand />
|
||||
<DialogClose asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭菜单" type="button">
|
||||
✕
|
||||
@@ -94,7 +81,7 @@ export function Topbar() {
|
||||
</DialogClose>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="flex-1 overflow-auto px-4 py-5">
|
||||
<NavLinks onNavigate={() => setMobileNavOpen(false)} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,19 +89,23 @@ export function Topbar() {
|
||||
</Dialog>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-zinc-500">欢迎回来</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-base font-semibold text-zinc-900">{me?.name ?? "-"}</p>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">{pageMeta.group}</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-lg font-semibold tracking-tight text-zinc-900">{pageMeta.title}</p>
|
||||
{wecomEntry ? (
|
||||
<span className="rounded-full border border-primary-200 bg-primary-50 px-2 py-0.5 text-xs font-medium text-primary" data-testid="topbar-wecom-entry-badge">
|
||||
<span
|
||||
className="badge-neutral border-primary-200 bg-primary-50 text-primary"
|
||||
data-testid="topbar-wecom-entry-badge"
|
||||
>
|
||||
当前通过企业微信进入
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">{me ? `你好,${me.name}` : "欢迎进入海音达工作台"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -139,14 +130,7 @@ export function Topbar() {
|
||||
<span className="material-symbols-outlined text-[18px]">search</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
aria-label="通知"
|
||||
type="button"
|
||||
data-testid="notifications-bell"
|
||||
>
|
||||
<Button asChild size="icon" variant="secondary" aria-label="通知" type="button" data-testid="notifications-bell">
|
||||
<Link className="relative" to="/notifications">
|
||||
<BellIcon />
|
||||
{unreadCount > 0 ? (
|
||||
@@ -159,12 +143,12 @@ export function Topbar() {
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" type="button">
|
||||
<Button data-testid="topbar-profile-trigger" variant="secondary" type="button" className="max-w-[160px] truncate">
|
||||
{me?.name ?? "账号"}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/settings/profile">个人设置</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SectionPanel } from "./SectionPanel";
|
||||
|
||||
export function DetailSideMeta({ title, description, children }: { title: string; description?: string; children: ReactNode }) {
|
||||
return (
|
||||
<SectionPanel title={title} description={description}>
|
||||
<div className="space-y-3 text-sm text-zinc-600">{children}</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function EntityOverviewHero({
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="workbench-hero">
|
||||
<div>
|
||||
<h1 className="workbench-hero__title">{title}</h1>
|
||||
{subtitle ? <p className="workbench-hero__subtitle">{subtitle}</p> : null}
|
||||
{meta ? <div className="mt-3 flex flex-wrap gap-2">{meta}</div> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function ListWorkspace({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={cn("page-scaffold", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function FilterBar({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <section className={cn("card-std p-4 md:p-5", className)}>{children}</section>;
|
||||
}
|
||||
|
||||
export function StatStrip({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <section className={cn("grid gap-4 md:grid-cols-3", className)}>{children}</section>;
|
||||
}
|
||||
|
||||
export function ActionToolbar({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={cn("flex flex-wrap items-center justify-between gap-3", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function PrimaryTable({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <section className={cn("card-std p-0 overflow-hidden", className)}>{children}</section>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function MetricCard({
|
||||
title,
|
||||
value,
|
||||
note,
|
||||
href,
|
||||
tone = "neutral",
|
||||
ariaLabel,
|
||||
}: {
|
||||
title: string;
|
||||
value: ReactNode;
|
||||
note?: ReactNode;
|
||||
href?: string;
|
||||
tone?: "neutral" | "warning" | "danger";
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<div className="metric-card__title">{title}</div>
|
||||
<div className="metric-card__value">{value}</div>
|
||||
{note ? <div className="metric-card__note">{note}</div> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
const className = cn(
|
||||
"metric-card",
|
||||
tone === "warning" && "metric-card--warning",
|
||||
tone === "danger" && "metric-card--danger",
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link to={href} aria-label={ariaLabel} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-label={ariaLabel} className={className}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function PageScaffold({ className, children }: { className?: string; children: ReactNode }) {
|
||||
return <div className={cn("page-scaffold", className)}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function SectionPanel({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("card-std section-panel", className)}>
|
||||
<div className="section-panel__header">
|
||||
<div>
|
||||
<h3 className="section-panel__title">{title}</h3>
|
||||
{description ? <p className="section-panel__desc">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
<div className="section-panel__body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function WorkbenchHero({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="workbench-hero">
|
||||
<div>
|
||||
{eyebrow ? <div className="workbench-hero__eyebrow">{eyebrow}</div> : null}
|
||||
<h1 className="workbench-hero__title">{title}</h1>
|
||||
{subtitle ? <p className="workbench-hero__subtitle">{subtitle}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function AppBrand({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex items-center justify-center rounded-2xl bg-[var(--color-accent-primary)] font-bold text-white shadow-sm ${compact ? "size-9 text-base" : "size-11 text-lg"}`}>
|
||||
H
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold tracking-tight text-zinc-900">海音达工作台</div>
|
||||
<div className="text-xs text-zinc-500">企业微信入口 + 工作台主平台</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-300 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-300 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-white hover:bg-primary-light",
|
||||
secondary: "border border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50",
|
||||
default: "bg-[var(--color-accent-primary)] text-white hover:bg-[var(--color-accent-primary-hover)] shadow-sm",
|
||||
secondary: "border border-zinc-200 bg-white text-zinc-700 hover:bg-zinc-50 shadow-sm",
|
||||
outline: "border border-zinc-200 bg-transparent text-zinc-900 hover:bg-zinc-50",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-700",
|
||||
destructive: "bg-red-600 text-white hover:bg-red-700 shadow-sm",
|
||||
ghost: "bg-transparent text-zinc-700 hover:bg-zinc-100",
|
||||
link: "bg-transparent text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-4",
|
||||
sm: "h-10 rounded-md px-3 text-xs",
|
||||
lg: "h-12 rounded-xl px-6 text-base",
|
||||
sm: "h-10 rounded-lg px-3 text-xs",
|
||||
lg: "h-12 rounded-2xl px-6 text-base",
|
||||
icon: "h-11 w-11 p-0",
|
||||
},
|
||||
},
|
||||
@@ -48,4 +48,3 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
:root {
|
||||
--color-bg-canvas: #f5f7fa;
|
||||
--color-bg-surface: #ffffff;
|
||||
--color-bg-surface-subtle: #fafafb;
|
||||
--color-bg-primary-soft: #f2f5f8;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #667085;
|
||||
--color-text-tertiary: #98a2b3;
|
||||
--color-border-default: #e4e7ec;
|
||||
--color-border-strong: #d0d5dd;
|
||||
--color-accent-primary: #111827;
|
||||
--color-accent-primary-hover: #1f2937;
|
||||
--color-success: #067647;
|
||||
--color-warning: #b54708;
|
||||
--color-danger: #b42318;
|
||||
--color-info: #155eef;
|
||||
--radius-sm: 0.5rem;
|
||||
--radius-md: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
--radius-panel: 1rem;
|
||||
--shadow-xs: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
--shadow-sm: 0 1px 3px rgba(16, 24, 40, 0.08), 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
--shadow-md: 0 8px 24px rgba(16, 24, 40, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export const semanticTokens = {
|
||||
colors: {
|
||||
bgCanvas: "#f5f7fa",
|
||||
bgSurface: "#ffffff",
|
||||
bgSurfaceSubtle: "#fafafb",
|
||||
bgPrimarySoft: "#f2f5f8",
|
||||
textPrimary: "#111827",
|
||||
textSecondary: "#667085",
|
||||
textTertiary: "#98a2b3",
|
||||
borderDefault: "#e4e7ec",
|
||||
borderStrong: "#d0d5dd",
|
||||
accentPrimary: "#111827",
|
||||
accentPrimaryHover: "#1f2937",
|
||||
success: "#067647",
|
||||
warning: "#b54708",
|
||||
danger: "#b42318",
|
||||
info: "#155eef",
|
||||
},
|
||||
radius: {
|
||||
sm: "0.5rem",
|
||||
md: "0.75rem",
|
||||
lg: "1rem",
|
||||
xl: "1.25rem",
|
||||
panel: "1rem",
|
||||
},
|
||||
shadow: {
|
||||
xs: "0 1px 2px rgba(16, 24, 40, 0.04)",
|
||||
sm: "0 1px 3px rgba(16, 24, 40, 0.08), 0 1px 2px rgba(16, 24, 40, 0.04)",
|
||||
md: "0 8px 24px rgba(16, 24, 40, 0.08)",
|
||||
},
|
||||
spacing: {
|
||||
pageX: "1.5rem",
|
||||
pageY: "1.5rem",
|
||||
cardX: "1.25rem",
|
||||
cardY: "1.25rem",
|
||||
sectionGap: "1.5rem",
|
||||
clusterGap: "1rem",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type SemanticTokens = typeof semanticTokens;
|
||||
@@ -0,0 +1,33 @@
|
||||
export type AppDensity = "comfortable" | "compact";
|
||||
|
||||
export type PageScaffoldMeta = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
eyebrow?: string;
|
||||
breadcrumb?: Array<{ label: string; href?: string }>;
|
||||
statusTag?: { label: string; tone: "neutral" | "success" | "warning" | "danger" };
|
||||
primaryAction?: { label: string; onClick: () => void };
|
||||
secondaryActions?: Array<{ label: string; onClick: () => void }>;
|
||||
};
|
||||
|
||||
export type NavBadge = {
|
||||
value: string | number;
|
||||
tone: "neutral" | "warning" | "danger";
|
||||
};
|
||||
|
||||
export type NavItemMeta = {
|
||||
key: string;
|
||||
href: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
group: "primary" | "admin" | "backup";
|
||||
priority: number;
|
||||
badge?: NavBadge | null;
|
||||
mobileVisible: boolean;
|
||||
};
|
||||
|
||||
export type PortalSectionSchema = {
|
||||
heroCards: string[];
|
||||
summaryBlocks: string[];
|
||||
supportBlocks: string[];
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
|
||||
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
|
||||
export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
|
||||
|
||||
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/portal", title: "Portal", group: "工作台" },
|
||||
{ path: "/announcements", title: "公告", group: "主导航" },
|
||||
{ path: "/team", title: "团队", group: "主导航" },
|
||||
{ path: "/projects", title: "项目", group: "主导航" },
|
||||
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
|
||||
{ path: "/weekly-reports", title: "周报", group: "主导航" },
|
||||
{ path: "/todos", title: "待办", group: "主导航" },
|
||||
{ path: "/reimbursements/", title: "报销详情", group: "主导航" },
|
||||
{ path: "/reimbursements", title: "报销", group: "主导航" },
|
||||
{ path: "/leave-requests", title: "请假", group: "主导航" },
|
||||
{ path: "/assets", title: "资产", group: "主导航" },
|
||||
{ path: "/workflow/tasks/", title: "审批详情", group: "备用模块" },
|
||||
{ path: "/workflow/tasks", title: "审批待办", group: "备用模块" },
|
||||
{ path: "/settings/profile", title: "个人设置", group: "账号设置" },
|
||||
{ path: "/system/workspace", title: "工作区设置", group: "管理入口" },
|
||||
{ path: "/people", title: "人员管理", group: "管理入口" },
|
||||
{ path: "/system/workflow-definitions", title: "流程定义", group: "管理入口" },
|
||||
{ path: "/system/settings", title: "系统设置", group: "管理入口" },
|
||||
{ path: "/system/feature-flags", title: "功能开关", group: "管理入口" },
|
||||
{ path: "/audit-logs", title: "审计日志", group: "管理入口" },
|
||||
];
|
||||
|
||||
export function resolvePageMeta(pathname: string) {
|
||||
const matched = [...PAGE_TITLES].sort((left, right) => right.path.length - left.path.length)
|
||||
.find((item) => pathname === item.path || pathname.startsWith(item.path));
|
||||
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
|
||||
if (pathname === "/") {
|
||||
return { title: "Portal", group: "工作台" };
|
||||
}
|
||||
|
||||
return { title: pathname, group: "工作台" };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PortalSectionSchema } from "../../design/types";
|
||||
|
||||
export const employeePortalSchema: PortalSectionSchema = {
|
||||
heroCards: ["my_todos", "today_presence", "my_weekly_report", "my_projects"],
|
||||
summaryBlocks: ["applications_assets", "announcements"],
|
||||
supportBlocks: ["recent_visits", "quick_links"],
|
||||
};
|
||||
|
||||
export const managerPortalSchema: PortalSectionSchema = {
|
||||
heroCards: ["pending_approvals", "team_presence", "weekly_missing", "weekly_help"],
|
||||
summaryBlocks: ["management_focus", "announcements"],
|
||||
supportBlocks: ["project_and_people", "adoption_summary"],
|
||||
};
|
||||
+118
-31
@@ -1,82 +1,162 @@
|
||||
/*
|
||||
Style tokens source (single-direction sync):
|
||||
- Extracted from Stitch MCP AI-generated Design Preview (B2B SaaS Theme)
|
||||
Design tokens source:
|
||||
- aligned to R29 semantic workbench system
|
||||
*/
|
||||
|
||||
@import "@material-symbols/font-400/outlined.css";
|
||||
@import "./styles/mobile-touch.css";
|
||||
@import "./design/theme.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-50 font-sans text-zinc-900 antialiased selection:bg-zinc-900 selection:text-white;
|
||||
background: var(--color-bg-canvas);
|
||||
color: var(--color-text-primary);
|
||||
@apply font-sans antialiased selection:bg-zinc-900 selection:text-white;
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply min-h-screen flex flex-col;
|
||||
}
|
||||
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
/* Layout */
|
||||
.layout-container {
|
||||
@apply mx-auto w-full px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
@apply flex items-center justify-center gap-2 px-4 py-2 bg-zinc-900 text-white rounded-lg text-sm font-bold hover:bg-zinc-800 transition-all shadow-sm;
|
||||
.page-scaffold {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply flex items-center justify-center gap-2 px-3 py-2 bg-white border border-zinc-200 rounded-lg text-sm font-medium text-zinc-700 hover:bg-zinc-50 transition-colors shadow-sm;
|
||||
.workbench-hero {
|
||||
background: linear-gradient(135deg, rgba(17, 24, 39, 0.03), rgba(255, 255, 255, 0.96));
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply flex flex-col gap-4 px-6 py-6 md:flex-row md:items-end md:justify-between;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply text-zinc-400 hover:text-zinc-600 p-1 rounded-md transition-colors;
|
||||
.workbench-hero__eyebrow {
|
||||
@apply text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500;
|
||||
}
|
||||
|
||||
.workbench-hero__title {
|
||||
@apply mt-2 text-3xl font-semibold tracking-tight text-zinc-900;
|
||||
}
|
||||
|
||||
.workbench-hero__subtitle {
|
||||
@apply mt-2 max-w-3xl text-sm leading-6 text-zinc-600;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card-std {
|
||||
@apply bg-white rounded-xl shadow-sm ring-1 ring-zinc-900/5 transition-all;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-panel);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
@apply text-sm font-medium text-zinc-500 mb-1;
|
||||
@apply text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
@apply mt-2 text-sm leading-6 text-zinc-600;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
@apply text-3xl font-bold text-zinc-900 tracking-tight;
|
||||
@apply text-3xl font-semibold tracking-tight text-zinc-900;
|
||||
}
|
||||
|
||||
.section-panel {
|
||||
@apply p-6;
|
||||
}
|
||||
|
||||
.section-panel__header {
|
||||
@apply flex flex-wrap items-start justify-between gap-3;
|
||||
}
|
||||
|
||||
.section-panel__title {
|
||||
@apply text-base font-semibold text-zinc-900;
|
||||
}
|
||||
|
||||
.section-panel__desc {
|
||||
@apply mt-1 text-sm text-zinc-500;
|
||||
}
|
||||
|
||||
.section-panel__body {
|
||||
@apply mt-5;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xs);
|
||||
@apply block px-5 py-5 transition-colors hover:bg-zinc-50;
|
||||
}
|
||||
|
||||
.metric-card--warning {
|
||||
@apply border-amber-200 bg-amber-50 hover:bg-amber-100;
|
||||
}
|
||||
|
||||
.metric-card--danger {
|
||||
@apply border-red-200 bg-red-50 hover:bg-red-100;
|
||||
}
|
||||
|
||||
.metric-card__title {
|
||||
@apply text-sm font-medium text-zinc-500;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
@apply mt-2 text-2xl font-semibold tracking-tight text-zinc-900;
|
||||
}
|
||||
|
||||
.metric-card__note {
|
||||
@apply mt-2 text-sm text-zinc-500;
|
||||
}
|
||||
|
||||
.info-chip {
|
||||
@apply inline-flex items-center rounded-full border border-zinc-200 bg-white px-3 py-1 text-xs font-medium text-zinc-600;
|
||||
}
|
||||
|
||||
.surface-muted {
|
||||
@apply rounded-2xl border border-zinc-200 bg-zinc-50;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.input-std {
|
||||
@apply h-10 w-full rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400;
|
||||
@apply h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-900 shadow-sm outline-none transition-colors placeholder:text-zinc-400 focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200;
|
||||
}
|
||||
|
||||
.select-std {
|
||||
@apply h-10 w-full rounded-md border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400;
|
||||
@apply h-11 w-full rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 shadow-sm outline-none transition-colors focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200;
|
||||
}
|
||||
|
||||
.select-sm {
|
||||
@apply h-8 rounded-md border border-zinc-200 bg-white px-2 py-1 text-xs font-medium text-zinc-600 shadow-sm outline-none transition-colors focus:border-zinc-400 focus:ring-1 focus:ring-zinc-400;
|
||||
@apply h-8 rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs font-medium text-zinc-600 shadow-sm outline-none transition-colors focus:border-zinc-400 focus:ring-2 focus:ring-zinc-200;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-container {
|
||||
@apply bg-white rounded-xl shadow-sm ring-1 ring-zinc-900/5 overflow-hidden;
|
||||
@apply overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply bg-zinc-50/50 border-b border-zinc-100;
|
||||
@apply border-b border-zinc-100 bg-zinc-50/70;
|
||||
}
|
||||
|
||||
.table-th {
|
||||
@apply px-6 py-4 text-xs font-bold text-zinc-500 text-left;
|
||||
@apply px-6 py-4 text-left text-xs font-semibold uppercase tracking-wide text-zinc-500;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
@@ -88,23 +168,30 @@
|
||||
}
|
||||
|
||||
.table-td-strong {
|
||||
@apply px-6 py-4 text-sm font-bold text-zinc-900;
|
||||
@apply px-6 py-4 text-sm font-semibold text-zinc-900;
|
||||
}
|
||||
|
||||
.page-shell-grid {
|
||||
@apply grid gap-6 xl:grid-cols-[minmax(0,1fr),360px];
|
||||
}
|
||||
|
||||
.detail-shell-grid {
|
||||
@apply grid gap-6 xl:grid-cols-[minmax(0,1.4fr),minmax(320px,0.8fr)];
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge-success {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 ring-1 ring-inset ring-emerald-600/10;
|
||||
@apply inline-flex items-center rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 ring-1 ring-inset ring-emerald-600/10;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-50 text-amber-700 ring-1 ring-inset ring-amber-600/10;
|
||||
@apply inline-flex items-center rounded-full bg-amber-50 px-2.5 py-0.5 text-xs font-semibold text-amber-700 ring-1 ring-inset ring-amber-600/10;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-50 text-red-700 ring-1 ring-inset ring-red-600/10;
|
||||
@apply inline-flex items-center rounded-full bg-red-50 px-2.5 py-0.5 text-xs font-semibold text-red-700 ring-1 ring-inset ring-red-600/10;
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-zinc-100 text-zinc-700 ring-1 ring-inset ring-zinc-600/10;
|
||||
@apply inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-semibold text-zinc-700 ring-1 ring-inset ring-zinc-600/10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,11 @@
|
||||
import { useEffect } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { NAV_ITEMS } from "../components/navigation/NavLinks";
|
||||
import { ErrorBoundary } from "../components/common/ErrorBoundary";
|
||||
import { Sidebar } from "../components/navigation/Sidebar";
|
||||
import { Topbar } from "../components/navigation/Topbar";
|
||||
import { resolvePageMeta } from "../features/navigation/schema";
|
||||
import { writeRecentVisit } from "../lib/recentVisits";
|
||||
|
||||
const EXTRA_TITLE_ENTRIES = [
|
||||
{ path: "/notifications", title: "通知中心" },
|
||||
{ path: "/reimbursements/", title: "报销详情" },
|
||||
{ path: "/workflow/tasks/", title: "审批详情" },
|
||||
{ path: "/people/", title: "员工档案" },
|
||||
];
|
||||
|
||||
function resolveVisitTitle(pathname: string) {
|
||||
const candidates = [
|
||||
...NAV_ITEMS.map((item) => ({ path: item.to, title: item.label })),
|
||||
...EXTRA_TITLE_ENTRIES,
|
||||
].sort((left, right) => right.path.length - left.path.length);
|
||||
|
||||
const matched = candidates.find((item) => pathname === item.path || pathname.startsWith(item.path));
|
||||
if (matched) {
|
||||
return matched.title;
|
||||
}
|
||||
|
||||
return pathname === "/" ? "Portal" : pathname;
|
||||
}
|
||||
|
||||
export function AppShellLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -35,17 +14,17 @@ export function AppShellLayout() {
|
||||
if (!pathname || pathname === "/login" || pathname.startsWith("/mobile/")) {
|
||||
return;
|
||||
}
|
||||
writeRecentVisit(pathname, resolveVisitTitle(pathname));
|
||||
writeRecentVisit(pathname, resolvePageMeta(pathname).title);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-zinc-50">
|
||||
<div className="flex min-h-screen bg-[var(--color-bg-canvas)]">
|
||||
<Sidebar />
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0 lg:ml-64">
|
||||
<main className="flex min-w-0 flex-1 flex-col lg:ml-72">
|
||||
<Topbar />
|
||||
|
||||
<div className="flex-1 p-4 sm:p-8">
|
||||
<div className="flex-1 px-4 py-6 sm:px-8 sm:py-8">
|
||||
<div className="layout-container max-w-none">
|
||||
<ErrorBoundary>
|
||||
<Outlet />
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { AppBrand } from "../components/shell/AppBrand";
|
||||
|
||||
export function AuthLayout() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-primary-50 via-background-light to-zinc-100 p-6">
|
||||
<div className="w-full max-w-md">
|
||||
<Outlet />
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(17,24,39,0.08),_transparent_34%),linear-gradient(180deg,_#f8fafc_0%,_#f3f4f6_100%)] px-6 py-10 lg:px-10 lg:py-14">
|
||||
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl items-center gap-8 lg:grid-cols-[minmax(0,1.05fr),minmax(420px,0.95fr)]">
|
||||
<section className="hidden rounded-[2rem] border border-zinc-200 bg-white/75 p-10 shadow-sm backdrop-blur lg:block">
|
||||
<AppBrand />
|
||||
<div className="mt-8 space-y-6">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">统一入口</div>
|
||||
<h1 className="mt-2 text-4xl font-semibold tracking-tight text-zinc-900">让员工先找到今天要做什么</h1>
|
||||
<p className="mt-3 max-w-xl text-base leading-7 text-zinc-600">
|
||||
企业微信负责进入和提醒,真正的审批、项目、周报与留痕都在 OA 内完成。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="surface-muted px-5 py-5">
|
||||
<div className="text-sm font-semibold text-zinc-900">主路径稳定</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">Portal、团队、请假、报销、资产、周报、待办、公告、项目保持稳定结构。</div>
|
||||
</div>
|
||||
<div className="surface-muted px-5 py-5">
|
||||
<div className="text-sm font-semibold text-zinc-900">入口可回退</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">企业微信免登失败时仍可使用账号密码,不让员工猜测下一步。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto w-full max-w-xl">
|
||||
<Outlet />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+301
-272
@@ -21,6 +21,9 @@ import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -129,6 +132,18 @@ export function LeaveRequestPage() {
|
||||
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
|
||||
}, [leaveTypeId, leaveTypesQuery.data?.items]);
|
||||
|
||||
const balanceSummary = useMemo(() => {
|
||||
return (balancesQuery.data?.items ?? []).reduce(
|
||||
(acc, item) => {
|
||||
acc.available += item.available;
|
||||
acc.reserved += item.reserved;
|
||||
acc.used += item.used;
|
||||
return acc;
|
||||
},
|
||||
{ available: 0, reserved: 0, used: 0 },
|
||||
);
|
||||
}, [balancesQuery.data?.items]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!leaveTypeId) {
|
||||
@@ -218,303 +233,317 @@ export function LeaveRequestPage() {
|
||||
const totalPages = Math.max(1, Math.ceil((requestsQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ListWorkspace>
|
||||
<PageHeader
|
||||
title="请假申请"
|
||||
subtitle="支持假种选择、半天粒度请假、额度冻结与撤回释放。"
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">额度概览</h3>
|
||||
{balancesQuery.isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
</div>
|
||||
) : null}
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
|
||||
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
|
||||
<MetricCard title="本次预估" value={requestedDaysPreview ?? "-"} note="创建草稿前可先预览天数" tone={requestedDaysPreview ? "warning" : "neutral"} />
|
||||
</section>
|
||||
|
||||
{balancesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(balancesQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="page-shell-grid">
|
||||
<SectionPanel title="新建请假草稿" description="先填写时间段与假种,再补充附件和说明。">
|
||||
<div className="space-y-4">
|
||||
{formError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!balancesQuery.isLoading && !balancesQuery.isError ? (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{(balancesQuery.data?.items ?? []).length ? (
|
||||
balancesQuery.data?.items.map((item) => (
|
||||
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
|
||||
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
|
||||
</div>
|
||||
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-zinc-500">可用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已冻结</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已使用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">授予额度</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="card-std p-6 text-sm text-zinc-500 md:col-span-3">
|
||||
暂无可用假种,请联系 HR 初始化请假类型与策略。
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-type">假期类型</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-type"
|
||||
value={leaveTypeId}
|
||||
onChange={(event) => setLeaveTypeId(event.target.value)}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{(leaveTypesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-date">开始日期</Label>
|
||||
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-session">开始时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-start-session"
|
||||
value={startSession}
|
||||
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-date">结束日期</Label>
|
||||
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-session">结束时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-end-session"
|
||||
value={endSession}
|
||||
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-attachment">附件</Label>
|
||||
<Input
|
||||
id="leave-attachment"
|
||||
type="file"
|
||||
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
|
||||
onChange={(event) => {
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
setAttachmentFile(nextFile);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{selectedLeaveType?.requiresAttachment
|
||||
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
|
||||
: "附件为可选项"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-reason">请假说明</Label>
|
||||
<Textarea
|
||||
id="leave-reason"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="请输入请假原因、备注或补充说明"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
|
||||
<div>
|
||||
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="额度概览" description="假种可用额度、冻结与已使用情况按自然周期汇总。">
|
||||
<div className="space-y-4">
|
||||
{balancesQuery.isLoading ? (
|
||||
<div className="grid gap-4">
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
<CardSkeleton lines={4} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{balancesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(balancesQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!balancesQuery.isLoading && !balancesQuery.isError ? (
|
||||
<div className="grid gap-4">
|
||||
{(balancesQuery.data?.items ?? []).length ? (
|
||||
balancesQuery.data?.items.map((item) => (
|
||||
<div className="card-std space-y-2 p-5" key={item.leaveType.id}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-zinc-900">{item.leaveType.name}</p>
|
||||
<p className="text-xs text-zinc-500">{item.periodStart} ~ {item.periodEnd}</p>
|
||||
</div>
|
||||
<StatusBadge status={item.leaveType.enabled ? "active" : "archived"} label={item.leaveType.enabled ? "启用" : "停用"} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-zinc-500">可用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.available}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已冻结</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.reserved}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">已使用</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.used}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500">授予额度</p>
|
||||
<p className="text-xl font-semibold text-zinc-900">{item.policy.annualQuotaDays}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-sm text-zinc-500">
|
||||
暂无可用假种,请联系 HR 初始化请假类型与策略。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建请假草稿</h3>
|
||||
|
||||
{formError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-type">假期类型</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-type"
|
||||
value={leaveTypeId}
|
||||
onChange={(event) => setLeaveTypeId(event.target.value)}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
{(leaveTypesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-date">开始日期</Label>
|
||||
<Input id="leave-start-date" type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-start-session">开始时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-start-session"
|
||||
value={startSession}
|
||||
onChange={(event) => setStartSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-date">结束日期</Label>
|
||||
<Input id="leave-end-date" type="date" value={endDate} onChange={(event) => setEndDate(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-end-session">结束时段</Label>
|
||||
<select
|
||||
className="select-std"
|
||||
id="leave-end-session"
|
||||
value={endSession}
|
||||
onChange={(event) => setEndSession(event.target.value as LeaveSession)}
|
||||
>
|
||||
<option value="am">上午</option>
|
||||
<option value="pm">下午</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-attachment">附件</Label>
|
||||
<Input
|
||||
id="leave-attachment"
|
||||
type="file"
|
||||
accept={selectedLeaveType?.attachmentMimeTypes?.join(",") || undefined}
|
||||
onChange={(event) => {
|
||||
const nextFile = event.target.files?.[0] ?? null;
|
||||
setAttachmentFile(nextFile);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{selectedLeaveType?.requiresAttachment
|
||||
? `当前假种要求上传附件(允许:${selectedLeaveType.attachmentMimeTypes.join(", ") || "默认文档/图片"})`
|
||||
: "附件为可选项"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leave-reason">请假说明</Label>
|
||||
<Textarea
|
||||
id="leave-reason"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="请输入请假原因、备注或补充说明"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
|
||||
<div>
|
||||
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
||||
</div>
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={() => createMutation.mutate()}>
|
||||
{createMutation.isPending ? "创建中..." : "创建草稿"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">请假记录</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canViewAll ? (
|
||||
<SectionPanel title="请假记录" description="支持按状态和范围收敛列表,草稿与驳回单可再次提交。">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-zinc-500">最近一次筛选会保留在当前页面状态中。</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canViewAll ? (
|
||||
<select
|
||||
className="select-std"
|
||||
value={scope}
|
||||
onChange={(event) => {
|
||||
setScope(event.target.value as "own" | "all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="own">只看我的</option>
|
||||
<option value="all">查看全员</option>
|
||||
</select>
|
||||
) : null}
|
||||
<select
|
||||
className="select-std"
|
||||
value={scope}
|
||||
value={statusFilter}
|
||||
onChange={(event) => {
|
||||
setScope(event.target.value as "own" | "all");
|
||||
setStatusFilter(event.target.value as LeaveRequestStatus | "");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="own">只看我的</option>
|
||||
<option value="all">查看全员</option>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="reviewing">审批中</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
) : null}
|
||||
<select
|
||||
className="select-std"
|
||||
value={statusFilter}
|
||||
onChange={(event) => {
|
||||
setStatusFilter(event.target.value as LeaveRequestStatus | "");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="reviewing">审批中</option>
|
||||
<option value="approved">已通过</option>
|
||||
<option value="rejected">已驳回</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["leave", "requests"] })}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
{requestsQuery.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||
|
||||
{requestsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(requestsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
{requestsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveError(requestsQuery.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{!requestsQuery.isLoading && !requestsQuery.isError ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>假种 / 申请人</TableHead>
|
||||
<TableHead>时间段</TableHead>
|
||||
<TableHead>天数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>附件</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="w-44">操作</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(requestsQuery.data?.items ?? []).length ? (
|
||||
requestsQuery.data?.items.map((item) => {
|
||||
const isOwner = item.ownerId === me?.id;
|
||||
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||
const canWithdraw = isOwner && item.status === "reviewing";
|
||||
{!requestsQuery.isLoading && !requestsQuery.isError ? (
|
||||
<>
|
||||
<PrimaryTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>假种 / 申请人</TableHead>
|
||||
<TableHead>时间段</TableHead>
|
||||
<TableHead>天数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>附件</TableHead>
|
||||
<TableHead>更新时间</TableHead>
|
||||
<TableHead className="w-44">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(requestsQuery.data?.items ?? []).length ? (
|
||||
requestsQuery.data?.items.map((item) => {
|
||||
const isOwner = item.ownerId === me?.id;
|
||||
const canSubmit = isOwner && ["draft", "rejected"].includes(item.status);
|
||||
const canWithdraw = isOwner && item.status === "reviewing";
|
||||
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{item.owner?.name ?? "-"}
|
||||
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatRangeLabel(item)}</TableCell>
|
||||
<TableCell>{item.requestedDays}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell className="text-xs text-zinc-500">
|
||||
{item.attachment?.originalName ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!canSubmit || submitMutation.isPending}
|
||||
onClick={() => submitMutation.mutate(item.id)}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||
onClick={() =>
|
||||
setWithdrawTarget({
|
||||
id: item.id,
|
||||
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.leaveType?.name ?? "-"}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{item.owner?.name ?? "-"}
|
||||
{item.owner?.employeeNo ? ` (${item.owner.employeeNo})` : ""}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{formatRangeLabel(item)}</TableCell>
|
||||
<TableCell>{item.requestedDays}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell className="text-xs text-zinc-500">
|
||||
{item.attachment?.originalName ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>{item.updatedAt ? new Date(item.updatedAt).toLocaleString() : "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!canSubmit || submitMutation.isPending}
|
||||
onClick={() => submitMutation.mutate(item.id)}
|
||||
>
|
||||
提交
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={!canWithdraw || withdrawMutation.isPending}
|
||||
onClick={() =>
|
||||
setWithdrawTarget({
|
||||
id: item.id,
|
||||
label: `${item.leaveType?.name ?? "请假"} · ${formatRangeLabel(item)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-4 text-zinc-500" colSpan={7}>
|
||||
暂无请假记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-4 text-zinc-500" colSpan={7}>
|
||||
暂无请假记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PrimaryTable>
|
||||
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
testIdPrefix="leave-requests"
|
||||
/>
|
||||
</div>
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
testIdPrefix="leave-requests"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(withdrawTarget)}
|
||||
@@ -531,6 +560,6 @@ export function LeaveRequestPage() {
|
||||
await withdrawMutation.mutateAsync(withdrawTarget.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
+117
-107
@@ -88,7 +88,7 @@ export function LoginPage() {
|
||||
} else if (err instanceof TypeError) {
|
||||
setError("无法连接后端服务,请确认 server 已启动(http://127.0.0.1:3000)。");
|
||||
} else {
|
||||
setError("登录失败,请稍后重试");
|
||||
setError("登录失败,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -102,125 +102,135 @@ export function LoginPage() {
|
||||
window.location.assign(buildWecomLoginUrl(wecomEntry.loginUrl, redirectTo));
|
||||
}
|
||||
|
||||
function renderPasswordPanel(testId: string, primary: boolean) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-[1.5rem] border border-zinc-200 bg-white p-6 shadow-sm" data-testid={testId}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">{primary ? "推荐入口" : "回退入口"}</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900">使用账号密码登录</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{primary
|
||||
? "适合桌面浏览器直接登录;若企业微信入口暂未开放,也可正常使用。"
|
||||
: "企业微信免登失败、未绑定或超时时,可改用账号密码继续进入。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-identifier">账号</Label>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
data-testid="login-identifier"
|
||||
id="login-identifier"
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="邮箱或手机号"
|
||||
value={identifier}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-password">密码</Label>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
data-testid="login-password"
|
||||
id="login-password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? <div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null}
|
||||
|
||||
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primary ? "default" : "secondary"}>
|
||||
{submitting ? "登录中..." : "登录并进入工作台"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std overflow-hidden border-primary-100 p-0">
|
||||
<div className="border-b border-zinc-200 bg-gradient-to-br from-primary-50 via-white to-zinc-50 p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">hyb-oa</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900">登录工作台</h1>
|
||||
<p className="mt-2 text-sm text-secondary">审批、项目、周报和团队状态统一在 OA 完成。</p>
|
||||
<div className="mt-4 rounded-xl border border-zinc-200 bg-white/90 px-4 py-3 text-sm text-zinc-600">
|
||||
<div className="card-std overflow-hidden border-zinc-200 p-0 shadow-md">
|
||||
<div className="border-b border-zinc-200 bg-gradient-to-br from-zinc-50 via-white to-zinc-100 px-6 py-6 sm:px-8 sm:py-7">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">统一入口</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-zinc-900">登录海音达工作台</h1>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-zinc-600">
|
||||
企业微信负责进入与提醒,真正的审批、项目、周报和团队留痕都在海音达工作台内完成。
|
||||
</p>
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-white/90 px-4 py-3 text-sm text-zinc-600">
|
||||
{isWecomContext
|
||||
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台。"
|
||||
: "桌面浏览器可直接使用账号密码登录;企业微信仅作为入口与提醒。"}
|
||||
? "当前在企业微信中打开,推荐直接使用企业微信进入工作台;失败时可随时回退到账号密码登录。"
|
||||
: "当前为浏览器场景,推荐直接使用账号密码登录;企业微信入口会作为次级入口提供。"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-6">
|
||||
<div className="space-y-5 px-6 py-6 sm:px-8 sm:py-7">
|
||||
{wecomNotice ? (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(wecomNotice.tone)}`} data-testid="login-wecom-notice">
|
||||
<div className="font-medium">{wecomNotice.title}</div>
|
||||
<div className="mt-1">{wecomNotice.description}</div>
|
||||
<div className="mt-1 leading-6">{wecomNotice.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
|
||||
<div className="space-y-3 rounded-2xl border border-primary-100 bg-primary-50/70 p-5" data-testid="login-wecom-primary-panel">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">推荐入口</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-zinc-900">使用企业微信进入工作台</h2>
|
||||
<p className="mt-1 text-sm text-zinc-600">在企业微信工作台打开时,优先使用免登入口;失败时仍可回退到账号密码登录。</p>
|
||||
</div>
|
||||
<Button className="w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
|
||||
使用企业微信进入工作台
|
||||
</Button>
|
||||
<p className="text-xs text-zinc-500">若当前企业微信账号尚未绑定,请先使用账号密码登录,再到个人设置完成绑定。</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-zinc-200 p-5" data-testid={primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel"}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-900">账号密码登录</h2>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{primaryMode === "password"
|
||||
? "使用公司邮箱或手机号登录,复杂填写与审批仍建议在 Web OA 内完成。"
|
||||
: "企业微信不可用时,可改用账号密码登录。"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">忘记密码:请联系管理员重置。</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="identifier">账号</Label>
|
||||
<Input
|
||||
autoComplete="username"
|
||||
data-testid="login-identifier"
|
||||
id="identifier"
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="邮箱或手机号"
|
||||
value={identifier}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
data-testid="login-password"
|
||||
id="password"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="login-error"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button data-testid="login-submit" className="w-full" disabled={submitting} type="submit" variant={primaryMode === "password" ? "default" : "secondary"}>
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
|
||||
<div className="space-y-3 rounded-2xl border border-zinc-200 bg-zinc-50 p-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-zinc-500">其他入口</p>
|
||||
<p className="mt-1 text-sm text-zinc-600">企业微信负责打开 OA 和接收提醒,实际业务仍在 OA 内完成。</p>
|
||||
</div>
|
||||
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
|
||||
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
||||
<div className="grid gap-4">
|
||||
{primaryMode === "wecom" && wecomEntry?.loginUrl ? (
|
||||
<div className="rounded-[1.5rem] border border-primary-100 bg-primary-50/70 p-6" data-testid="login-wecom-primary-panel">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">推荐入口</div>
|
||||
<h2 className="mt-2 text-xl font-semibold tracking-tight text-zinc-900">使用企业微信进入工作台</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">当前是企业微信场景,优先使用免登入口;若未绑定或回调失效,仍可切换为账号密码登录。</p>
|
||||
<Button className="mt-5 w-full" data-testid="wecom-entry-primary" type="button" onClick={handleWecomLogin}>
|
||||
使用企业微信进入工作台
|
||||
</Button>
|
||||
) : null}
|
||||
{oidcProvider ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isWecomContext && !wecomEntry?.loginUrl && !wecomConfigQuery.isLoading ? (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(wecomStatus.tone)}`}>
|
||||
<div className="font-medium">企业微信入口当前不可用</div>
|
||||
<div className="mt-1">{wecomStatus.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{renderPasswordPanel(
|
||||
primaryMode === "password" ? "login-password-primary-panel" : "login-password-secondary-panel",
|
||||
primaryMode === "password",
|
||||
)}
|
||||
|
||||
{((primaryMode !== "wecom" && wecomEntry?.loginUrl) || oidcProvider) ? (
|
||||
<div className="rounded-[1.5rem] border border-zinc-200 bg-zinc-50/70 p-6">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">补充入口</div>
|
||||
<h2 className="mt-2 text-lg font-semibold text-zinc-900">其他进入方式</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-zinc-600">入口失败时请优先选择可理解、可回退的方式继续进入。</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{primaryMode !== "wecom" && wecomEntry?.loginUrl ? (
|
||||
<Button className="w-full" data-testid="wecom-entry-secondary" type="button" variant="secondary" onClick={handleWecomLogin}>
|
||||
使用企业微信进入工作台
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{oidcProvider ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-600">
|
||||
<div className="font-medium text-zinc-900">企业微信入口状态:{wecomStatus.label}</div>
|
||||
<div className="mt-1 leading-6">{wecomStatus.description}</div>
|
||||
{wecomStatus.reasonLabels.length ? (
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-zinc-500">
|
||||
{wecomStatus.reasonLabels.map((label) => (
|
||||
<li key={label}>{label}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+158
-211
@@ -1,13 +1,18 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchPortalHome, type PortalHomePayload } from "../api/portal";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { readRecentVisits } from "../lib/recentVisits";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { EmptyStateCard } from "../components/common/EmptyStateCard";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { WorkbenchHero } from "../components/patterns/WorkbenchHero";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { employeePortalSchema, managerPortalSchema } from "../features/portal/schema";
|
||||
import { readRecentVisits } from "../lib/recentVisits";
|
||||
|
||||
type SummaryItem = {
|
||||
key: string;
|
||||
@@ -22,14 +27,10 @@ function isSummaryItem(item: SummaryItem | null): item is SummaryItem {
|
||||
return Boolean(item);
|
||||
}
|
||||
|
||||
function cardClasses(severity?: "normal" | "warning" | "high") {
|
||||
if (severity === "high") {
|
||||
return "card-std border-red-200 bg-red-50 p-5";
|
||||
}
|
||||
if (severity === "warning") {
|
||||
return "card-std border-amber-200 bg-amber-50 p-5";
|
||||
}
|
||||
return "card-std p-5";
|
||||
function severityTone(severity?: "normal" | "warning" | "high") {
|
||||
if (severity === "high") return "danger" as const;
|
||||
if (severity === "warning") return "warning" as const;
|
||||
return "neutral" as const;
|
||||
}
|
||||
|
||||
function pickCards(cards: PortalHomePayload["cards"], keys: string[]) {
|
||||
@@ -42,101 +43,59 @@ function compactCards(cards: PortalHomePayload["cards"], excludedKeys: string[])
|
||||
}
|
||||
|
||||
function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
|
||||
if (!payload) {
|
||||
return "未启用";
|
||||
}
|
||||
if (!payload) return "未启用";
|
||||
return payload.status === "submitted" ? "已提交" : "待填写";
|
||||
}
|
||||
|
||||
function renderAnnouncementLink(item: PortalHomePayload["announcements"][number]) {
|
||||
if (!item.href) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
className="mt-2 inline-flex text-sm font-medium text-primary hover:underline"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
查看详情
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
return <EmptyStateCard title={title} description="暂无需要优先处理的摘要信息。" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<SectionPanel title={title} description="优先看结论与下一步,再进入对应模块处理。">
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.href}
|
||||
className={item.tone === "warning"
|
||||
? "block rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 hover:bg-amber-100"
|
||||
: "block rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50"}
|
||||
? "block rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 hover:bg-amber-100"
|
||||
: "block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50"}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-zinc-900">{item.label}</span>
|
||||
<span className="text-sm font-semibold text-zinc-900">{item.value}</span>
|
||||
<div className="font-medium text-zinc-900">{item.label}</div>
|
||||
<div className="text-sm font-semibold text-zinc-900">{item.value}</div>
|
||||
</div>
|
||||
{item.note ? <div className="mt-1 text-xs text-zinc-500">{item.note}</div> : null}
|
||||
{item.note ? <div className="mt-2 text-sm leading-6 text-zinc-500">{item.note}</div> : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryCardsPanel({ title, cards }: { title: string; cards: PortalHomePayload["cards"] }) {
|
||||
if (!cards.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.key}
|
||||
to={card.href}
|
||||
className={card.severity === "high"
|
||||
? "rounded-xl border border-red-200 bg-red-50 px-4 py-3 hover:bg-red-100"
|
||||
: card.severity === "warning"
|
||||
? "rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 hover:bg-amber-100"
|
||||
: "rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50"}
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">{card.title}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{card.value}</div>
|
||||
</Link>
|
||||
))}
|
||||
<SectionPanel
|
||||
title="公告"
|
||||
description="公告保留在第二阅读层,不与今日任务卡竞争。"
|
||||
actions={<Link to="/announcements" className="text-sm font-medium text-primary hover:underline">查看全部</Link>}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{announcements.length ? announcements.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">{item.summary}</div>
|
||||
{item.href ? (
|
||||
<a className="mt-3 inline-flex text-sm font-medium text-primary hover:underline" href={item.href} rel="noreferrer" target="_blank">
|
||||
查看详情
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)) : <EmptyStateCard title="暂无公告" description="当前没有需要广播的公告。" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">常用入口</h3>
|
||||
<div className="grid gap-2">
|
||||
{items.map((item) => (
|
||||
<Link key={item.key} to={item.href} className="rounded-xl border border-zinc-200 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50">
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,77 +103,91 @@ function RecentVisitsPanel() {
|
||||
const recentVisits = readRecentVisits();
|
||||
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">最近访问</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{recentVisits.length ? recentVisits.map((item) => (
|
||||
<Link key={item.path} to={item.path} className="block rounded-xl border border-zinc-200 px-3 py-2 hover:bg-zinc-50">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.path}</div>
|
||||
</Link>
|
||||
)) : <div className="text-zinc-500">暂无最近访问</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnnouncementsPanel({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||
return (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">公告</h3>
|
||||
<Link to="/announcements" className="text-sm text-primary hover:underline">查看全部</Link>
|
||||
</div>
|
||||
<SectionPanel title="继续工作" description="保留最近访问路径,帮助快速回到上一次处理中断的位置。">
|
||||
<div className="space-y-3">
|
||||
{announcements.length ? announcements.slice(0, 4).map((item) => (
|
||||
<div key={item.id} className="rounded-xl border border-zinc-200 p-3">
|
||||
{recentVisits.length ? recentVisits.map((item) => (
|
||||
<Link key={item.path} to={item.path} className="block rounded-2xl border border-zinc-200 bg-white px-4 py-4 hover:bg-zinc-50">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
|
||||
{renderAnnouncementLink(item)}
|
||||
</div>
|
||||
)) : <div className="rounded-xl border border-dashed border-zinc-200 px-4 py-6 text-sm text-zinc-500">暂无公告,后续可由管理员在公告模块统一发布通知。</div>}
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.path}</div>
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无最近访问" description="开始使用后,最近处理过的页面会显示在这里。" />}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: PortalHomePayload["adoptionSummary"] }) {
|
||||
function QuickLinksPanel({ items }: { items: PortalHomePayload["quickLinks"] }) {
|
||||
return (
|
||||
<SectionPanel title="常用入口" description="只保留高频入口,避免把所有模块堆成超市。">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.length ? items.map((item) => (
|
||||
<Link key={item.key} to={item.href} className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm font-medium text-zinc-700 hover:bg-zinc-50">
|
||||
{item.title}
|
||||
</Link>
|
||||
)) : <EmptyStateCard title="暂无常用入口" description="工作区尚未配置推荐入口。" />}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryCardsPanel({ title, description, cards }: { title: string; description: string; cards: PortalHomePayload["cards"] }) {
|
||||
if (!cards.length) {
|
||||
return <EmptyStateCard title={title} description="暂无需要额外关注的统计项。" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPanel title={title} description={description}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{cards.map((card) => (
|
||||
<MetricCard
|
||||
key={card.key}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
href={card.href}
|
||||
note="进入详情页处理"
|
||||
tone={severityTone(card.severity)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
|
||||
function AdoptionSummaryPanel({ adoptionSummary }: { adoptionSummary: NonNullable<PortalHomePayload["adoptionSummary"]> | null }) {
|
||||
if (!adoptionSummary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<details className="card-std p-6" open>
|
||||
<summary className="cursor-pointer list-none">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">试点观测摘要</h3>
|
||||
<p className="mt-1 text-sm text-zinc-500">仅管理员默认展开,用于观察登录、周报和状态覆盖率。</p>
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-wide text-zinc-400">展开 / 收起</span>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-left">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-zinc-900">试点观测摘要</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">只给管理员默认展开,用于观察习惯是否已形成。</div>
|
||||
</div>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-zinc-400">展开 / 收起</span>
|
||||
</summary>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">近 7 天登录覆盖率</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.login7dRate}%</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.login7dRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">周报提交率</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.weeklySubmissionRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">团队状态覆盖率</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.presenceCoverageRate}%</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm">
|
||||
<div className="text-zinc-500">当前公告数</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{adoptionSummary.announcementCount}</div>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-zinc-900">{adoptionSummary.announcementCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm">
|
||||
<div className="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
|
||||
<div className="font-medium text-zinc-900">本周观测提示</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5 text-zinc-600">
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
{adoptionSummary.hints.map((hint) => (
|
||||
<li key={hint}>{hint}</li>
|
||||
))}
|
||||
@@ -238,23 +211,16 @@ export function PortalHomePage() {
|
||||
const message = isApiError(portalQuery.error)
|
||||
? `${portalQuery.error.code}: ${mapApiErrorMessage(portalQuery.error.code, portalQuery.error.details, portalQuery.error.message)}`
|
||||
: "Portal 加载失败";
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle="小团队统一入口与高频待办概览。" />
|
||||
<div className="card-std p-6 text-sm text-red-600">{message}</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="card-std p-6 text-sm text-red-600">{message}</div>;
|
||||
}
|
||||
|
||||
const payload = portalQuery.data;
|
||||
const primaryCardKeys = isManagerView
|
||||
? ["pending_approvals", "team_presence", "weekly_missing", "weekly_help"]
|
||||
: ["my_todos", "today_presence", "my_weekly_report", "my_projects"];
|
||||
const primaryCards = pickCards(payload.cards, primaryCardKeys);
|
||||
const portalSchema = isManagerView ? managerPortalSchema : employeePortalSchema;
|
||||
const primaryCards = pickCards(payload.cards, portalSchema.heroCards);
|
||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||
const secondaryCards = compactCards(payload.cards, primaryCardKeySet);
|
||||
|
||||
const employeeSummaryCandidates: Array<SummaryItem | null> = [
|
||||
const employeeSummaryItems = ([
|
||||
{
|
||||
key: "expense",
|
||||
href: "/reimbursements",
|
||||
@@ -283,10 +249,9 @@ export function PortalHomePage() {
|
||||
tone: payload.todayPresence.status === "leave" ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
const employeeSummaryItems = employeeSummaryCandidates.filter(isSummaryItem);
|
||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||
|
||||
const managerSummaryCandidates: Array<SummaryItem | null> = [
|
||||
const managerSummaryItems = ([
|
||||
{
|
||||
key: "team-summary",
|
||||
href: payload.teamPresence?.href ?? "/team",
|
||||
@@ -325,87 +290,69 @@ export function PortalHomePage() {
|
||||
tone: payload.peopleLifecycle.offboardingCount > 0 || payload.peopleLifecycle.assetReturnPendingCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
payload.riskSummary
|
||||
? {
|
||||
key: "risk-summary",
|
||||
href: payload.riskSummary.href,
|
||||
label: "待复核风险",
|
||||
value: `${payload.riskSummary.openCount} 项`,
|
||||
note: "仅展示脱敏摘要,详细处理仍在 OA 内完成。",
|
||||
tone: payload.riskSummary.openCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
const managerSummaryItems = managerSummaryCandidates.filter(isSummaryItem);
|
||||
|
||||
const headerSubtitle = isManagerView
|
||||
? `你好,${payload.greeting.name}。先看今天最重要的管理动作。`
|
||||
: `你好,${payload.greeting.name}。先处理今天要做的几件事。`;
|
||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Portal" subtitle={headerSubtitle} />
|
||||
<PageScaffold>
|
||||
<WorkbenchHero
|
||||
eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
|
||||
title={`你好,${payload.greeting.name}`}
|
||||
subtitle={isManagerView
|
||||
? "先处理审批、团队状态和周报缺口,再进入项目与组织协同。"
|
||||
: "先处理今天必须完成的事,再继续查看项目、周报和公告。"}
|
||||
actions={
|
||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
<Link to="/system/workspace">完成工作区设置</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
{!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<div className="card-std flex items-center justify-between gap-3 border-amber-200 bg-amber-50 p-4">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">工作区尚未初始化</div>
|
||||
<div className="text-sm text-zinc-600">建议先完成小团队模式的导航与模块设置,再邀请全员使用。</div>
|
||||
</div>
|
||||
<Button asChild type="button">
|
||||
<Link to="/system/workspace">去初始化</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
{primaryCards.map((card) => (
|
||||
<Link
|
||||
<MetricCard
|
||||
key={card.key}
|
||||
to={card.href}
|
||||
aria-label={`portal-card-${card.key}`}
|
||||
className={cardClasses(card.severity)}
|
||||
>
|
||||
<div className="text-sm text-zinc-500">{card.title}</div>
|
||||
<div className="mt-2 text-2xl font-bold text-zinc-900">{card.value}</div>
|
||||
</Link>
|
||||
ariaLabel={`portal-card-${card.key}`}
|
||||
href={card.href}
|
||||
note={card.key === "my_weekly_report"
|
||||
? formatWeeklyStatus(payload.weeklyReport)
|
||||
: card.key === "today_presence"
|
||||
? payload.todayPresence?.note ?? "更新今日状态"
|
||||
: card.key === "team_presence"
|
||||
? payload.teamPresence ? `请假 ${payload.teamPresence.leave},未更新 ${payload.teamPresence.unspecified}` : "团队状态板待启用"
|
||||
: undefined}
|
||||
title={card.title}
|
||||
tone={severityTone(card.severity)}
|
||||
value={card.value}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
|
||||
<SummaryPanel title={isManagerView ? "今日管理重点" : "我的申请与资产"} items={isManagerView ? managerSummaryItems : employeeSummaryItems} />
|
||||
<AnnouncementsPanel announcements={payload.announcements} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
<RecentVisitsPanel />
|
||||
<SecondaryCardsPanel title={isManagerView ? "其他提醒" : "更多事项"} cards={secondaryCards} />
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
{isManagerView ? (
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="把需要继续跟进的项目、人员与风险放到第二层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
) : (
|
||||
<RecentVisitsPanel />
|
||||
)}
|
||||
|
||||
{isManagerView ? (
|
||||
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
|
||||
) : (
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{payload.adoptionSummary ? <AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} /> : null}
|
||||
|
||||
{!isManagerView && payload.projectSummary ? (
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">项目与周报概览</h3>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Link to={payload.projectSummary.href} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">我的项目</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{payload.projectSummary.myCount}</div>
|
||||
</Link>
|
||||
<Link to={payload.projectSummary.href} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">今日待跟进</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{payload.projectSummary.followUpDueTodayCount}</div>
|
||||
</Link>
|
||||
<Link to={payload.weeklyReport?.href ?? "/weekly-reports"} className="rounded-xl border border-zinc-200 px-4 py-3 hover:bg-zinc-50">
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-500">本周周报</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatWeeklyStatus(payload.weeklyReport)}</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{isManagerView ? <QuickLinksPanel items={payload.quickLinks} /> : null}
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default PortalHomePage;
|
||||
|
||||
+136
-105
@@ -1,8 +1,10 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { fetchWecomConfig, startWecomBind, unbindWecom } from "../api/auth";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
resolveWecomBindNotice,
|
||||
@@ -27,6 +29,12 @@ function maskExternalUserId(value: string) {
|
||||
return `${value.slice(0, 4)}****${value.slice(-4)}`;
|
||||
}
|
||||
|
||||
function statusTone(status?: string) {
|
||||
if (status === "active") return "badge-success";
|
||||
if (status === "inactive") return "badge-warning";
|
||||
return "badge-neutral";
|
||||
}
|
||||
|
||||
export function ProfilePage() {
|
||||
const { me, permissions } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -35,12 +43,14 @@ export function ProfilePage() {
|
||||
queryFn: () => fetchWecomConfig(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: async () => startWecomBind("/settings/profile"),
|
||||
onSuccess: (payload) => {
|
||||
window.location.assign(payload.loginUrl);
|
||||
},
|
||||
});
|
||||
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: () => unbindWecom(),
|
||||
onSuccess: () => {
|
||||
@@ -53,115 +63,136 @@ export function ProfilePage() {
|
||||
const isBound = Boolean(me?.wecomBinding?.externalUserId);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="个人设置" subtitle="维护个人信息、权限摘要和企业微信绑定状态。" />
|
||||
<PageScaffold>
|
||||
<PageHeader
|
||||
title="个人设置"
|
||||
eyebrow="账号与入口"
|
||||
subtitle="维护个人资料、角色摘要与企业微信绑定状态。复杂业务仍在海音达工作台内完成,企业微信只负责进入与提醒。"
|
||||
meta={<span className={statusTone(me?.status)}>{me?.status === "active" ? "账号正常" : "账号状态待确认"}</span>}
|
||||
/>
|
||||
|
||||
<section className="card-std">
|
||||
<h3 className="card-title">个人信息</h3>
|
||||
<dl className="grid gap-3 text-sm text-zinc-700 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-secondary">姓名</dt>
|
||||
<dd className="font-medium text-zinc-900">{me?.name ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-secondary">员工号</dt>
|
||||
<dd className="font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-secondary">邮箱</dt>
|
||||
<dd className="font-medium text-zinc-900">{me?.email ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-secondary">手机号</dt>
|
||||
<dd className="font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-secondary">部门 ID</dt>
|
||||
<dd className="font-medium text-zinc-900">{me?.departmentId ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-secondary">直属上级 ID</dt>
|
||||
<dd className="font-medium text-zinc-900">{me?.directManagerId ?? "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="card-std mt-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="card-title">企业微信绑定</h3>
|
||||
<p className="text-sm text-zinc-500">绑定后可从企业微信工作台直接进入 OA;企业微信只做入口与提醒,不承接第二套业务录入。</p>
|
||||
</div>
|
||||
|
||||
{bindNotice ? (
|
||||
<div className={`rounded-xl border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
|
||||
<div className="font-medium">{bindNotice.title}</div>
|
||||
<div className="mt-1">{bindNotice.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-zinc-200 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">接入状态</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">{wecomStatus.description}</div>
|
||||
{wecomStatus.reasonLabels.length ? (
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-zinc-500">
|
||||
{wecomStatus.reasonLabels.map((label) => (
|
||||
<li key={label}>{label}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-zinc-200 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-zinc-500">当前绑定状态</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
|
||||
<div className="mt-1 text-sm text-zinc-600">
|
||||
{isBound
|
||||
? "已完成绑定,下次可从企业微信工作台直接进入 OA。"
|
||||
: "未绑定时,企业微信登录会回退到账号密码登录。"}
|
||||
</div>
|
||||
{isBound ? (
|
||||
<div className="mt-3 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-500">
|
||||
绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}<br />
|
||||
绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}
|
||||
<div className="page-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<SectionPanel title="基本信息" description="员工可直接理解的个人资料与联系方式。">
|
||||
<dl className="grid gap-4 text-sm text-zinc-700 md:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-zinc-500">姓名</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.name ?? "-"}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">员工号</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.employeeNo ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">邮箱</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.email ?? "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-zinc-500">手机号</dt>
|
||||
<dd className="mt-1 font-medium text-zinc-900">{me?.phone ?? "-"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="企业微信绑定" description="绑定后可从企业微信工作台直接进入海音达工作台;消息只做提醒与回流,不承接第二套业务录入。">
|
||||
<div className="space-y-4" data-testid="profile-wecom-card">
|
||||
{bindNotice ? (
|
||||
<div className={`rounded-2xl border px-4 py-3 text-sm ${noticeClasses(bindNotice.tone)}`} data-testid="profile-wecom-notice">
|
||||
<div className="font-medium">{bindNotice.title}</div>
|
||||
<div className="mt-1">{bindNotice.description}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="surface-muted px-4 py-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">接入状态</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{wecomStatus.label}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">{wecomStatus.description}</div>
|
||||
{wecomStatus.reasonLabels.length ? (
|
||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-zinc-500">
|
||||
{wecomStatus.reasonLabels.map((label) => (
|
||||
<li key={label}>{label}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="surface-muted px-4 py-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-400">当前绑定状态</div>
|
||||
<div className="mt-2 text-lg font-semibold text-zinc-900">{isBound ? "已绑定" : "未绑定"}</div>
|
||||
<div className="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{isBound ? "下次可直接从企业微信工作台进入海音达工作台。" : "未绑定时,企业微信登录会自动回退到账号密码登录。"}
|
||||
</div>
|
||||
{isBound ? (
|
||||
<div className="mt-3 rounded-xl border border-zinc-200 bg-white px-3 py-3 text-xs text-zinc-500">
|
||||
<div>绑定标识:{maskExternalUserId(me?.wecomBinding?.externalUserId ?? "")}</div>
|
||||
<div className="mt-1">绑定时间:{me?.wecomBinding?.createdAt ? new Date(me.wecomBinding.createdAt).toLocaleString() : "-"}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
|
||||
onClick={() => bindMutation.mutate()}
|
||||
>
|
||||
{isBound ? "重新绑定企业微信" : "立即绑定企业微信"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!isBound || unbindMutation.isPending}
|
||||
onClick={() => unbindMutation.mutate()}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!wecomConfigQuery.data?.bindEnabled || bindMutation.isPending}
|
||||
onClick={() => bindMutation.mutate()}
|
||||
>
|
||||
{isBound ? "重新绑定企业微信" : "立即绑定企业微信"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={!isBound || unbindMutation.isPending}
|
||||
onClick={() => unbindMutation.mutate()}
|
||||
>
|
||||
解绑
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="space-y-6">
|
||||
<SectionPanel title="组织信息" description="对员工展示可理解的说明,不直接暴露内部标识字段。">
|
||||
<div className="space-y-4 text-sm text-zinc-600">
|
||||
<div className="surface-muted px-4 py-4">
|
||||
<div className="font-medium text-zinc-900">组织归属</div>
|
||||
<div className="mt-2 leading-6">部门与直属汇报关系由管理员统一维护,当前个人页只展示员工可理解的设置入口,避免把内部 ID 直接暴露给员工。</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-zinc-200 px-4 py-4">
|
||||
<div className="text-zinc-500">部门维护状态</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{me?.departmentId ? "已关联组织" : "待管理员维护"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-zinc-200 px-4 py-4">
|
||||
<div className="text-zinc-500">直属汇报关系</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{me?.directManagerId ? "已维护" : "待管理员维护"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<section className="card-std mt-4">
|
||||
<h3 className="card-title">权限列表</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{permissions.map((permission) => (
|
||||
<span
|
||||
className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary"
|
||||
key={permission}
|
||||
>
|
||||
{permission}
|
||||
</span>
|
||||
))}
|
||||
<SectionPanel title="账号状态与角色" description="角色与权限保持透明,但不让设置页变成技术字段堆叠。">
|
||||
<div className="space-y-4 text-sm text-zinc-600">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={statusTone(me?.status)}>{me?.status === "active" ? "active" : me?.status ?? "unknown"}</span>
|
||||
{(me?.roleCodes ?? []).map((role) => (
|
||||
<span className="badge-neutral" key={role}>{role}</span>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-900">权限摘要</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{permissions.map((permission) => (
|
||||
<span className="badge-neutral" key={permission}>{permission}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
+384
-375
@@ -5,8 +5,12 @@ import { fetchEmployees } from "../api/employees";
|
||||
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
|
||||
import { fetchContractsRegistry } from "../api/contractsRegistry";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { isApiError } from "../api/types";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
@@ -486,10 +490,19 @@ export function ProjectDetailPage() {
|
||||
const riskItems = risksQuery.data?.items ?? detail.risks;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`${detail.projectNo} · ${detail.name}`}
|
||||
subtitle="项目主档、成员、里程碑、任务、风险、费用、周报与资料引用统一查看。"
|
||||
subtitle="项目概览、执行、留痕与归档在同一工作台内查看和处理。"
|
||||
meta={
|
||||
<>
|
||||
<StatusBadge status={detail.status} />
|
||||
{detail.isConfidential ? <span className="badge-warning">涉密项目</span> : null}
|
||||
<span className="info-chip">客户:{detail.clientAlias || detail.clientName}</span>
|
||||
<span className="info-chip">负责人:{detail.owner?.name ?? "-"}</span>
|
||||
<span className="info-chip">项目经理:{detail.manager?.name ?? "-"}</span>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="secondary" type="button">
|
||||
@@ -505,14 +518,14 @@ export function ProjectDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<div className="card-std p-4"><div className="text-sm text-zinc-500">成员</div><div className="mt-2 text-2xl font-bold">{counts?.members ?? 0}</div></div>
|
||||
<div className="card-std p-4"><div className="text-sm text-zinc-500">里程碑</div><div className="mt-2 text-2xl font-bold">{counts?.milestones ?? 0}</div></div>
|
||||
<div className="card-std p-4"><div className="text-sm text-zinc-500">任务</div><div className="mt-2 text-2xl font-bold">{counts?.tasks ?? 0}</div></div>
|
||||
<div className="card-std p-4"><div className="text-sm text-zinc-500">风险</div><div className="mt-2 text-2xl font-bold">{counts?.risks ?? 0}</div></div>
|
||||
<div className="card-std p-4"><div className="text-sm text-zinc-500">报销合计</div><div className="mt-2 text-2xl font-bold">{expensesQuery.data?.totalAmount ?? "0.00"}</div></div>
|
||||
<div className="card-std p-4"><div className="text-sm text-zinc-500">差旅合计</div><div className="mt-2 text-2xl font-bold">{travelQuery.data?.totalAmount ?? "0.00"}</div></div>
|
||||
</div>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<MetricCard title="成员" value={counts?.members ?? 0} note="执行角色与观察者" />
|
||||
<MetricCard title="里程碑" value={counts?.milestones ?? 0} note="用于跟踪关键节点" />
|
||||
<MetricCard title="任务" value={counts?.tasks ?? 0} note="按优先级与状态推进" />
|
||||
<MetricCard title="风险" value={counts?.risks ?? 0} note="高风险建议指定责任人" tone={(counts?.risks ?? 0) > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="报销合计" value={expensesQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联报销" />
|
||||
<MetricCard title="差旅合计" value={travelQuery.data?.totalAmount ?? "0.00"} note="当前项目已关联差旅" />
|
||||
</section>
|
||||
|
||||
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
|
||||
<div className="card-std border-amber-200 bg-amber-50 p-4" data-testid="project-closure-warning">
|
||||
@@ -655,379 +668,375 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-base font-semibold text-zinc-900">项目主档</h3>
|
||||
<StatusBadge status={detail.status} />
|
||||
{detail.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密项目</span> : null}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>项目名称</Label>
|
||||
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户名称</Label>
|
||||
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户简称</Label>
|
||||
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
|
||||
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>开始日期</Label>
|
||||
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目标完成</Label>
|
||||
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>实际完成</Label>
|
||||
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目经理</Label>
|
||||
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
|
||||
<option value="">未指定</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目摘要</Label>
|
||||
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
|
||||
项目包含涉密资料,正文与附件保持离线管理
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
|
||||
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6 text-sm">
|
||||
<h3 className="text-base font-semibold text-zinc-900">项目摘要</h3>
|
||||
<div>负责人:{detail.owner?.name ?? "-"}</div>
|
||||
<div>项目经理:{detail.manager?.name ?? "-"}</div>
|
||||
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
|
||||
<div>合同台账:{counts?.contracts ?? 0}</div>
|
||||
<div>交付记录:{counts?.deliveries ?? 0}</div>
|
||||
<div>资料引用:{counts?.documentRefs ?? 0}</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
|
||||
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">成员</h3>
|
||||
<div className="text-sm text-zinc-500">当前 {memberItems.length} 人</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
|
||||
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
|
||||
<option value="">选择成员</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
|
||||
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
|
||||
添加成员
|
||||
</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{memberItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
|
||||
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
|
||||
<TableCell>
|
||||
{item.leftAt ? "已移出" : (
|
||||
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">里程碑</h3>
|
||||
<div className="text-sm text-zinc-500">逾期请及时调整状态</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
|
||||
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
|
||||
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
|
||||
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
|
||||
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{milestoneItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell>{item.dueDate ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
|
||||
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">任务</h3><div className="text-sm text-zinc-500">支持高优先级与阻塞状态</div></div>
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
|
||||
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
|
||||
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
|
||||
<option value="">未指派</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
|
||||
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
|
||||
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{taskItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
|
||||
<option value="">未指派</option>
|
||||
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
|
||||
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
|
||||
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">风险</h3><div className="text-sm text-zinc-500">问题与阻塞统一留痕</div></div>
|
||||
<div className="space-y-3">
|
||||
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
|
||||
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
|
||||
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
|
||||
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
|
||||
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
|
||||
<option value="">未指定责任人</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
|
||||
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{riskItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.detail}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.level}</TableCell>
|
||||
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
|
||||
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">执行面板</h3><div className="text-sm text-zinc-500">人、事、费、周报统一查看</div></div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">报销</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||
{item.formNumber} · {item.totalAmount}
|
||||
</Link>
|
||||
))}
|
||||
<div className="detail-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<SectionPanel title="概览与主档" description="先看主信息,再统一维护时间、负责人和涉密属性。">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>项目名称</Label>
|
||||
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户名称</Label>
|
||||
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>客户简称</Label>
|
||||
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>状态</Label>
|
||||
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
|
||||
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>开始日期</Label>
|
||||
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>目标完成</Label>
|
||||
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>实际完成</Label>
|
||||
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目经理</Label>
|
||||
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
|
||||
<option value="">未指定</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>项目摘要</Label>
|
||||
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
|
||||
项目包含涉密资料,正文与附件保持离线管理
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
|
||||
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">差旅</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||
{item.requestNo} · {item.estimatedAmount}
|
||||
</Link>
|
||||
))}
|
||||
</SectionPanel>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionPanel title="执行 · 成员" description={`当前 ${memberItems.length} 人`}>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
|
||||
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
|
||||
<option value="">选择成员</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
|
||||
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
|
||||
添加成员
|
||||
</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{memberItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
|
||||
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
|
||||
<TableCell>
|
||||
{item.leftAt ? "已移出" : (
|
||||
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">项目周报</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
|
||||
</Link>
|
||||
))}
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="执行 · 里程碑" description="逾期请及时调整状态。">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
|
||||
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
|
||||
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
|
||||
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
|
||||
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{milestoneItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.title}</TableCell>
|
||||
<TableCell>{item.dueDate ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
|
||||
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">合同台账</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
|
||||
{item.contractNo} · {item.counterpartyName}
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionPanel title="执行 · 任务" description="支持高优先级与阻塞状态。">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
|
||||
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
|
||||
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
|
||||
<option value="">未指派</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
|
||||
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
|
||||
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{taskItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
|
||||
<option value="">未指派</option>
|
||||
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
|
||||
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
|
||||
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="执行 · 风险" description="问题与阻塞统一留痕。">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
|
||||
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
|
||||
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
|
||||
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
|
||||
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
|
||||
<option value="">未指定责任人</option>
|
||||
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||
</select>
|
||||
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
|
||||
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{riskItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.detail}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.level}</TableCell>
|
||||
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
|
||||
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<SectionPanel title="留痕 · 交付记录" description="现场、验收、交接统一留痕。">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
|
||||
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
|
||||
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
|
||||
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
|
||||
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
|
||||
</select>
|
||||
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
|
||||
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(deliveriesQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.summary}</div>
|
||||
</TableCell>
|
||||
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
|
||||
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
|
||||
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel title="归档与合规 · 涉密资料引用" description="只登记目录、链接和归档位置,正文与附件仍离线管理。">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
|
||||
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
|
||||
</div>
|
||||
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
|
||||
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
|
||||
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
|
||||
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
|
||||
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
|
||||
</select>
|
||||
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(docsQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
|
||||
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
|
||||
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<SectionPanel title="留痕 · 项目周报关联" description="按项目查看每周进展、风险与下一步动作。">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(weeklyQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.employee?.name ?? "-"}</TableCell>
|
||||
<TableCell>{item.weekStart ?? "-"}</TableCell>
|
||||
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
|
||||
<TableCell>{item.nextStepText || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<DetailSideMeta title="项目摘要" description="优先帮助项目负责人判断当前状态、关联量和下一步动作。">
|
||||
<div>负责人:{detail.owner?.name ?? "-"}</div>
|
||||
<div>项目经理:{detail.manager?.name ?? "-"}</div>
|
||||
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
|
||||
<div>合同台账:{counts?.contracts ?? 0}</div>
|
||||
<div>交付记录:{counts?.deliveries ?? 0}</div>
|
||||
<div>资料引用:{counts?.documentRefs ?? 0}</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
|
||||
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
|
||||
</div>
|
||||
</DetailSideMeta>
|
||||
|
||||
<SectionPanel title="执行摘要" description="人、事、费、周报统一查看,帮助快速切回具体页面。">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">报销</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||
{item.formNumber} · {item.totalAmount}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">差旅</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||
{item.requestNo} · {item.estimatedAmount}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">项目周报</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 p-4">
|
||||
<div className="text-sm text-zinc-500">合同台账</div>
|
||||
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
|
||||
{item.contractNo} · {item.counterpartyName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">交付记录</h3><div className="text-sm text-zinc-500">现场、验收、交接留痕</div></div>
|
||||
<div className="space-y-3">
|
||||
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
|
||||
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
|
||||
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
|
||||
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
|
||||
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
|
||||
</select>
|
||||
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
|
||||
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(deliveriesQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.summary}</div>
|
||||
</TableCell>
|
||||
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
|
||||
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
|
||||
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">涉密资料引用</h3><div className="text-sm text-zinc-500">只登记目录 / 链接 / 归档位置</div></div>
|
||||
<div className="space-y-3">
|
||||
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
|
||||
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
|
||||
</div>
|
||||
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
|
||||
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
|
||||
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
|
||||
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
|
||||
</select>
|
||||
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
|
||||
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
|
||||
</select>
|
||||
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(docsQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
|
||||
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
|
||||
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">项目周报关联</h3><div className="text-sm text-zinc-500">按项目查看每周进展</div></div>
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(weeklyQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.employee?.name ?? "-"}</TableCell>
|
||||
<TableCell>{item.weekStart ?? "-"}</TableCell>
|
||||
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
|
||||
<TableCell>{item.nextStepText || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { FilterBar, ListWorkspace } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -94,6 +97,22 @@ export function ProjectsPage() {
|
||||
[employeesQuery.data?.items, me?.id],
|
||||
);
|
||||
|
||||
const projectItems = listQuery.data?.items ?? [];
|
||||
const projectStats = useMemo(() => {
|
||||
return projectItems.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += 1;
|
||||
if (item.status === "active") {
|
||||
acc.active += 1;
|
||||
}
|
||||
acc.overdueMilestones += item.counts.overdueMilestones;
|
||||
acc.openRisks += item.counts.openRisks;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, active: 0, overdueMilestones: 0, openRisks: 0 },
|
||||
);
|
||||
}, [projectItems]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createProject({
|
||||
@@ -141,7 +160,7 @@ export function ProjectsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ListWorkspace>
|
||||
<PageHeader
|
||||
title="项目"
|
||||
subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。"
|
||||
@@ -154,9 +173,16 @@ export function ProjectsPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),360px]">
|
||||
<div className="space-y-4">
|
||||
<div className="card-std grid gap-3 p-4 md:grid-cols-[minmax(0,1fr),180px,140px]">
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="当前项目" value={projectStats.total} note={mine ? "已切换到我的项目" : "当前筛选结果"} />
|
||||
<MetricCard title="进行中" value={projectStats.active} note="优先跟进活跃项目" />
|
||||
<MetricCard title="逾期里程碑" value={projectStats.overdueMilestones} note="建议先处理延期事项" tone={projectStats.overdueMilestones > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="未关闭风险" value={projectStats.openRisks} note="问题留痕建议绑定责任人" tone={projectStats.openRisks > 0 ? "danger" : "neutral"} />
|
||||
</section>
|
||||
|
||||
<div className="page-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<FilterBar className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px,160px]">
|
||||
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
|
||||
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
|
||||
{STATUS_OPTIONS.map((item) => (
|
||||
@@ -166,48 +192,56 @@ export function ProjectsPage() {
|
||||
))}
|
||||
</select>
|
||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
|
||||
{mine ? "只看我的" : "查看全部"}
|
||||
{mine ? "只看我的" : "切换到我的"}
|
||||
</Button>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{listQuery.isLoading ? <div className="card-std p-6 text-sm text-zinc-500">项目加载中...</div> : null}
|
||||
{listQuery.isError ? <div className="card-std p-6 text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||
<SectionPanel
|
||||
title="项目列表"
|
||||
description={projectItems.length ? `当前筛出 ${projectItems.length} 个项目` : "按项目名、客户或状态快速缩小范围"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{listQuery.isLoading ? <div className="text-sm text-zinc-500">项目加载中...</div> : null}
|
||||
{listQuery.isError ? <div className="text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||
|
||||
<div className="grid gap-4">
|
||||
{(listQuery.data?.items ?? []).map((item) => (
|
||||
<Link key={item.id} to={`/projects/${item.id}`} className="card-std block space-y-3 p-5 hover:border-primary/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.projectNo}</div>
|
||||
<StatusBadge status={item.status} />
|
||||
{item.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密</span> : null}
|
||||
<div className="grid gap-4">
|
||||
{projectItems.map((item) => (
|
||||
<Link key={item.id} to={`/projects/${item.id}`} className="card-std block space-y-3 p-5 hover:border-primary/40">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-lg font-semibold text-zinc-900">{item.projectNo}</div>
|
||||
<StatusBadge status={item.status} />
|
||||
{item.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密</span> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-base text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">客户:{item.clientAlias || item.clientName}</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<div>负责人:{item.owner?.name ?? "-"}</div>
|
||||
<div>项目经理:{item.manager?.name ?? "-"}</div>
|
||||
<div>目标完成:{item.targetEndAt ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-base text-zinc-900">{item.name}</div>
|
||||
<div className="mt-1 text-sm text-zinc-500">客户:{item.clientAlias || item.clientName}</div>
|
||||
<div className="text-sm text-zinc-600">{item.summary || "暂无项目摘要"}</div>
|
||||
<div className="text-sm text-zinc-500">{summaryLine(item)}</div>
|
||||
</Link>
|
||||
))}
|
||||
{!listQuery.isLoading && !projectItems.length ? (
|
||||
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-sm text-zinc-500">
|
||||
当前没有符合筛选条件的项目。
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<div>负责人:{item.owner?.name ?? "-"}</div>
|
||||
<div>项目经理:{item.manager?.name ?? "-"}</div>
|
||||
<div>目标完成:{item.targetEndAt ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600">{item.summary || "暂无项目摘要"}</div>
|
||||
<div className="text-sm text-zinc-500">{summaryLine(item)}</div>
|
||||
</Link>
|
||||
))}
|
||||
{!listQuery.isLoading && !(listQuery.data?.items ?? []).length ? (
|
||||
<div className="card-std p-6 text-sm text-zinc-500">当前没有符合筛选条件的项目。</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建项目</h3>
|
||||
<SectionPanel title="新建项目" description="先建主档,再补成员、里程碑与风险。">
|
||||
{!canManageProjects ? (
|
||||
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>项目名称</Label>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
|
||||
@@ -256,11 +290,11 @@ export function ProjectsPage() {
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "创建项目"}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+142
-144
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -11,15 +11,21 @@ import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||||
}
|
||||
|
||||
export function ReimbursementsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { permissions, me } = useAuth();
|
||||
@@ -47,6 +53,20 @@ export function ReimbursementsPage() {
|
||||
queryFn: () => fetchMyDelegations("active"),
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const stats = useMemo(() => {
|
||||
return items.reduce(
|
||||
(acc, item) => {
|
||||
acc.currentPageCount += 1;
|
||||
acc.currentPageAmount += Number(item.totalAmount ?? 0);
|
||||
if (item.status === "reviewing") acc.reviewing += 1;
|
||||
if (item.status === "paid") acc.paid += 1;
|
||||
return acc;
|
||||
},
|
||||
{ currentPageCount: 0, currentPageAmount: 0, reviewing: 0, paid: 0 },
|
||||
);
|
||||
}, [items]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createReimbursement({
|
||||
@@ -87,7 +107,7 @@ export function ReimbursementsPage() {
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ListWorkspace>
|
||||
<PageHeader
|
||||
title="我的报销"
|
||||
subtitle="报销列表、详情与提交/撤回/支付动作入口。"
|
||||
@@ -167,86 +187,62 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="card-std">
|
||||
<p className="card-title">待我审批</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="card-value">12</h3>
|
||||
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
|
||||
2.4%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-std">
|
||||
<p className="card-title">已报销总额</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="card-value">¥124,500</h3>
|
||||
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
|
||||
5.1%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-std">
|
||||
<p className="card-title">异常警报</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="card-value">2</h3>
|
||||
<div className="flex items-center text-red-500 text-xs font-bold mb-1">
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_downward</span>
|
||||
1.2%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条记录`} />
|
||||
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="按当前页与筛选统计" />
|
||||
<MetricCard title="审批中" value={stats.reviewing} note="待继续推进的报销单" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="已支付" value={stats.paid} note={`当前范围:${scope === "all" ? "全范围" : "仅自己"}`} />
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-2">
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
||||
{[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "paid", label: "已支付" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
].map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`reimbursements-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
<FilterBar className="space-y-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
||||
{[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "paid", label: "已支付" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
].map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`reimbursements-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{canViewAll ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
variant={scope === "own" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{canViewAll ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
variant={scope === "own" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||
type="button"
|
||||
>
|
||||
仅自己
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-all"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
仅自己
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-all"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
||||
|
||||
@@ -263,71 +259,73 @@ export function ReimbursementsPage() {
|
||||
) : null}
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<div className="table-container mt-4">
|
||||
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th className="table-th">单号</th>
|
||||
<th className="table-th">状态</th>
|
||||
<th className="table-th">金额</th>
|
||||
<th className="table-th">报销人</th>
|
||||
<th className="table-th">日期</th>
|
||||
<th className="table-th w-24">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{(listQuery.data?.items ?? []).length ? (
|
||||
listQuery.data?.items.map((item) => (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
{item.formNumber && item.formNumber !== item.id && (
|
||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||
)}
|
||||
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</div> : null}
|
||||
</td>
|
||||
<td className="table-td"><StatusBadge status={item.status} /></td>
|
||||
<td className="table-td-strong">
|
||||
{item.totalAmount != null
|
||||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(item.totalAmount))
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
|
||||
{(item.owner?.name ?? "U").charAt(0)}
|
||||
<>
|
||||
<PrimaryTable>
|
||||
<table className="w-full text-left border-collapse" data-testid="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th className="table-th">单号</th>
|
||||
<th className="table-th">状态</th>
|
||||
<th className="table-th">金额</th>
|
||||
<th className="table-th">报销人</th>
|
||||
<th className="table-th">日期</th>
|
||||
<th className="table-th w-24">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
{item.formNumber && item.formNumber !== item.id && (
|
||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||
)}
|
||||
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</div> : null}
|
||||
</td>
|
||||
<td className="table-td"><StatusBadge status={item.status} /></td>
|
||||
<td className="table-td-strong">
|
||||
{item.totalAmount != null
|
||||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(item.totalAmount))
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="table-td">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
|
||||
{(item.owner?.name ?? "U").charAt(0)}
|
||||
</div>
|
||||
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||||
</div>
|
||||
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
||||
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
||||
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrimaryTable>
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
|
||||
testIdPrefix="reimbursements"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
|
||||
testIdPrefix="reimbursements"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
+124
-60
@@ -1,8 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
|
||||
import { fetchProjects } from "../api/projects";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
@@ -32,6 +35,20 @@ export function TodosPage() {
|
||||
queryFn: () => fetchProjects({}),
|
||||
});
|
||||
|
||||
const todoItems = todosQuery.data?.items ?? [];
|
||||
const todoStats = useMemo(() => {
|
||||
return todoItems.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += 1;
|
||||
if (item.status === "todo") acc.todo += 1;
|
||||
if (item.status === "doing") acc.doing += 1;
|
||||
if (item.status === "done") acc.done += 1;
|
||||
return acc;
|
||||
},
|
||||
{ total: 0, todo: 0, doing: 0, done: 0 },
|
||||
);
|
||||
}, [todoItems]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createTodo({ title, description: description || undefined, projectId: projectId || null }),
|
||||
onSuccess: async () => {
|
||||
@@ -72,69 +89,116 @@ export function TodosPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ListWorkspace>
|
||||
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。支持直接挂到项目。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
|
||||
<option value="">不关联项目</option>
|
||||
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.projectNo} · {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-2">
|
||||
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="todo">todo</option>
|
||||
<option value="doing">doing</option>
|
||||
<option value="done">done</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>新建待办</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="全部待办" value={todoStats.total} note="当前筛选结果" />
|
||||
<MetricCard title="待处理" value={todoStats.todo} note="建议优先清空新增事项" tone={todoStats.todo > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="进行中" value={todoStats.doing} note="重点跟进阻塞项" />
|
||||
<MetricCard title="已完成" value={todoStats.done} note="已完成项会持续留痕" />
|
||||
</section>
|
||||
|
||||
<div className="card-std p-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>项目</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(todosQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
|
||||
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
||||
<TableCell>{item.priority}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>完成</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>取消</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="page-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<FilterBar className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-zinc-500">按状态筛选后,列表只保留当前关注的工作项。</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select className="select-std w-[180px]" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="todo">todo</option>
|
||||
<option value="doing">doing</option>
|
||||
<option value="done">done</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
<Button type="button" variant="secondary" onClick={() => queryClient.invalidateQueries({ queryKey: ["todos"] })}>
|
||||
刷新列表
|
||||
</Button>
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
<SectionPanel title="待办列表" description={todoItems.length ? `当前共 ${todoItems.length} 条待办` : "暂无待办,建议从当前周计划或项目动作拆出任务。"}>
|
||||
{todosQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(todosQuery.error, "加载待办失败")}</div> : null}
|
||||
<PrimaryTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标题</TableHead>
|
||||
<TableHead>项目</TableHead>
|
||||
<TableHead>负责人</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{todosQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
待办加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : todoItems.length ? (
|
||||
todoItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
|
||||
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
||||
<TableCell>{item.priority}</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>
|
||||
完成
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
当前没有符合条件的待办。
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PrimaryTable>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
|
||||
<SectionPanel title="新增待办" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
|
||||
<option value="">不关联项目</option>
|
||||
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.projectNo} · {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
|
||||
{createMutation.isPending ? "新建中..." : "新建待办"}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createTodo } from "../api/todos";
|
||||
import { fetchProjects } from "../api/projects";
|
||||
@@ -6,6 +6,9 @@ import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, upda
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
@@ -73,6 +76,11 @@ export function WeeklyReportsPage() {
|
||||
nextStepText: item.nextStepText.trim() || undefined,
|
||||
}));
|
||||
|
||||
const linkedProjectCount = useMemo(
|
||||
() => projectLinks.filter((item) => item.projectId).length,
|
||||
[projectLinks],
|
||||
);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft, projectLinks: normalizedProjectLinks }),
|
||||
onSuccess: async () => {
|
||||
@@ -119,40 +127,54 @@ export function WeeklyReportsPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ListWorkspace>
|
||||
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划,可按项目拆分进展。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-zinc-500">选择周起始日(默认自动按周一归档)</div>
|
||||
<input className="input-std" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
|
||||
<FilterBar className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium text-zinc-900">按周归档</div>
|
||||
<div className="text-sm text-zinc-500">默认按周一归档;提交后会同步更新 Portal 与项目摘要。</div>
|
||||
</div>
|
||||
<input className="input-std w-[220px]" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
|
||||
</FilterBar>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">本周完成</label>
|
||||
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">当前阻塞</label>
|
||||
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">下周计划</label>
|
||||
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
|
||||
需要老板/管理员协助
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="当前状态" value={report?.status === "submitted" ? "已提交" : "待完善"} note={report?.submittedAt ? `提交于 ${new Date(report.submittedAt).toLocaleString()}` : "保存后可继续补充"} tone={report?.status === "submitted" ? "neutral" : "warning"} />
|
||||
<MetricCard title="关联项目" value={linkedProjectCount} note={`共 ${projectsQuery.data?.items.length ?? 0} 个我的项目可选`} />
|
||||
<MetricCard title="需协助" value={draft.needsHelp ? "是" : "否"} note="勾选后会在管理首页高亮" tone={draft.needsHelp ? "warning" : "neutral"} />
|
||||
<MetricCard title="团队缺口" value={canViewTeam ? (teamQuery.data?.summary.missingCount ?? 0) : "-"} note={canViewTeam ? `待协助 ${teamQuery.data?.summary.needsHelpCount ?? 0}` : "仅管理员可见"} tone={canViewTeam && (teamQuery.data?.summary.missingCount ?? 0) > 0 ? "warning" : "neutral"} />
|
||||
</section>
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">项目关联</h3>
|
||||
<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => [...prev, emptyProjectLink()])}>新增项目进展</Button>
|
||||
<SectionPanel title="本周周报" description="先写本周完成,再补当前阻塞和下周计划。">
|
||||
<div className="space-y-4">
|
||||
{reportQuery.isLoading ? <div className="text-sm text-zinc-500">周报加载中...</div> : null}
|
||||
{reportQuery.isError ? <div className="text-sm text-red-600">{errorMessage(reportQuery.error, "加载周报失败")}</div> : null}
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">本周完成</label>
|
||||
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">当前阻塞</label>
|
||||
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">下周计划</label>
|
||||
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
|
||||
需要老板/管理员协助
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<SectionPanel
|
||||
title="项目关联"
|
||||
description="项目进展、风险与下一步动作建议按项目拆开写。"
|
||||
actions={<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => [...prev, emptyProjectLink()])}>新增项目进展</Button>}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{projectLinks.map((item, index) => (
|
||||
<div key={`${item.projectId}-${index}`} className="rounded-lg border border-zinc-200 p-4 space-y-3">
|
||||
@@ -170,47 +192,58 @@ export function WeeklyReportsPage() {
|
||||
<Textarea placeholder="下一步动作" value={item.nextStepText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, nextStepText: event.target.value } : entry))} />
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
{canViewTeam ? (
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">团队汇总</h3>
|
||||
<div className="text-sm text-zinc-500">
|
||||
未提交 {teamQuery.data?.summary.missingCount ?? 0} · 需协助 {teamQuery.data?.summary.needsHelpCount ?? 0}
|
||||
</div>
|
||||
<SectionPanel title="团队汇总" description="帮助老板和管理员先看到未提交与需协助人员。">
|
||||
<div className="mb-4 grid gap-3 md:grid-cols-4">
|
||||
<MetricCard title="团队人数" value={teamQuery.data?.summary.totalEmployees ?? 0} />
|
||||
<MetricCard title="已提交" value={teamQuery.data?.summary.submittedCount ?? 0} />
|
||||
<MetricCard title="未提交" value={teamQuery.data?.summary.missingCount ?? 0} tone={(teamQuery.data?.summary.missingCount ?? 0) > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="需协助" value={teamQuery.data?.summary.needsHelpCount ?? 0} tone={(teamQuery.data?.summary.needsHelpCount ?? 0) > 0 ? "warning" : "neutral"} />
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>需协助</TableHead>
|
||||
<TableHead>关联项目</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(teamQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>{item.employee.name}</TableCell>
|
||||
<TableCell>{item.report?.status ?? "missing"}</TableCell>
|
||||
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
|
||||
<TableCell>{item.report?.projectLinks?.map((link) => link.project?.name ?? link.projectId).join(" / ") || "-"}</TableCell>
|
||||
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
||||
{teamQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(teamQuery.error, "加载团队周报失败")}</div> : null}
|
||||
<PrimaryTable>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>需协助</TableHead>
|
||||
<TableHead>关联项目</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teamQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={5}>
|
||||
团队周报加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
(teamQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>{item.employee.name}</TableCell>
|
||||
<TableCell>{item.report?.status ?? "missing"}</TableCell>
|
||||
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
|
||||
<TableCell>{item.report?.projectLinks?.map((link) => link.project?.name ?? link.projectId).join(" / ") || "-"}</TableCell>
|
||||
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</PrimaryTable>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
</div>
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户