diff --git a/client/src/api/errorMessages.ts b/client/src/api/errorMessages.ts index 67ffde29..73134ce3 100644 --- a/client/src/api/errorMessages.ts +++ b/client/src/api/errorMessages.ts @@ -167,6 +167,21 @@ export function mapApiErrorMessage( return "自己的申请不能审批,请等待下一审批人处理。"; } + if (details?.workflow === "self_remind_not_allowed") { + return "不能催办分配给自己的审批任务。"; + } + + if (details?.workflow === "remind_owner_required") { + return "只有申请人或提交人可以催办该审批。"; + } + + if (details?.workflow === "remind_too_frequent") { + const nextAllowedAt = typeof details?.nextAllowedAt === "string" ? details.nextAllowedAt : null; + return nextAllowedAt + ? `24 小时内已催办过,请在 ${nextAllowedAt.slice(0, 19).replace("T", " ")} 后再试。` + : "24 小时内已催办过。"; + } + if (details?.workflow === "business_approval_role_required") { return "系统管理员不自动获得业务审批权,请配置正式审批角色后处理。"; } diff --git a/client/src/api/workflow.ts b/client/src/api/workflow.ts index b7ebaf17..91a00b44 100644 --- a/client/src/api/workflow.ts +++ b/client/src/api/workflow.ts @@ -35,6 +35,31 @@ export type WorkflowTaskListResponse = { items: WorkflowTaskSummary[]; }; +export type WorkflowTaskSummaryBucket = { + key: string; + pendingTotal: number; + overdueTotal: number; +}; + +export type WorkflowTaskProductivitySummary = { + scope: "mine" | "system"; + pendingTotal: number; + overdueTotal: number; + earliestPendingAt: string | null; + averagePendingAgeHours: number; + byEntityType: WorkflowTaskSummaryBucket[]; + byStep: WorkflowTaskSummaryBucket[]; + byAssigneeRole: WorkflowTaskSummaryBucket[]; +}; + +export type WorkflowTaskRemindResponse = { + taskId: string; + outcome: "reminded"; + workflowInstanceId: string; + nextAllowedAt: string; + notificationCount: number; +}; + export type WorkflowHistoryItem = { id: string; taskId: string | null; @@ -208,6 +233,10 @@ export type WorkflowTaskListQuery = { pageSize?: number; }; +export type WorkflowTaskSummaryQuery = { + scope?: "mine" | "system"; +}; + export type WorkflowApproverQuery = { keyword?: string; page?: number; @@ -249,6 +278,12 @@ export async function fetchWorkflowTasks(query: WorkflowTaskListQuery) { return apiRequestData(`/api/workflow/tasks${toQueryString(query)}`); } +export async function fetchWorkflowTaskSummary(query: WorkflowTaskSummaryQuery = {}) { + return apiRequestData( + `/api/workflow/tasks/summary${toQueryString(query)}`, + ); +} + export async function fetchWorkflowTaskDetail(taskId: string) { return apiRequestData(`/api/workflow/tasks/${taskId}`); } @@ -266,6 +301,13 @@ export async function approveWorkflowTask(taskId: string) { }); } +export async function remindWorkflowTask(taskId: string, comment?: string) { + return apiRequestData(`/api/workflow/tasks/${taskId}/remind`, { + method: "POST", + body: JSON.stringify(comment ? { comment } : {}), + }); +} + export async function rejectWorkflowTask(taskId: string, comment: string) { return apiRequestData>(`/api/workflow/tasks/${taskId}/reject`, { method: "POST", diff --git a/client/src/pages/MobileNotificationsPage.tsx b/client/src/pages/MobileNotificationsPage.tsx index b2df7782..d3275114 100644 --- a/client/src/pages/MobileNotificationsPage.tsx +++ b/client/src/pages/MobileNotificationsPage.tsx @@ -17,7 +17,7 @@ const PAGE_SIZE = 20; const NOTIFICATION_TYPE_OPTIONS = [ { label: "全部", value: "" }, - { label: "审批待办", value: "workflow.task_assigned" }, + { label: "审批待办", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" }, { label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid" }, { label: "请假", value: "leave.approved,leave.rejected" }, { label: "采购", value: "purchase_request.approved,purchase_request.rejected" }, diff --git a/client/src/pages/MobilePortalPage.tsx b/client/src/pages/MobilePortalPage.tsx index 3f60e098..43020684 100644 --- a/client/src/pages/MobilePortalPage.tsx +++ b/client/src/pages/MobilePortalPage.tsx @@ -10,7 +10,7 @@ import { import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchNotifications } from "../api/notifications"; import { isApiError } from "../api/types"; -import { fetchWorkflowTasks } from "../api/workflow"; +import { fetchWorkflowTasks, fetchWorkflowTaskSummary } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission, hasBusinessApproverRole } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; @@ -172,6 +172,12 @@ export function MobilePortalPage() { queryFn: () => fetchWorkflowTasks({ status: "pending", page: 1, pageSize: 3 }), }); + const workflowSummaryQuery = useQuery({ + enabled: canAccessApprovals, + queryKey: ["mobile", "workflow", "summary"], + queryFn: () => fetchWorkflowTaskSummary(), + }); + const notificationsQuery = useQuery({ queryKey: ["mobile", "portal", "notifications"], queryFn: () => fetchNotifications({ page: 1, pageSize: 3 }), @@ -210,7 +216,10 @@ export function MobilePortalPage() { const attendanceToday = attendanceTodayQuery.data ?? null; const attendanceAction = resolveAttendanceAction(attendanceToday); const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error); - const pendingApprovalCount = canAccessApprovals ? pendingCountQuery.data?.total ?? 0 : 0; + const pendingApprovalCount = canAccessApprovals + ? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0 + : 0; + const overdueApprovalCount = canAccessApprovals ? workflowSummaryQuery.data?.overdueTotal ?? 0 : 0; const pendingTasks = pendingCountQuery.data?.items ?? []; const notifications = notificationsQuery.data?.items ?? []; const unreadNotificationCount = notifications.filter((item) => !item.readAt).length; @@ -220,7 +229,11 @@ export function MobilePortalPage() { ? { key: "approvals", label: "审批", - meta: pendingApprovalCount > 0 ? `${pendingApprovalCount} 条待处理` : "暂无待办", + meta: overdueApprovalCount > 0 + ? `${pendingApprovalCount} 待处理 / ${overdueApprovalCount} 超时` + : pendingApprovalCount > 0 + ? `${pendingApprovalCount} 条待处理` + : "暂无待办", icon: "fact_check", to: "/mobile/approvals", tone: pendingApprovalCount > 0 ? "warning" : "primary", @@ -339,6 +352,24 @@ export function MobilePortalPage() { {resolveErrorMessage(pendingCountQuery.error)}

) : null} + {workflowSummaryQuery.data ? ( +
+
+
{workflowSummaryQuery.data.pendingTotal}
+
待办
+
+
+
0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}> + {workflowSummaryQuery.data.overdueTotal} +
+
超时
+
+
+
{workflowSummaryQuery.data.byEntityType.length}
+
类型
+
+
+ ) : null} {!pendingCountQuery.isLoading && !pendingTasks.length ? (

没有待处理审批。

) : null} diff --git a/client/src/pages/MobileReimbursementDetailPage.tsx b/client/src/pages/MobileReimbursementDetailPage.tsx index b8d3bc1b..ba437e9f 100644 --- a/client/src/pages/MobileReimbursementDetailPage.tsx +++ b/client/src/pages/MobileReimbursementDetailPage.tsx @@ -27,6 +27,7 @@ import { type ReimbursementItem, type ReimbursementStatus, } from "../api/reimbursements"; +import { remindWorkflowTask } from "../api/workflow"; import type { PrecheckResponse } from "../api/feeControl"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; @@ -119,6 +120,20 @@ function formatDateTime(value: string | null | undefined) { return value.slice(0, 19).replace("T", " "); } +function workflowActionLabel(action: string) { + const labels: Record = { + start: "提交", + approve: "通过", + reject: "驳回", + return: "退回", + transfer: "转审", + withdraw: "撤回", + remind: "催办", + overdue_remind: "超时提醒", + }; + return labels[action] ?? action; +} + function formatInvoiceTitle(invoice: { invoiceCode?: string | null; invoiceNumber?: string | null; @@ -205,6 +220,13 @@ export function MobileReimbursementDetailPage() { const canSubmitExpense = hasPermission(permissions, "expense.submit"); const isParticipant = Boolean(detail && me?.id && [detail.ownerId, detail.submitterId].includes(me.id)); const canEdit = isEditableStatus(detail?.status); + const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null; + const canRemindPendingWorkflow = Boolean( + detail?.status === "reviewing" && + pendingTaskId && + isParticipant && + detail?.pendingWorkflow?.task?.assignee_id !== me?.id, + ); const canOpenInvoiceDialog = Boolean(canEdit && isParticipant && canUploadInvoice && canViewOwnInvoice); const canUnbindInvoices = Boolean(canEdit && (isParticipant || canViewAllExpense)); const canDeleteDraft = Boolean(detail?.status === "draft" && canDeleteExpense && (isParticipant || canViewAllExpense)); @@ -393,6 +415,21 @@ export function MobileReimbursementDetailPage() { }, }); + const remindMutation = useMutation({ + mutationFn: (taskId: string) => remindWorkflowTask(taskId), + onSuccess: async () => { + await Promise.all([ + invalidateDetail(), + queryClient.invalidateQueries({ queryKey: ["mobile", "workflow"] }), + queryClient.invalidateQueries({ queryKey: ["notifications"] }), + ]); + toast.success("已催办当前审批人"); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error)); + }, + }); + const bindInvoicesMutation = useMutation({ mutationFn: async (invoiceSelections: Array<{ invoiceId: string; linkedItemId?: string }>) => { const results: Array<{ invoiceId: string; success: boolean; message?: string }> = []; @@ -800,6 +837,49 @@ export function MobileReimbursementDetailPage() { {detail.description ?

{detail.description}

: null} + {detail.status === "reviewing" || detail.approvalHistory.length ? ( +
+
+
+

审批

+

+ 当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"} +

+
+ {canRemindPendingWorkflow && pendingTaskId ? ( + + ) : null} +
+ + {detail.approvalHistory.length ? ( +
+ {detail.approvalHistory.map((item) => ( +
+
+
{workflowActionLabel(item.action)}
+
{formatDateTime(item.createdAt)}
+
+
+ {item.stepName ?? "-"} · {item.actorName || "系统"} +
+ {item.comment ?

{item.comment}

: null} +
+ ))} +
+ ) : ( +

暂无审批记录。

+ )} +
+ ) : null} +

报销明细

diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index 0689a13c..4ff3edc0 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -15,6 +15,7 @@ import { type PortalRecentActivity, type PortalTeamStatusItem, } from "../api/portal"; +import { fetchWorkflowTaskSummary, type WorkflowTaskProductivitySummary } from "../api/workflow"; import { isApiError } from "../api/types"; import { useAuth } from "../auth/AuthContext"; import { hasAnyPermission, isManagerRole } from "../auth/permissions"; @@ -46,6 +47,15 @@ type SummaryItem = { tone?: "normal" | "warning"; }; +function workflowEntityLabel(entityType: string) { + if (entityType === "reimbursement") return "报销"; + if (entityType === "leave") return "请假"; + if (entityType === "purchase_request") return "采购"; + if (entityType === "travel_request") return "差旅"; + if (entityType === "unknown") return "未知业务"; + return entityType; +} + type TodoAggregateItem = { title: string; sourceLabel: string; @@ -435,6 +445,52 @@ function FinanceSummaryPanel({ summary }: { summary: PortalHomePayload["financeS return ; } +function ApprovalProductivityPanel({ + summary, + title = "审批效率摘要", +}: { + summary: WorkflowTaskProductivitySummary | null; + title?: string; +}) { + if (!summary) { + return null; + } + + const typeDistribution = summary.byEntityType + .filter((item) => item.pendingTotal > 0) + .map((item) => `${workflowEntityLabel(item.key)} ${item.pendingTotal}`) + .join(" / "); + const items: SummaryItem[] = [ + { + key: "workflow-pending-total", + href: "/workflow/tasks", + label: "当前待办", + value: `${summary.pendingTotal} 条`, + note: summary.earliestPendingAt + ? `最早待处理:${formatRelativeTime(summary.earliestPendingAt)}` + : "暂无待处理审批。", + tone: summary.pendingTotal > 0 ? "warning" : "normal", + }, + { + key: "workflow-overdue-total", + href: "/workflow/tasks", + label: "超时待办", + value: `${summary.overdueTotal} 条`, + note: `平均等待 ${summary.averagePendingAgeHours} 小时`, + tone: summary.overdueTotal > 0 ? "warning" : "normal", + }, + { + key: "workflow-entity-distribution", + href: "/workflow/tasks", + label: "业务分布", + value: typeDistribution || "暂无", + note: "按报销、请假、采购、差旅汇总。", + }, + ]; + + return ; +} + function AnnouncementsList({ announcements }: { announcements: PortalHomePayload["announcements"] }) { const safeAnnouncements = Array.isArray(announcements) ? announcements : []; @@ -875,6 +931,16 @@ export function PortalHomePage() { queryFn: () => fetchMyTodayAttendance(), retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2, }); + const workflowSummaryQuery = useQuery({ + enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS), + queryKey: ["workflow", "tasks", "summary", "mine"], + queryFn: () => fetchWorkflowTaskSummary(), + }); + const workflowSystemSummaryQuery = useQuery({ + enabled: hasAnyPermission(permissions, ["system.audit.view"]), + queryKey: ["workflow", "tasks", "summary", "system"], + queryFn: () => fetchWorkflowTaskSummary({ scope: "system" }), + }); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); useEffect(() => { @@ -1281,6 +1347,15 @@ export function PortalHomePage() { {roleCapabilities.canApproveTasks ? ( ) : null} + {roleCapabilities.canApproveTasks ? ( + + ) : null} + {roleCapabilities.canManageSystem ? ( + + ) : null} {roleCapabilities.canUseFinance ? ( ) : null} diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index f645f315..b55c8599 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -32,6 +32,7 @@ import { fetchLegalEntities, fetchSuppliers, } from "../api/masterData"; +import { remindWorkflowTask } from "../api/workflow"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; @@ -201,7 +202,7 @@ function resolveStageAction(stageKey: PurchaseErpStageKey, stage: PurchaseReques export function PurchaseRequestsPage() { const queryClient = useQueryClient(); - const { permissions } = useAuth(); + const { me, permissions } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const canCreate = hasPermission(permissions, "procurement.create"); @@ -531,6 +532,24 @@ export function PurchaseRequestsPage() { }, }); + const remindMutation = useMutation({ + mutationFn: (taskId: string) => remindWorkflowTask(taskId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }), + queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), + queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }), + queryClient.invalidateQueries({ queryKey: ["notifications"] }), + ]); + toast.success("已催办当前审批人"); + }, + onError: (error) => { + const message = resolveError(error); + setFormError(message); + toast.error(message); + }, + }); + const archiveMutation = useMutation({ mutationFn: async () => { if (!selectedId) { @@ -698,6 +717,16 @@ export function PurchaseRequestsPage() { const canSubmit = Boolean(detail?.permissions.canSubmit); const canWithdraw = Boolean(detail?.permissions.canWithdraw); const canArchiveOrder = Boolean(detail?.permissions.canArchiveOrder && canManage); + const pendingTaskId = detail?.pendingWorkflow?.task?.id ?? null; + const isPurchaseParticipant = Boolean( + detail && me?.id && [detail.applicantId, detail.submitterId].includes(me.id), + ); + const canRemindPendingWorkflow = Boolean( + detail?.status === "reviewing" && + pendingTaskId && + isPurchaseParticipant && + detail?.pendingWorkflow?.task?.assigneeId !== me?.id, + ); return (
@@ -978,6 +1007,17 @@ export function PurchaseRequestsPage() { > {withdrawMutation.isPending ? "撤回中..." : "撤回"} + {canRemindPendingWorkflow && pendingTaskId ? ( + + ) : null}
diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 59ae9c54..d8b170bf 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -24,6 +24,7 @@ import { type ReimbursementItem, type ReimbursementStatus, } from "../api/reimbursements"; +import { remindWorkflowTask } from "../api/workflow"; import { bindInvoice, fetchBindableInvoices, @@ -109,6 +110,20 @@ function invoiceStatusLabel(status: string) { return INVOICE_STATUS_LABEL[status] ?? "未知状态"; } +function workflowActionLabel(action: string) { + const labels: Record = { + start: "提交", + approve: "通过", + reject: "驳回", + return: "退回", + transfer: "转审", + withdraw: "撤回", + remind: "催办", + overdue_remind: "超时提醒", + }; + return labels[action] ?? action; +} + function resolveErpSyncStatus( exportState: | { @@ -298,6 +313,23 @@ export function ReimbursementDetailPage() { }, }); + const remindMutation = useMutation({ + mutationFn: (taskId: string) => remindWorkflowTask(taskId), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + queryClient.invalidateQueries({ queryKey: ["notifications"] }), + queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }), + queryClient.invalidateQueries({ queryKey: ["workflow", "tasks", "summary"] }), + queryClient.invalidateQueries({ queryKey: ["portal-home"] }), + ]); + toast.success("已催办当前审批人"); + }, + onError: (error) => { + toast.error(buildActionErrorMessage(error, "催办失败,请稍后重试")); + }, + }); + const itemSaveMutation = useMutation({ mutationFn: async (data: ItemEditorSaveData) => { if (!reimbursementId) { @@ -766,6 +798,13 @@ export function ReimbursementDetailPage() { const canPay = hasPermission(permissions, "expense.finance.pay"); const canEditItems = ["draft", "rejected"].includes(detail.status); const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? ""); + const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null; + const canRemindPendingWorkflow = Boolean( + detail.status === "reviewing" && + pendingTaskId && + isReimbursementParticipant && + detail.pendingWorkflow?.task?.assignee_id !== me?.id, + ); const canOpenInvoiceBindDialog = canEditItems && isReimbursementParticipant && @@ -944,6 +983,17 @@ export function ReimbursementDetailPage() { + {canRemindPendingWorkflow && pendingTaskId ? ( + + ) : null} {canDeleteReimbursement ? (