R32-1 R32-4 R33-2: 收口工作台交互与报销链路
这个提交包含在:
@@ -81,6 +81,16 @@ export type ReimbursementBudgetHint = {
|
||||
exceedsBudget: boolean;
|
||||
};
|
||||
|
||||
export type ReimbursementWorkflowProgress = {
|
||||
currentStepIndex: number;
|
||||
totalSteps: number;
|
||||
steps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: "completed" | "current" | "pending";
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ReimbursementDetail = {
|
||||
id: string;
|
||||
formNumber: string | null;
|
||||
@@ -125,6 +135,7 @@ export type ReimbursementDetail = {
|
||||
assignee_role: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
workflowProgress: ReimbursementWorkflowProgress | null;
|
||||
riskFlags: RiskFlag[];
|
||||
items: ReimbursementItem[];
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type WorkflowTaskSummary = {
|
||||
entityId: string;
|
||||
currentStatus: string;
|
||||
} | null;
|
||||
allowedActions: Array<"approve" | "reject" | "return" | "transfer">;
|
||||
};
|
||||
|
||||
export type WorkflowTaskListResponse = {
|
||||
|
||||
@@ -8,6 +8,15 @@ import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
|
||||
type SearchGroupKey = "people" | "documents" | "projectAnnouncement" | "other";
|
||||
|
||||
const SEARCH_GROUPS: Array<{ key: SearchGroupKey; label: string }> = [
|
||||
{ key: "people", label: "人员" },
|
||||
{ key: "documents", label: "业务单据" },
|
||||
{ key: "projectAnnouncement", label: "项目与公告" },
|
||||
{ key: "other", label: "其他" },
|
||||
];
|
||||
|
||||
function entityLabel(entityType: string) {
|
||||
if (entityType === "employee") return "人员";
|
||||
if (entityType === "announcement") return "公告";
|
||||
@@ -24,6 +33,29 @@ function entityLabel(entityType: string) {
|
||||
return entityType;
|
||||
}
|
||||
|
||||
function resolveSearchGroup(entityType: string): SearchGroupKey {
|
||||
if (entityType === "employee") {
|
||||
return "people";
|
||||
}
|
||||
|
||||
if ([
|
||||
"reimbursement",
|
||||
"leave",
|
||||
"purchase_request",
|
||||
"travel_request",
|
||||
"weekly_report",
|
||||
"todo",
|
||||
].includes(entityType)) {
|
||||
return "documents";
|
||||
}
|
||||
|
||||
if (["project", "announcement"].includes(entityType)) {
|
||||
return "projectAnnouncement";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -31,19 +63,33 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
const searchQuery = useQuery({
|
||||
enabled: open && trimmed.length >= 2,
|
||||
queryKey: ["global-search", trimmed],
|
||||
queryFn: () => searchGlobal(trimmed, 10),
|
||||
queryFn: () => searchGlobal(trimmed, 20),
|
||||
});
|
||||
|
||||
const groupedItems = useMemo(() => {
|
||||
const items = searchQuery.data?.items ?? [];
|
||||
return SEARCH_GROUPS.map((group) => {
|
||||
const matches = items.filter((item) => resolveSearchGroup(item.entityType) === group.key);
|
||||
return {
|
||||
...group,
|
||||
total: matches.length,
|
||||
items: matches.slice(0, 3),
|
||||
};
|
||||
}).filter((group) => group.total > 0);
|
||||
}, [searchQuery.data?.items]);
|
||||
|
||||
const message = useMemo(() => {
|
||||
if (!trimmed) return "输入姓名、公告、项目、合同、资产、请假、报销、周报或待办关键字开始搜索。";
|
||||
if (trimmed.length < 2) return "至少输入 2 个字符。";
|
||||
if (searchQuery.isLoading) return "搜索中...";
|
||||
if (searchQuery.isError) {
|
||||
return isApiError(searchQuery.error)
|
||||
? `${searchQuery.error.code}: ${mapApiErrorMessage(searchQuery.error.code, searchQuery.error.details, searchQuery.error.message)}`
|
||||
: "搜索失败,请稍后重试";
|
||||
}
|
||||
if (!groupedItems.length) return "未找到匹配结果。";
|
||||
return null;
|
||||
}, [trimmed, searchQuery.error, searchQuery.isError]);
|
||||
}, [groupedItems.length, searchQuery.error, searchQuery.isError, searchQuery.isLoading, trimmed]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -61,24 +107,39 @@ export function GlobalSearchDialog({ open, onOpenChange }: { open: boolean; onOp
|
||||
|
||||
{message ? <div className="text-sm text-zinc-500">{message}</div> : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
{(searchQuery.data?.items ?? []).map((item) => (
|
||||
<button
|
||||
key={`${item.entityType}-${item.entityId}`}
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate(item.href);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{groupedItems.map((group) => (
|
||||
<section key={group.key} className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs uppercase text-zinc-500">{item.matchedBy}</div>
|
||||
<h3 className="text-sm font-semibold text-zinc-900">{group.label}</h3>
|
||||
<span className="text-xs text-zinc-400">{group.total} 条</span>
|
||||
</div>
|
||||
{item.subtitle ? <div className="mt-1 text-sm text-zinc-600">{item.subtitle}</div> : null}
|
||||
<div className="mt-1 text-xs text-zinc-500">{entityLabel(item.entityType)} · {item.status ?? "-"}</div>
|
||||
</button>
|
||||
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => (
|
||||
<button
|
||||
key={`${item.entityType}-${item.entityId}`}
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-zinc-200 px-3 py-3 text-left hover:bg-zinc-50"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
navigate(item.href);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs uppercase text-zinc-500">{item.matchedBy}</div>
|
||||
</div>
|
||||
{item.subtitle ? <div className="mt-1 text-sm text-zinc-600">{item.subtitle}</div> : null}
|
||||
<div className="mt-1 text-xs text-zinc-500">{entityLabel(item.entityType)} · {item.status ?? "-"}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{group.total > group.items.length ? (
|
||||
<div className="text-xs text-zinc-400">查看更多结果</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchNotifications } from "../../api/notifications";
|
||||
@@ -58,6 +58,36 @@ export function Topbar() {
|
||||
const unreadCount = unreadQuery.data?.unreadCount ?? 0;
|
||||
const unreadBadge = unreadCount > 99 ? "99+" : String(unreadCount);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key.toLowerCase() !== "k") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.matchMedia("(min-width: 768px)").matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (target instanceof HTMLElement) {
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
if (target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setSearchOpen(true);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TEST_IDS = {
|
||||
tasksTable: "workflow-tasks-table",
|
||||
taskRow: "workflow-task-row",
|
||||
detailLink: "workflow-task-detail-link",
|
||||
quickApprove: "workflow-task-quick-approve",
|
||||
summary: "workflow-task-summary",
|
||||
approve: "workflow-task-approve",
|
||||
reject: "workflow-task-reject",
|
||||
@@ -33,6 +34,7 @@ export const TEST_IDS = {
|
||||
budgetHint: "reimbursement-budget-hint",
|
||||
erpExport: "reimbursement-erp-export",
|
||||
overview: "reimbursement-overview",
|
||||
workflowProgress: "reimbursement-workflow-progress",
|
||||
},
|
||||
weeklyReport: {
|
||||
save: "weekly-report-save",
|
||||
|
||||
@@ -270,26 +270,6 @@ export function PortalHomePage() {
|
||||
tone: payload.weeklySummary.missingCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
payload.projectSummary
|
||||
? {
|
||||
key: "project-summary",
|
||||
href: payload.projectSummary.href,
|
||||
label: "待跟进 / 待收口项目",
|
||||
value: `${payload.projectSummary.followUpDueTodayCount} / ${payload.projectSummary.closurePendingProjectCount}`,
|
||||
note: `逾期里程碑 ${payload.projectSummary.overdueMilestonesCount},风险 ${payload.projectSummary.openRiskCount}`,
|
||||
tone: payload.projectSummary.openRiskCount > 0 || payload.projectSummary.closurePendingProjectCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
payload.peopleLifecycle
|
||||
? {
|
||||
key: "people-lifecycle",
|
||||
href: payload.peopleLifecycle.href,
|
||||
label: "入离职 / 资产待归还",
|
||||
value: `${payload.peopleLifecycle.onboardingCount + payload.peopleLifecycle.offboardingCount} / ${payload.peopleLifecycle.assetReturnPendingCount}`,
|
||||
note: "用于跟进入职、离职和资产交接闭环。",
|
||||
tone: payload.peopleLifecycle.offboardingCount > 0 || payload.peopleLifecycle.assetReturnPendingCount > 0 ? "warning" as const : "normal" as const,
|
||||
}
|
||||
: null,
|
||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||
|
||||
return (
|
||||
@@ -329,30 +309,37 @@ export function PortalHomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{isManagerView ? (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
|
||||
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
{isManagerView ? (
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="把需要继续跟进的项目、人员与风险放到第二层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
) : (
|
||||
<RecentVisitsPanel />
|
||||
)}
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
|
||||
<AnnouncementsPanel announcements={payload.announcements} />
|
||||
<SecondaryCardsPanel
|
||||
title="项目跟进与组织协同"
|
||||
description="把项目、组织和风险类信息下移到第二阅读层。"
|
||||
cards={secondaryCards}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isManagerView ? (
|
||||
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
|
||||
) : (
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
|
||||
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
|
||||
<AnnouncementsPanel announcements={payload.announcements} />
|
||||
</div>
|
||||
|
||||
{isManagerView ? <QuickLinksPanel items={payload.quickLinks} /> : null}
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||
<RecentVisitsPanel />
|
||||
<QuickLinksPanel items={payload.quickLinks} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
ReimbursementItemEditor,
|
||||
@@ -697,6 +698,37 @@ export function ReimbursementDetailPage() {
|
||||
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
||||
</DetailSideMeta>
|
||||
|
||||
{detail.status === "reviewing" && detail.workflowProgress ? (
|
||||
<DetailSideMeta
|
||||
title="审批进度"
|
||||
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
|
||||
>
|
||||
<div className="space-y-3" data-testid={TEST_IDS.reimbursement.workflowProgress}>
|
||||
{detail.workflowProgress.steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={
|
||||
step.status === "current"
|
||||
? "rounded-lg border border-primary-200 bg-primary-50 px-3 py-2"
|
||||
: step.status === "completed"
|
||||
? "rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2"
|
||||
: "rounded-lg border border-zinc-200 bg-white px-3 py-2"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-zinc-900">
|
||||
第 {index + 1} 步 · {step.name}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailSideMeta>
|
||||
) : null}
|
||||
|
||||
<ReimbursementActions
|
||||
status={detail.status}
|
||||
canPay={canPay}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchWorkflowTasks } from "../api/workflow";
|
||||
import { approveWorkflowTask, fetchWorkflowTasks } from "../api/workflow";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -10,6 +11,7 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -31,6 +33,7 @@ function entityLabel(entityType: string | undefined) {
|
||||
|
||||
export function WorkflowTasksPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status =
|
||||
(searchParams.get("status") as
|
||||
@@ -47,6 +50,26 @@ export function WorkflowTasksPage() {
|
||||
queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
|
||||
]);
|
||||
toast.success("审批已通过");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isApiError(error)
|
||||
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "审批失败,请稍后重试";
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
@@ -123,14 +146,27 @@ export function WorkflowTasksPage() {
|
||||
<TableCell><StatusBadge status={task.status} /></TableCell>
|
||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
data-testid="workflow-task-detail-link"
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{status === "pending" && task.allowedActions.includes("approve") ? (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
data-testid={TEST_IDS.workflow.quickApprove}
|
||||
disabled={approveMutation.isPending && approveMutation.variables === task.id}
|
||||
onClick={() => approveMutation.mutate(task.id)}
|
||||
>
|
||||
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
data-testid={TEST_IDS.workflow.detailLink}
|
||||
>
|
||||
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
在新工单中引用
屏蔽一个用户