PC6 完善 PC 工作台与审批信息架构
这个提交包含在:
@@ -71,6 +71,20 @@ export type PortalHomePayload = {
|
|||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
peopleLifecycle: { onboardingCount: number; offboardingCount: number; assetReturnPendingCount: number; href: string } | null;
|
peopleLifecycle: { onboardingCount: number; offboardingCount: number; assetReturnPendingCount: number; href: string } | null;
|
||||||
|
roleCapabilities?: {
|
||||||
|
canSubmitApplications: boolean;
|
||||||
|
canApproveTasks: boolean;
|
||||||
|
canUseFinance: boolean;
|
||||||
|
canManageSystem: boolean;
|
||||||
|
canViewTeam: boolean;
|
||||||
|
isAdminOnly: boolean;
|
||||||
|
};
|
||||||
|
financeSummary?: {
|
||||||
|
pendingBatchCount: number;
|
||||||
|
failedItemCount: number;
|
||||||
|
riskCount: number;
|
||||||
|
href: string;
|
||||||
|
} | null;
|
||||||
adoptionSummary:
|
adoptionSummary:
|
||||||
| {
|
| {
|
||||||
activeEmployeeCount: number;
|
activeEmployeeCount: number;
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ export type WorkflowApproverListResponse = {
|
|||||||
|
|
||||||
export type WorkflowTaskListQuery = {
|
export type WorkflowTaskListQuery = {
|
||||||
status?: "pending" | "completed" | "rejected" | "cancelled" | "skipped";
|
status?: "pending" | "completed" | "rejected" | "cancelled" | "skipped";
|
||||||
|
entityType?: "reimbursement" | "leave" | "purchase_request" | "travel_request";
|
||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ export function ApOperationsPage() {
|
|||||||
}),
|
}),
|
||||||
onSuccess: (response, format) => {
|
onSuccess: (response, format) => {
|
||||||
downloadBlob(response.blob, response.filename ?? `ap-operations.${format}`);
|
downloadBlob(response.blob, response.filename ?? `ap-operations.${format}`);
|
||||||
toast.success(`AP 运营数据已导出为 ${format.toUpperCase()}`);
|
toast.success(`财务处理数据已导出为 ${format.toUpperCase()}`);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(resolveErrorMessage(error, "导出失败"));
|
toast.error(resolveErrorMessage(error, "导出失败"));
|
||||||
@@ -533,14 +533,14 @@ export function ApOperationsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading) {
|
if (summaryQuery.isLoading && paymentBatchesQuery.isLoading && riskFlagsQuery.isLoading) {
|
||||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 AP 运营工作台...</div>;
|
return <div className="card-std p-6 text-sm text-zinc-500">加载财务工作台...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="AP 运营"
|
title="财务付款与对账"
|
||||||
subtitle="将待付款批次、异常付款、风险标记和对账异常收口在同一工作台内处理。"
|
subtitle="集中处理待付款批次、异常付款、风险标记和对账异常。"
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ type GettingStartedChecklistItem = {
|
|||||||
|
|
||||||
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
|
const LEAVE_PORTAL_PERMISSIONS = ["leave.view_own", "leave.view_all", "leave.create", "leave.submit"];
|
||||||
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
|
const EXPENSE_PORTAL_PERMISSIONS = ["expense.view_own", "expense.view_all", "expense.create", "expense.proxy_manage"];
|
||||||
|
const PROCUREMENT_PORTAL_PERMISSIONS = ["procurement.view_own", "procurement.view_all", "procurement.create", "procurement.submit"];
|
||||||
|
const TRAVEL_PORTAL_PERMISSIONS = ["travel.view_own", "travel.view_all", "travel.create", "travel.submit"];
|
||||||
|
const APPROVAL_PORTAL_PERMISSIONS = ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"];
|
||||||
|
const FINANCE_PORTAL_PERMISSIONS = ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"];
|
||||||
|
const SYSTEM_PORTAL_PERMISSIONS = ["system.users", "system.settings", "system.audit.view", "notification.channel.manage"];
|
||||||
const TODO_SEVERITY_RANK = {
|
const TODO_SEVERITY_RANK = {
|
||||||
high: 3,
|
high: 3,
|
||||||
warning: 2,
|
warning: 2,
|
||||||
@@ -90,6 +95,28 @@ function severityTone(severity?: "normal" | "warning" | "high") {
|
|||||||
return "neutral" as const;
|
return "neutral" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFallbackRoleCapabilities(permissions: string[], roleCodes: string[]) {
|
||||||
|
const hasAdminRole = roleCodes.includes("admin") || hasAnyPermission(permissions, ["*"]);
|
||||||
|
const hasBusinessApproverRole = roleCodes.some((roleCode) =>
|
||||||
|
["manager", "ceo", "finance", "finance_mgr", "hr"].includes(roleCode),
|
||||||
|
);
|
||||||
|
const hasFinanceRole = roleCodes.some((roleCode) => ["finance", "finance_mgr"].includes(roleCode));
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSubmitApplications: hasAnyPermission(permissions, [
|
||||||
|
...LEAVE_PORTAL_PERMISSIONS,
|
||||||
|
...EXPENSE_PORTAL_PERMISSIONS,
|
||||||
|
...PROCUREMENT_PORTAL_PERMISSIONS,
|
||||||
|
...TRAVEL_PORTAL_PERMISSIONS,
|
||||||
|
]),
|
||||||
|
canApproveTasks: hasBusinessApproverRole && hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
|
||||||
|
canUseFinance: hasFinanceRole && hasAnyPermission(permissions, FINANCE_PORTAL_PERMISSIONS),
|
||||||
|
canManageSystem: hasAdminRole || hasAnyPermission(permissions, SYSTEM_PORTAL_PERMISSIONS),
|
||||||
|
canViewTeam: hasBusinessApproverRole || hasAnyPermission(permissions, ["report.dashboard.view", "system.users", "project.manage"]),
|
||||||
|
isAdminOnly: hasAdminRole && !hasBusinessApproverRole,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pickCards(cards: PortalHomePayload["cards"], keys: string[]) {
|
function pickCards(cards: PortalHomePayload["cards"], keys: string[]) {
|
||||||
const map = new Map(cards.map((card) => [card.key, card]));
|
const map = new Map(cards.map((card) => [card.key, card]));
|
||||||
return keys.map((key) => map.get(key)).filter((card): card is PortalHomePayload["cards"][number] => Boolean(card));
|
return keys.map((key) => map.get(key)).filter((card): card is PortalHomePayload["cards"][number] => Boolean(card));
|
||||||
@@ -376,6 +403,38 @@ function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FinanceSummaryPanel({ summary }: { summary: PortalHomePayload["financeSummary"] }) {
|
||||||
|
if (!summary) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: SummaryItem[] = [
|
||||||
|
{
|
||||||
|
key: "finance-pending-batches",
|
||||||
|
href: summary.href,
|
||||||
|
label: "待付款批次",
|
||||||
|
value: `${summary.pendingBatchCount} 个`,
|
||||||
|
tone: summary.pendingBatchCount > 0 ? "warning" : "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "finance-failed-items",
|
||||||
|
href: summary.href,
|
||||||
|
label: "异常付款",
|
||||||
|
value: `${summary.failedItemCount} 项`,
|
||||||
|
tone: summary.failedItemCount > 0 ? "warning" : "normal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "finance-risk-flags",
|
||||||
|
href: summary.href,
|
||||||
|
label: "待复核风险",
|
||||||
|
value: `${summary.riskCount} 项`,
|
||||||
|
tone: summary.riskCount > 0 ? "warning" : "normal",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <SummaryPanel title="财务待处理" items={items} />;
|
||||||
|
}
|
||||||
|
|
||||||
function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) {
|
||||||
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
|
const safeAnnouncements = Array.isArray(announcements) ? announcements : [];
|
||||||
|
|
||||||
@@ -817,7 +876,6 @@ export function PortalHomePage() {
|
|||||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||||
});
|
});
|
||||||
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
|
||||||
const isManagerView = isManagerRole(permissions);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowWelcome(shouldShowWelcomeDialog());
|
setShowWelcome(shouldShowWelcomeDialog());
|
||||||
@@ -910,6 +968,20 @@ export function PortalHomePage() {
|
|||||||
const quickLinkItems = Array.isArray(payload.quickLinks) ? payload.quickLinks : [];
|
const quickLinkItems = Array.isArray(payload.quickLinks) ? payload.quickLinks : [];
|
||||||
const recentActivities = Array.isArray(payload.recentActivities) ? payload.recentActivities : [];
|
const recentActivities = Array.isArray(payload.recentActivities) ? payload.recentActivities : [];
|
||||||
const teamStatusBoard = Array.isArray(payload.teamStatusBoard) ? payload.teamStatusBoard : [];
|
const teamStatusBoard = Array.isArray(payload.teamStatusBoard) ? payload.teamStatusBoard : [];
|
||||||
|
const roleCapabilities = payload.roleCapabilities ?? buildFallbackRoleCapabilities(permissions, me?.roleCodes ?? []);
|
||||||
|
const isManagerView =
|
||||||
|
roleCapabilities.canViewTeam ||
|
||||||
|
roleCapabilities.canApproveTasks ||
|
||||||
|
roleCapabilities.canUseFinance ||
|
||||||
|
roleCapabilities.canManageSystem ||
|
||||||
|
isManagerRole(permissions);
|
||||||
|
const workspaceEyebrow = roleCapabilities.canUseFinance
|
||||||
|
? "财务工作台"
|
||||||
|
: roleCapabilities.canManageSystem
|
||||||
|
? "管理工作台"
|
||||||
|
: roleCapabilities.canApproveTasks
|
||||||
|
? "审批工作台"
|
||||||
|
: "个人工作台";
|
||||||
const portalSchema = isManagerView ? managerPortalSchema : employeePortalSchema;
|
const portalSchema = isManagerView ? managerPortalSchema : employeePortalSchema;
|
||||||
const primaryCards = pickCards(cards, portalSchema.heroCards);
|
const primaryCards = pickCards(cards, portalSchema.heroCards);
|
||||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||||
@@ -986,8 +1058,87 @@ export function PortalHomePage() {
|
|||||||
: null,
|
: null,
|
||||||
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||||
|
|
||||||
|
const approvalSummaryItems = ([
|
||||||
|
roleCapabilities.canApproveTasks
|
||||||
|
? {
|
||||||
|
key: "approval-pending",
|
||||||
|
href: payload.pendingTasks.href,
|
||||||
|
label: "审批待办",
|
||||||
|
value: `${payload.pendingTasks.count} 条`,
|
||||||
|
note: payload.pendingTasks.count > 0 ? "进入审批中心处理。" : "暂无待处理审批。",
|
||||||
|
tone: payload.pendingTasks.count > 0 ? "warning" as const : "normal" as const,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
roleCapabilities.canApproveTasks
|
||||||
|
? {
|
||||||
|
key: "approval-completed",
|
||||||
|
href: "/workflow/tasks?status=completed",
|
||||||
|
label: "已处理",
|
||||||
|
value: "查看",
|
||||||
|
note: "复核历史处理记录。",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||||
|
|
||||||
|
const systemSummaryItems = ([
|
||||||
|
roleCapabilities.canManageSystem
|
||||||
|
? {
|
||||||
|
key: "system-users",
|
||||||
|
href: "/people",
|
||||||
|
label: "账号与人员",
|
||||||
|
value: "管理",
|
||||||
|
note: "维护账号、角色和组织信息。",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
roleCapabilities.canManageSystem
|
||||||
|
? {
|
||||||
|
key: "system-workspace",
|
||||||
|
href: "/system/workspace",
|
||||||
|
label: "工作区设置",
|
||||||
|
value: "配置",
|
||||||
|
note: "检查入口、通知和基础配置。",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
roleCapabilities.canManageSystem
|
||||||
|
? {
|
||||||
|
key: "system-audit",
|
||||||
|
href: "/audit",
|
||||||
|
label: "审计日志",
|
||||||
|
value: "查看",
|
||||||
|
note: "查询关键操作留痕。",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
] as Array<SummaryItem | null>).filter(isSummaryItem);
|
||||||
|
|
||||||
const quickActions: QuickActionConfig[] = [];
|
const quickActions: QuickActionConfig[] = [];
|
||||||
if (hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) {
|
if (roleCapabilities.canApproveTasks) {
|
||||||
|
quickActions.push({
|
||||||
|
key: "approval",
|
||||||
|
icon: "fact_check",
|
||||||
|
label: "处理审批",
|
||||||
|
description: "进入审批中心查看待办",
|
||||||
|
to: "/workflow/tasks",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roleCapabilities.canUseFinance && payload.financeSummary) {
|
||||||
|
quickActions.push({
|
||||||
|
key: "finance",
|
||||||
|
icon: "payments",
|
||||||
|
label: "财务处理",
|
||||||
|
description: "查看付款和风险待处理",
|
||||||
|
to: payload.financeSummary.href,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (roleCapabilities.canManageSystem) {
|
||||||
|
quickActions.push({
|
||||||
|
key: "system",
|
||||||
|
icon: "admin_panel_settings",
|
||||||
|
label: "系统管理",
|
||||||
|
description: "账号、配置和审计入口",
|
||||||
|
to: "/people",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, LEAVE_PORTAL_PERMISSIONS)) {
|
||||||
quickActions.push({
|
quickActions.push({
|
||||||
key: "leave",
|
key: "leave",
|
||||||
icon: "event_available",
|
icon: "event_available",
|
||||||
@@ -996,7 +1147,7 @@ export function PortalHomePage() {
|
|||||||
to: "/leave-requests?action=create",
|
to: "/leave-requests?action=create",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
if (!roleCapabilities.isAdminOnly && hasAnyPermission(permissions, EXPENSE_PORTAL_PERMISSIONS)) {
|
||||||
quickActions.push({
|
quickActions.push({
|
||||||
key: "expense",
|
key: "expense",
|
||||||
icon: "request_quote",
|
icon: "request_quote",
|
||||||
@@ -1036,10 +1187,16 @@ export function PortalHomePage() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
|
||||||
<WorkbenchHero
|
<WorkbenchHero
|
||||||
eyebrow={isManagerView ? "管理工作台" : "个人工作台"}
|
eyebrow={workspaceEyebrow}
|
||||||
title={`你好,${payload.greeting.name}`}
|
title={`你好,${payload.greeting.name}`}
|
||||||
className="workbench-hero--compact"
|
className="workbench-hero--compact"
|
||||||
subtitle={isManagerView ? "审批、团队、周报。" : "待办、打卡、申请。"}
|
subtitle={roleCapabilities.canUseFinance
|
||||||
|
? "付款、风险、审批。"
|
||||||
|
: roleCapabilities.canManageSystem
|
||||||
|
? "配置、审计、通知。"
|
||||||
|
: isManagerView
|
||||||
|
? "审批、团队、周报。"
|
||||||
|
: "待办、打卡、申请。"}
|
||||||
actions={
|
actions={
|
||||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||||
<Button asChild variant="secondary" type="button">
|
<Button asChild variant="secondary" type="button">
|
||||||
@@ -1117,11 +1274,22 @@ export function PortalHomePage() {
|
|||||||
{isManagerView ? (
|
{isManagerView ? (
|
||||||
<>
|
<>
|
||||||
<SecondaryWorkspacePanel
|
<SecondaryWorkspacePanel
|
||||||
title="今日管理重点 / 常用入口"
|
title="角色重点 / 常用入口"
|
||||||
description="按需展开。"
|
description="按需展开。"
|
||||||
>
|
>
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),360px]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)] 2xl:grid-cols-[minmax(0,1fr),minmax(0,1fr),360px]">
|
||||||
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
|
{roleCapabilities.canApproveTasks ? (
|
||||||
|
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
|
||||||
|
) : null}
|
||||||
|
{roleCapabilities.canUseFinance ? (
|
||||||
|
<FinanceSummaryPanel summary={payload.financeSummary ?? null} />
|
||||||
|
) : null}
|
||||||
|
{roleCapabilities.canManageSystem ? (
|
||||||
|
<SummaryPanel title="系统管理" items={systemSummaryItems} />
|
||||||
|
) : null}
|
||||||
|
{roleCapabilities.canViewTeam ? (
|
||||||
|
<SummaryPanel title="今日团队状态" items={managerSummaryItems} />
|
||||||
|
) : null}
|
||||||
<QuickLinksPanel items={quickLinkItems} />
|
<QuickLinksPanel items={quickLinkItems} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -981,6 +981,25 @@ export function PurchaseRequestsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">采购状态</div>
|
||||||
|
<div className="mt-2"><StatusBadge status={detail.status} /></div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">总金额</div>
|
||||||
|
<div className="mt-2 font-semibold text-zinc-900">{totalAmountPreview}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">当前节点</div>
|
||||||
|
<div className="mt-2 font-medium text-zinc-900">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">附件</div>
|
||||||
|
<div className="mt-2 font-medium text-zinc-900">{detail.attachments.length} 个</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -1059,10 +1078,15 @@ export function PurchaseRequestsPage() {
|
|||||||
<Textarea rows={4} value={reason} disabled={!canEditDraft} onChange={(event) => setReason(event.target.value)} />
|
<Textarea rows={4} value={reason} disabled={!canEditDraft} onChange={(event) => setReason(event.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm text-zinc-600">
|
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 text-sm text-zinc-600">
|
||||||
<div>预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}</div>
|
<div>预算池:{detail.budgetPool?.name ?? "待提交时自动解析"}</div>
|
||||||
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
<div>待办节点:{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
||||||
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
|
<div>总金额:<span className="font-semibold text-zinc-900">{totalAmountPreview}</span></div>
|
||||||
|
{!detail.budgetPool ? (
|
||||||
|
<div className="mt-2 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
|
||||||
|
未匹配预算池时仍会按当前规则提交审批;如需强预算校验,请由管理员补充预算配置。
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -14,6 +14,35 @@ import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
|||||||
import { TEST_IDS } from "../lib/testIds";
|
import { TEST_IDS } from "../lib/testIds";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
const WORKFLOW_STATUS_OPTIONS = [
|
||||||
|
{ value: "pending", label: "待处理" },
|
||||||
|
{ value: "completed", label: "已处理" },
|
||||||
|
{ value: "rejected", label: "已驳回" },
|
||||||
|
{ value: "cancelled", label: "已取消" },
|
||||||
|
{ value: "skipped", label: "已跳过" },
|
||||||
|
] as const;
|
||||||
|
const WORKFLOW_ENTITY_OPTIONS = [
|
||||||
|
{ value: "all", label: "全部业务" },
|
||||||
|
{ value: "reimbursement", label: "报销" },
|
||||||
|
{ value: "leave", label: "请假" },
|
||||||
|
{ value: "purchase_request", label: "采购" },
|
||||||
|
{ value: "travel_request", label: "差旅" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type WorkflowStatusFilter = (typeof WORKFLOW_STATUS_OPTIONS)[number]["value"];
|
||||||
|
type WorkflowEntityFilter = (typeof WORKFLOW_ENTITY_OPTIONS)[number]["value"];
|
||||||
|
|
||||||
|
function normalizeStatusFilter(value: string | null): WorkflowStatusFilter {
|
||||||
|
return WORKFLOW_STATUS_OPTIONS.some((item) => item.value === value)
|
||||||
|
? (value as WorkflowStatusFilter)
|
||||||
|
: "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntityFilter(value: string | null): WorkflowEntityFilter {
|
||||||
|
return WORKFLOW_ENTITY_OPTIONS.some((item) => item.value === value)
|
||||||
|
? (value as WorkflowEntityFilter)
|
||||||
|
: "all";
|
||||||
|
}
|
||||||
|
|
||||||
function entityLabel(entityType: string | undefined) {
|
function entityLabel(entityType: string | undefined) {
|
||||||
if (entityType === "leave") {
|
if (entityType === "leave") {
|
||||||
@@ -82,19 +111,36 @@ export function WorkflowTasksPage() {
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const status =
|
const status = normalizeStatusFilter(searchParams.get("status"));
|
||||||
(searchParams.get("status") as
|
const entityType = normalizeEntityFilter(searchParams.get("entityType"));
|
||||||
| "pending"
|
|
||||||
| "completed"
|
|
||||||
| "rejected"
|
|
||||||
| "cancelled"
|
|
||||||
| "skipped"
|
|
||||||
| null) ?? "pending";
|
|
||||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||||
|
|
||||||
|
function updateSearchParams(next: {
|
||||||
|
status?: WorkflowStatusFilter;
|
||||||
|
entityType?: WorkflowEntityFilter;
|
||||||
|
page?: number;
|
||||||
|
}) {
|
||||||
|
const nextStatus = next.status ?? status;
|
||||||
|
const nextEntityType = next.entityType ?? entityType;
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
status: nextStatus,
|
||||||
|
page: String(next.page ?? page),
|
||||||
|
};
|
||||||
|
if (nextEntityType !== "all") {
|
||||||
|
params.entityType = nextEntityType;
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
}
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["workflow", "tasks", status, page],
|
queryKey: ["workflow", "tasks", status, entityType, page],
|
||||||
queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
|
queryFn: () =>
|
||||||
|
fetchWorkflowTasks({
|
||||||
|
status,
|
||||||
|
entityType: entityType === "all" ? undefined : entityType,
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
const approveMutation = useMutation({
|
||||||
@@ -125,32 +171,47 @@ export function WorkflowTasksPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
<PageHeader title="待我处理" subtitle="待处理审批。" />
|
<PageHeader title="审批中心" subtitle="集中处理待办、查看已办和业务审批历史。" />
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-4 md:p-5">
|
<div className="card-std space-y-4 p-4 md:p-5">
|
||||||
<Tabs
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||||
data-testid="workflow-tasks-status-tabs"
|
<Tabs
|
||||||
value={status}
|
data-testid="workflow-tasks-status-tabs"
|
||||||
onValueChange={(value) => setSearchParams({ status: value, page: "1" })}
|
value={status}
|
||||||
>
|
onValueChange={(value) => updateSearchParams({ status: value as WorkflowStatusFilter, page: 1 })}
|
||||||
<TabsList className="flex w-full flex-wrap justify-start gap-1">
|
>
|
||||||
{[
|
<TabsList className="flex w-full flex-wrap justify-start gap-1 xl:w-auto">
|
||||||
{ value: "pending", label: "待处理" },
|
{WORKFLOW_STATUS_OPTIONS.map((item) => (
|
||||||
{ value: "completed", label: "已处理" },
|
<TabsTrigger
|
||||||
{ value: "rejected", label: "已驳回" },
|
data-testid={`workflow-tasks-tab-${item.value}`}
|
||||||
{ value: "cancelled", label: "已取消" },
|
key={item.value}
|
||||||
{ value: "skipped", label: "已跳过" },
|
value={item.value}
|
||||||
].map((item) => (
|
>
|
||||||
<TabsTrigger
|
{item.label}
|
||||||
data-testid={`workflow-tasks-tab-${item.value}`}
|
</TabsTrigger>
|
||||||
key={item.value}
|
))}
|
||||||
value={item.value}
|
</TabsList>
|
||||||
>
|
</Tabs>
|
||||||
{item.label}
|
|
||||||
</TabsTrigger>
|
<div className="flex items-center gap-2">
|
||||||
))}
|
<label className="text-sm font-medium text-zinc-700" htmlFor="workflow-entity-type-filter">
|
||||||
</TabsList>
|
业务类型
|
||||||
</Tabs>
|
</label>
|
||||||
|
<select
|
||||||
|
id="workflow-entity-type-filter"
|
||||||
|
className="select-std min-w-36"
|
||||||
|
data-testid="workflow-tasks-entity-filter"
|
||||||
|
value={entityType}
|
||||||
|
onChange={(event) => updateSearchParams({ entityType: event.target.value as WorkflowEntityFilter, page: 1 })}
|
||||||
|
>
|
||||||
|
{WORKFLOW_ENTITY_OPTIONS.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
{query.isLoading ? <TableSkeleton columns={7} rows={5} /> : null}
|
||||||
|
|
||||||
@@ -184,6 +245,7 @@ export function WorkflowTasksPage() {
|
|||||||
<div className="mt-3 grid gap-1 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
<div className="mt-3 grid gap-1 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||||
<div>申请人:{displayApplicant(task)}</div>
|
<div>申请人:{displayApplicant(task)}</div>
|
||||||
<div>金额/天数:{displayMetric(task)}</div>
|
<div>金额/天数:{displayMetric(task)}</div>
|
||||||
|
<div>当前节点:{task.stepName}</div>
|
||||||
<div>业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}</div>
|
<div>业务状态:{businessStatusLabel(task.businessSummary?.businessStatus)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||||
@@ -224,12 +286,13 @@ export function WorkflowTasksPage() {
|
|||||||
<Table data-testid="workflow-tasks-table">
|
<Table data-testid="workflow-tasks-table">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<tr>
|
<tr>
|
||||||
<TableHead>审批事项</TableHead>
|
<TableHead>业务类型 / 编号</TableHead>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
<TableHead>申请人</TableHead>
|
<TableHead>申请人</TableHead>
|
||||||
<TableHead>金额/天数</TableHead>
|
<TableHead>金额/天数</TableHead>
|
||||||
|
<TableHead>当前节点</TableHead>
|
||||||
<TableHead>业务状态</TableHead>
|
<TableHead>业务状态</TableHead>
|
||||||
<TableHead>任务状态</TableHead>
|
<TableHead>更新时间</TableHead>
|
||||||
<TableHead>创建时间</TableHead>
|
|
||||||
<TableHead className="w-24">操作</TableHead>
|
<TableHead className="w-24">操作</TableHead>
|
||||||
</tr>
|
</tr>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -237,16 +300,18 @@ export function WorkflowTasksPage() {
|
|||||||
{taskItems.length ? (
|
{taskItems.length ? (
|
||||||
taskItems.map((task) => (
|
taskItems.map((task) => (
|
||||||
<TableRow data-testid="workflow-task-row" key={task.id}>
|
<TableRow data-testid="workflow-task-row" key={task.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{entityLabel(task.workflowInstance?.entityType)}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{displayReference(task)}</div>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">{displayTitle(task)}</div>
|
<div className="font-medium text-zinc-900">{displayTitle(task)}</div>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="mt-1"><StatusBadge status={task.status} /></div>
|
||||||
{entityLabel(task.workflowInstance?.entityType)} · {displayReference(task)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{displayApplicant(task)}</TableCell>
|
<TableCell>{displayApplicant(task)}</TableCell>
|
||||||
<TableCell>{displayMetric(task)}</TableCell>
|
<TableCell>{displayMetric(task)}</TableCell>
|
||||||
|
<TableCell>{task.stepName}</TableCell>
|
||||||
<TableCell>{businessStatusLabel(task.businessSummary?.businessStatus)}</TableCell>
|
<TableCell>{businessStatusLabel(task.businessSummary?.businessStatus)}</TableCell>
|
||||||
<TableCell><StatusBadge status={task.status} /></TableCell>
|
|
||||||
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -275,7 +340,7 @@ export function WorkflowTasksPage() {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="py-3 text-zinc-500" colSpan={7}>
|
<TableCell className="py-3 text-zinc-500" colSpan={8}>
|
||||||
暂无数据
|
暂无数据
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -289,7 +354,7 @@ export function WorkflowTasksPage() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
page={page}
|
page={page}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={(p) => setSearchParams({ status, page: String(p) })}
|
onPageChange={(p) => updateSearchParams({ page: p })}
|
||||||
testIdPrefix="workflow-tasks"
|
testIdPrefix="workflow-tasks"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||||
|
|
||||||
import { EMPLOYEE, login, logout, type E2EUser } from "./helpers/auth";
|
import { EMPLOYEE, FINANCE, login, logout, type E2EUser } from "./helpers/auth";
|
||||||
|
|
||||||
const ADMIN: E2EUser = {
|
const ADMIN: E2EUser = {
|
||||||
identifier: "[email protected]",
|
identifier: "[email protected]",
|
||||||
@@ -34,15 +34,27 @@ test.describe("visual portal", () => {
|
|||||||
await page.setViewportSize({ width: 1440, height: 1200 });
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
|
|
||||||
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-pending_approvals"]')).toHaveCount(0);
|
||||||
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-team_presence"]')).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-weekly_missing"]')).toBeVisible();
|
||||||
await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible();
|
await expect(page.locator('[aria-label="portal-card-weekly_help"]')).toBeVisible();
|
||||||
await expect(page.locator("summary").filter({ hasText: "今日管理重点 / 常用入口" })).toBeVisible();
|
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
|
||||||
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
await expect(page.getByText("试点观测摘要")).toBeVisible();
|
||||||
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
await expect(page.getByRole("tab", { name: "公告" })).toBeVisible();
|
||||||
|
|
||||||
await attachScreenshot(page, testInfo, "portal-admin.png");
|
await attachScreenshot(page, testInfo, "portal-admin.png");
|
||||||
await logout(page, ADMIN.name);
|
await logout(page, ADMIN.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("财务 Portal 显示财务待处理入口", async ({ page }, testInfo) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 1200 });
|
||||||
|
await login(page, FINANCE);
|
||||||
|
|
||||||
|
await expect(page.getByText("财务工作台")).toBeVisible();
|
||||||
|
await expect(page.getByText("财务处理")).toBeVisible();
|
||||||
|
await expect(page.locator("summary").filter({ hasText: "角色重点 / 常用入口" })).toBeVisible();
|
||||||
|
|
||||||
|
await attachScreenshot(page, testInfo, "portal-finance.png");
|
||||||
|
await logout(page, FINANCE.name);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ test("E2E 审批列表快捷通过:经理可在待办列表行内直接通过
|
|||||||
await login(page, MANAGER);
|
await login(page, MANAGER);
|
||||||
await (await openNavLink(page, "待我处理")).click();
|
await (await openNavLink(page, "待我处理")).click();
|
||||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||||
|
await page.getByTestId("workflow-tasks-entity-filter").selectOption("leave");
|
||||||
|
|
||||||
const taskRows = page.locator(
|
const taskRows = page.locator(
|
||||||
`[data-testid="${TEST_IDS.workflow.taskRow}"]:has(a[href="/workflow/tasks/${submitResult.currentTaskId}"])`,
|
`[data-testid="${TEST_IDS.workflow.taskRow}"]:has(a[href="/workflow/tasks/${submitResult.currentTaskId}"])`,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
|
||||||
PC6-1,PC 信息架构优化合同建档,在 R6 首轮通过后固定 PC 工作台和高频业务页第一批优化范围。,"1) 本 CSV 校验通过;2) 范围限定 PC 工作台、审批中心、报销、财务、采购;3) 不扩预算、ERP、AI、完整差旅、复杂 BPM、审批设计器;4) 不改移动统一设计。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv;人工核对主 R6 已 DONE。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv,[],R6 通过后才允许实施;本合同不代表全面 PC 重构。
|
PC6-1,PC 信息架构优化合同建档,在 R6 首轮通过后固定 PC 工作台和高频业务页第一批优化范围。,"1) 本 CSV 校验通过;2) 范围限定 PC 工作台、审批中心、报销、财务、采购;3) 不扩预算、ERP、AI、完整差旅、复杂 BPM、审批设计器;4) 不改移动统一设计。","流程级验证:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv;人工核对主 R6 已 DONE。",python3|manual,DONE,DONE,DONE,issues/2026-05-12_01-40-00-r6-pc-workbench-ia-polish.csv,[],R6 通过后才允许实施;本合同不代表全面 PC 重构。
|
||||||
PC6-2,PC 工作台角色化摘要,优化 PC 工作台按员工、审批人、财务、管理员展示待办、通知、我的申请、财务待处理和最近业务摘要。,"1) 同一工作台框架按角色裁剪摘要和入口;2) 员工不看到财务付款和管理配置;3) 管理员不获得业务审批按钮;4) 入口与服务端权限一致。","流程级验证:员工、审批人、财务、管理员分别登录 PC 工作台 -> 检查摘要、入口和无权动作隐藏;直连无权 API 仍拒绝。",playwright|manual,TODO,TODO,TODO,client/src/pages/PortalHomePage.tsx|server/src/routes/portal.js,"[""PC6-1""]",如需后端摘要字段,先复用现有 portal 接口,新增字段必须保持向后兼容。
|
PC6-2,PC 工作台角色化摘要,优化 PC 工作台按员工、审批人、财务、管理员展示待办、通知、我的申请、财务待处理和最近业务摘要。,"1) 同一工作台框架按角色裁剪摘要和入口;2) 员工不看到财务付款和管理配置;3) 管理员不获得业务审批按钮;4) 入口与服务端权限一致。","流程级验证:员工、审批人、财务、管理员分别登录 PC 工作台 -> 检查摘要、入口和无权动作隐藏;直连无权 API 仍拒绝。",playwright|manual,DONE,DONE,DONE,client/src/pages/PortalHomePage.tsx|server/src/routes/portal.js,"[""PC6-1""]",已通过 portal 单测、员工/管理员/财务 Portal Playwright 视觉;管理员不显示业务审批主卡。
|
||||||
PC6-3,PC 审批中心统一信息架构,统一待办、已办、我发起和通知入口,列表展示业务类型、编号、申请人、金额或天数、当前节点、状态和更新时间。,"1) PC 审批列表可按状态和业务类型筛选;2) 详情页展示摘要、业务字段、附件发票、审批流和日志;3) 自审阻断和 admin-only 禁止审批不回归。","流程级验证:SMOKE002 提交报销/请假/采购 -> SMOKE101 在 PC 审批中心处理 -> 查看已办和发起记录 -> 检查审批历史一致。",playwright|node,TODO,TODO,TODO,client/src/pages/WorkflowTasksPage.tsx|server/src/routes/workflow.js,"[""PC6-2""]",涉及审批权限时需 reviewer 复核。
|
PC6-3,PC 审批中心统一信息架构,统一待办、已办、我发起和通知入口,列表展示业务类型、编号、申请人、金额或天数、当前节点、状态和更新时间。,"1) PC 审批列表可按状态和业务类型筛选;2) 详情页展示摘要、业务字段、附件发票、审批流和日志;3) 自审阻断和 admin-only 禁止审批不回归。","流程级验证:SMOKE002 提交报销/请假/采购 -> SMOKE101 在 PC 审批中心处理 -> 查看已办和发起记录 -> 检查审批历史一致。",playwright|node,DONE,DONE,DONE,client/src/pages/WorkflowTasksPage.tsx|server/src/routes/workflow.js,"[""PC6-2""]",已通过 workflow entityType 单测、审批快捷通过 E2E 和 reimbursement/approval/leave/procurement 业务门禁。
|
||||||
PC6-4,PC 业务详情页第一批收口,优化报销、采购和财务详情页的信息分区,突出发票、附件、审批流、付款状态和采购状态解释。,"1) 报销详情突出发票、附件、审批流、付款状态;2) 采购详情突出状态、附件、审批历史;3) 财务页展示待复核、待付款、已付款和导出入口;4) 不引入桌面页面到移动路由。","流程级验证:PC 1440x960 打开报销详情、采购详情、财务页面 -> 核对核心字段和操作;移动路由静态检查无 PC 页面误挂。",playwright|node,TODO,TODO,TODO,client/src/pages/ReimbursementsPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/ApOperationsPage.tsx,"[""PC6-3""]",只做第一批高频页面,不全站重构。
|
PC6-4,PC 业务详情页第一批收口,优化报销、采购和财务详情页的信息分区,突出发票、附件、审批流、付款状态和采购状态解释。,"1) 报销详情突出发票、附件、审批流、付款状态;2) 采购详情突出状态、附件、审批历史;3) 财务页展示待复核、待付款、已付款和导出入口;4) 不引入桌面页面到移动路由。","流程级验证:PC 1440x960 打开报销详情、采购详情、财务页面 -> 核对核心字段和操作;移动路由静态检查无 PC 页面误挂。",playwright|node,DONE,DONE,DONE,client/src/pages/ReimbursementsPage.tsx|client/src/pages/PurchaseRequestsPage.tsx|client/src/pages/ApOperationsPage.tsx,"[""PC6-3""]",已通过构建、PC Portal/审批 E2E 和三类业务门禁;仅收口 PC 页面,不改移动路由。
|
||||||
PC6-5,PC 优化回归与部署,完成 PC 第一批优化后执行类型检查、构建、PC 视觉和公网复检。,"1) client typecheck/build 通过;2) PC 1440x960 视觉截图通过;3) 关键业务流不回归;4) 部署后 /ready、bundle 扫描和 live smoke 通过。","流程级验证:cd client && npm run typecheck && npm run build;PC Playwright 视觉;bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh。",npm|playwright|bash,TODO,TODO,TODO,client|scripts/qa|deploy/trialrun,"[""PC6-4""]",面向用户可测前必须完成部署和公网复检。
|
PC6-5,PC 优化回归与部署,完成 PC 第一批优化后执行类型检查、构建、PC 视觉和公网复检。,"1) client typecheck/build 通过;2) PC 1440x960 视觉截图通过;3) 关键业务流不回归;4) 部署后 /ready、bundle 扫描和 live smoke 通过。","流程级验证:cd client && npm run typecheck && npm run build;PC Playwright 视觉;bash deploy/trialrun/deploy.sh;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh。",npm|playwright|bash,DONE,DONE,DONE,client|scripts/qa|deploy/trialrun,"[""PC6-4""]",已部署到 https://oa.hyinda.com;/ready、public bundle findings=0、live smoke 和真实 OCR 可解释终态通过,报告 artifacts/qa-flow/live-smoke-readiness-20260512-023246。
|
||||||
|
|||||||
|
+79
-9
@@ -10,6 +10,30 @@ const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
|||||||
|
|
||||||
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
|
const PORTAL_RECENT_ACTIVITY_LIMIT = 15;
|
||||||
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
|
const PORTAL_RECENT_ACTIVITY_SOURCE_TAKE = 24;
|
||||||
|
const BUSINESS_APPROVE_PERMISSIONS = [
|
||||||
|
"expense.approve",
|
||||||
|
"leave.approve",
|
||||||
|
"procurement.approve",
|
||||||
|
"travel.approve",
|
||||||
|
];
|
||||||
|
const BUSINESS_SUBMIT_PERMISSIONS = [
|
||||||
|
"expense.create",
|
||||||
|
"expense.submit",
|
||||||
|
"leave.create",
|
||||||
|
"leave.submit",
|
||||||
|
"procurement.create",
|
||||||
|
"procurement.submit",
|
||||||
|
"travel.create",
|
||||||
|
"travel.submit",
|
||||||
|
];
|
||||||
|
const SYSTEM_MANAGE_PERMISSIONS = [
|
||||||
|
"system.users",
|
||||||
|
"system.settings",
|
||||||
|
"system.audit.view",
|
||||||
|
"notification.channel.manage",
|
||||||
|
];
|
||||||
|
const FINANCE_ROLE_CODES = new Set(["finance", "finance_mgr"]);
|
||||||
|
const BUSINESS_APPROVER_ROLE_CODES = new Set(["manager", "ceo", "finance", "finance_mgr", "hr"]);
|
||||||
|
|
||||||
async function buildTaskVisibilityFilters(tx, request) {
|
async function buildTaskVisibilityFilters(tx, request) {
|
||||||
const roleFilters = (request.employee.roleCodes ?? []).length
|
const roleFilters = (request.employee.roleCodes ?? []).length
|
||||||
@@ -18,6 +42,30 @@ async function buildTaskVisibilityFilters(tx, request) {
|
|||||||
return [{ assignee_id: request.employee.id }, ...roleFilters];
|
return [{ assignee_id: request.employee.id }, ...roleFilters];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAnyPermission(fastify, employee, permissionCodes) {
|
||||||
|
return permissionCodes.some((permissionCode) => fastify.hasPermission(employee, permissionCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRoleCapabilities(fastify, employee) {
|
||||||
|
const roleCodes = employee.roleCodes ?? [];
|
||||||
|
const isAdmin = roleCodes.includes("admin") || fastify.hasPermission(employee, "*");
|
||||||
|
const hasBusinessApproverRole = roleCodes.some((roleCode) => BUSINESS_APPROVER_ROLE_CODES.has(roleCode));
|
||||||
|
const canUseFinance =
|
||||||
|
roleCodes.some((roleCode) => FINANCE_ROLE_CODES.has(roleCode)) &&
|
||||||
|
hasAnyPermission(fastify, employee, ["payment.batch.manage", "risk.review.manage", "expense.finance.pay"]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
canSubmitApplications: hasAnyPermission(fastify, employee, BUSINESS_SUBMIT_PERMISSIONS),
|
||||||
|
canApproveTasks: hasBusinessApproverRole && hasAnyPermission(fastify, employee, BUSINESS_APPROVE_PERMISSIONS),
|
||||||
|
canUseFinance,
|
||||||
|
canManageSystem: isAdmin || hasAnyPermission(fastify, employee, SYSTEM_MANAGE_PERMISSIONS),
|
||||||
|
canViewTeam:
|
||||||
|
hasBusinessApproverRole ||
|
||||||
|
hasAnyPermission(fastify, employee, ["report.dashboard.view", "system.users", "project.manage"]),
|
||||||
|
isAdminOnly: isAdmin && !hasBusinessApproverRole,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureFeatureEnabled(fastify, request) {
|
async function ensureFeatureEnabled(fastify, request) {
|
||||||
const gate = await evaluateFeatureFlag({
|
const gate = await evaluateFeatureFlag({
|
||||||
prisma: fastify.prisma,
|
prisma: fastify.prisma,
|
||||||
@@ -410,6 +458,7 @@ async function portalRoutes(fastify) {
|
|||||||
const today = currentDateString(fastify.config.appTimezone);
|
const today = currentDateString(fastify.config.appTimezone);
|
||||||
const currentWeekStart = resolveWeekStart(today);
|
const currentWeekStart = resolveWeekStart(today);
|
||||||
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
const assigneeFilters = await buildTaskVisibilityFilters(fastify.prisma, request);
|
||||||
|
const roleCapabilities = buildRoleCapabilities(fastify, request.employee);
|
||||||
const isManagerView =
|
const isManagerView =
|
||||||
fastify.hasPermission(request.employee, "system.users") ||
|
fastify.hasPermission(request.employee, "system.users") ||
|
||||||
fastify.hasPermission(request.employee, "report.dashboard.view") ||
|
fastify.hasPermission(request.employee, "report.dashboard.view") ||
|
||||||
@@ -447,6 +496,8 @@ async function portalRoutes(fastify) {
|
|||||||
openProjectRiskCount,
|
openProjectRiskCount,
|
||||||
projectFollowUpDueTodayCount,
|
projectFollowUpDueTodayCount,
|
||||||
closurePendingProjectCount,
|
closurePendingProjectCount,
|
||||||
|
pendingFinanceBatchCount,
|
||||||
|
failedFinanceItemCount,
|
||||||
recentActivities,
|
recentActivities,
|
||||||
teamStatusBoard,
|
teamStatusBoard,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -454,7 +505,7 @@ async function portalRoutes(fastify) {
|
|||||||
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
fastify.prisma.reimbursementForm.count({ where: { OR: [{ owner_id: request.employee.id }, { submitter_id: request.employee.id }] } }),
|
||||||
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
|
fastify.prisma.leaveRequest.count({ where: { owner_id: request.employee.id } }),
|
||||||
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
|
fastify.prisma.assetCard.count({ where: { assignee_employee_id: request.employee.id } }),
|
||||||
fastify.hasPermission(request.employee, "payment.batch.manage") || fastify.hasPermission(request.employee, "risk.review.manage")
|
roleCapabilities.canUseFinance
|
||||||
? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } })
|
? fastify.prisma.entityRiskFlag.count({ where: { status: "open" } })
|
||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
featureEnabled.announcements
|
featureEnabled.announcements
|
||||||
@@ -556,6 +607,14 @@ async function portalRoutes(fastify) {
|
|||||||
select: { project_id: true },
|
select: { project_id: true },
|
||||||
}).then((items) => items.length)
|
}).then((items) => items.length)
|
||||||
: Promise.resolve(0),
|
: Promise.resolve(0),
|
||||||
|
roleCapabilities.canUseFinance
|
||||||
|
? fastify.prisma.paymentBatch.count({
|
||||||
|
where: { status: { in: ["draft", "reviewed", "failed"] } },
|
||||||
|
})
|
||||||
|
: Promise.resolve(0),
|
||||||
|
roleCapabilities.canUseFinance
|
||||||
|
? fastify.prisma.paymentBatchItem.count({ where: { status: "failed" } })
|
||||||
|
: Promise.resolve(0),
|
||||||
buildRecentActivities(fastify.prisma),
|
buildRecentActivities(fastify.prisma),
|
||||||
buildTeamStatusBoard(fastify.prisma, today),
|
buildTeamStatusBoard(fastify.prisma, today),
|
||||||
]);
|
]);
|
||||||
@@ -619,13 +678,15 @@ async function portalRoutes(fastify) {
|
|||||||
href: "/people",
|
href: "/people",
|
||||||
severity: (assetReturnPendingCount ?? 0) > 0 ? "warning" : "normal",
|
severity: (assetReturnPendingCount ?? 0) > 0 ? "warning" : "normal",
|
||||||
});
|
});
|
||||||
cards.push({
|
if (roleCapabilities.canApproveTasks) {
|
||||||
key: "pending_approvals",
|
cards.push({
|
||||||
title: "审批待办",
|
key: "pending_approvals",
|
||||||
value: pendingTaskCount,
|
title: "审批待办",
|
||||||
href: "/workflow/tasks",
|
value: pendingTaskCount,
|
||||||
severity: pendingTaskCount > 0 ? "warning" : "normal",
|
href: "/workflow/tasks",
|
||||||
});
|
severity: pendingTaskCount > 0 ? "warning" : "normal",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (featureEnabled.projectDomain) {
|
if (featureEnabled.projectDomain) {
|
||||||
cards.push({ key: "project_milestones", title: "逾期里程碑", value: overdueProjectMilestoneCount, href: "/projects", severity: overdueProjectMilestoneCount > 0 ? "warning" : "normal" });
|
cards.push({ key: "project_milestones", title: "逾期里程碑", value: overdueProjectMilestoneCount, href: "/projects", severity: overdueProjectMilestoneCount > 0 ? "warning" : "normal" });
|
||||||
cards.push({ key: "project_risks", title: "项目风险", value: openProjectRiskCount, href: "/projects", severity: openProjectRiskCount > 0 ? "high" : "normal" });
|
cards.push({ key: "project_risks", title: "项目风险", value: openProjectRiskCount, href: "/projects", severity: openProjectRiskCount > 0 ? "high" : "normal" });
|
||||||
@@ -660,7 +721,7 @@ async function portalRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (featureEnabled.paymentBatch && riskCount > 0) {
|
if (featureEnabled.paymentBatch && roleCapabilities.canUseFinance && riskCount > 0) {
|
||||||
cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" });
|
cards.push({ key: "risk_flags", title: "待复核风险", value: riskCount, href: "/ap-operations", severity: "high" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,6 +808,15 @@ async function portalRoutes(fastify) {
|
|||||||
href: "/people",
|
href: "/people",
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
roleCapabilities,
|
||||||
|
financeSummary: roleCapabilities.canUseFinance
|
||||||
|
? {
|
||||||
|
pendingBatchCount: pendingFinanceBatchCount,
|
||||||
|
failedItemCount: failedFinanceItemCount,
|
||||||
|
riskCount,
|
||||||
|
href: "/ap-operations",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
adoptionSummary,
|
adoptionSummary,
|
||||||
recentActivities,
|
recentActivities,
|
||||||
teamStatusBoard,
|
teamStatusBoard,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const BATCH_REJECT_BODY_SCHEMA = z.object({
|
|||||||
|
|
||||||
const TASK_LIST_QUERY_SCHEMA = z.object({
|
const TASK_LIST_QUERY_SCHEMA = z.object({
|
||||||
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
||||||
|
entityType: z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]).optional(),
|
||||||
page: z.coerce.number().int().min(1).optional(),
|
page: z.coerce.number().int().min(1).optional(),
|
||||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||||
});
|
});
|
||||||
@@ -250,6 +251,7 @@ function parseTaskListQuery(query) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: parsed.data.status ?? "pending",
|
status: parsed.data.status ?? "pending",
|
||||||
|
entityType: parsed.data.entityType ?? null,
|
||||||
page: parsed.data.page ?? 1,
|
page: parsed.data.page ?? 1,
|
||||||
pageSize: parsed.data.pageSize ?? 20,
|
pageSize: parsed.data.pageSize ?? 20,
|
||||||
};
|
};
|
||||||
@@ -1543,6 +1545,9 @@ async function workflowRoutes(fastify) {
|
|||||||
const where = {
|
const where = {
|
||||||
status: query.status,
|
status: query.status,
|
||||||
OR: assigneeFilters,
|
OR: assigneeFilters,
|
||||||
|
...(query.entityType
|
||||||
|
? { workflow_instance: { is: { entity_type: query.entityType } } }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [tasks, total] = await Promise.all([
|
const [tasks, total] = await Promise.all([
|
||||||
@@ -2663,5 +2668,6 @@ module.exports.__testables = {
|
|||||||
buildWorkflowEntityMeta,
|
buildWorkflowEntityMeta,
|
||||||
handlePurchaseApprovalErpSync,
|
handlePurchaseApprovalErpSync,
|
||||||
listAllowedActionsForTask,
|
listAllowedActionsForTask,
|
||||||
|
parseTaskListQuery,
|
||||||
resolveWorkflowOutcomeNotificationType,
|
resolveWorkflowOutcomeNotificationType,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ async function buildApp(options = {}) {
|
|||||||
);
|
);
|
||||||
const travelRows = options.travelRows ?? [];
|
const travelRows = options.travelRows ?? [];
|
||||||
const permissionCodes = options.permissionCodes ?? ["expense.view_own"];
|
const permissionCodes = options.permissionCodes ?? ["expense.view_own"];
|
||||||
|
const roleCodes = options.roleCodes ?? [];
|
||||||
const employeeId = options.employeeId ?? "employee-1";
|
const employeeId = options.employeeId ?? "employee-1";
|
||||||
|
|
||||||
resolvePresenceForEmployee.mockImplementation(async (_prisma, targetEmployeeId) => {
|
resolvePresenceForEmployee.mockImplementation(async (_prisma, targetEmployeeId) => {
|
||||||
@@ -189,6 +190,15 @@ async function buildApp(options = {}) {
|
|||||||
assetCard: {
|
assetCard: {
|
||||||
count: vi.fn(async () => 1),
|
count: vi.fn(async () => 1),
|
||||||
},
|
},
|
||||||
|
paymentBatch: {
|
||||||
|
count: vi.fn(async () => options.pendingFinanceBatchCount ?? 0),
|
||||||
|
},
|
||||||
|
paymentBatchItem: {
|
||||||
|
count: vi.fn(async () => options.failedFinanceItemCount ?? 0),
|
||||||
|
},
|
||||||
|
entityRiskFlag: {
|
||||||
|
count: vi.fn(async () => options.financeRiskCount ?? 0),
|
||||||
|
},
|
||||||
employee: {
|
employee: {
|
||||||
count: vi.fn(async () => employees.length),
|
count: vi.fn(async () => employees.length),
|
||||||
findMany: vi.fn(async () => employees),
|
findMany: vi.fn(async () => employees),
|
||||||
@@ -295,7 +305,7 @@ async function buildApp(options = {}) {
|
|||||||
name: "测试用户",
|
name: "测试用户",
|
||||||
department_id: "department-1",
|
department_id: "department-1",
|
||||||
permissionCodes,
|
permissionCodes,
|
||||||
roleCodes: [],
|
roleCodes,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
app.decorate(
|
app.decorate(
|
||||||
@@ -472,4 +482,64 @@ describe("portal routes", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns roleCapabilities and financeSummary only for finance roles", async () => {
|
||||||
|
featureFlagState.paymentBatch = true;
|
||||||
|
const { app } = await buildApp({
|
||||||
|
roleCodes: ["employee", "finance"],
|
||||||
|
permissionCodes: ["expense.view_own", "payment.batch.manage", "expense.finance.pay", "expense.approve"],
|
||||||
|
pendingFinanceBatchCount: 3,
|
||||||
|
failedFinanceItemCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/portal/home",
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const payload = response.json().data;
|
||||||
|
expect(payload.roleCapabilities).toMatchObject({
|
||||||
|
canApproveTasks: true,
|
||||||
|
canUseFinance: true,
|
||||||
|
canManageSystem: false,
|
||||||
|
});
|
||||||
|
expect(payload.financeSummary).toEqual({
|
||||||
|
pendingBatchCount: 3,
|
||||||
|
failedItemCount: 2,
|
||||||
|
riskCount: 0,
|
||||||
|
href: "/ap-operations",
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose financeSummary to admin-only accounts", async () => {
|
||||||
|
featureFlagState.paymentBatch = true;
|
||||||
|
const { app } = await buildApp({
|
||||||
|
roleCodes: ["admin"],
|
||||||
|
permissionCodes: ["*"],
|
||||||
|
pendingFinanceBatchCount: 3,
|
||||||
|
failedFinanceItemCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/portal/home",
|
||||||
|
headers: { authorization: "Bearer test" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const payload = response.json().data;
|
||||||
|
expect(payload.roleCapabilities).toMatchObject({
|
||||||
|
canManageSystem: true,
|
||||||
|
canUseFinance: false,
|
||||||
|
isAdminOnly: true,
|
||||||
|
});
|
||||||
|
expect(payload.financeSummary).toBeNull();
|
||||||
|
expect(payload.cards.map((card) => card.key)).not.toContain("pending_approvals");
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const workflowRoutes = require("../../../src/routes/workflow");
|
const workflowRoutes = require("../../../src/routes/workflow");
|
||||||
|
|
||||||
describe("Workflow Route Permissions", () => {
|
describe("Workflow Route Permissions", () => {
|
||||||
const { listAllowedActionsForTask } = workflowRoutes.__testables;
|
const { listAllowedActionsForTask, parseTaskListQuery } = workflowRoutes.__testables;
|
||||||
|
|
||||||
it("allows leave approvers to act on leave tasks", () => {
|
it("allows leave approvers to act on leave tasks", () => {
|
||||||
const actions = listAllowedActionsForTask(
|
const actions = listAllowedActionsForTask(
|
||||||
@@ -107,4 +107,17 @@ describe("Workflow Route Permissions", () => {
|
|||||||
|
|
||||||
expect(actions).toEqual([]);
|
expect(actions).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses optional workflow entityType filter for PC approval center", () => {
|
||||||
|
expect(parseTaskListQuery({ status: "pending", entityType: "purchase_request", page: "2" })).toMatchObject({
|
||||||
|
status: "pending",
|
||||||
|
entityType: "purchase_request",
|
||||||
|
page: 2,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported workflow entityType filters", () => {
|
||||||
|
expect(() => parseTaskListQuery({ entityType: "invoice_pool" })).toThrow("参数校验失败");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户