AP6 增加审批催办与效率摘要
这个提交包含在:
@@ -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" },
|
||||
|
||||
@@ -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)}
|
||||
</p>
|
||||
) : null}
|
||||
{workflowSummaryQuery.data ? (
|
||||
<div className="mb-3 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.pendingTotal}</div>
|
||||
<div className="mt-1 text-zinc-500">待办</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className={workflowSummaryQuery.data.overdueTotal > 0 ? "font-semibold text-amber-700" : "font-semibold text-zinc-900"}>
|
||||
{workflowSummaryQuery.data.overdueTotal}
|
||||
</div>
|
||||
<div className="mt-1 text-zinc-500">超时</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-2 py-2">
|
||||
<div className="font-semibold text-zinc-900">{workflowSummaryQuery.data.byEntityType.length}</div>
|
||||
<div className="mt-1 text-zinc-500">类型</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!pendingCountQuery.isLoading && !pendingTasks.length ? (
|
||||
<p className="text-sm text-zinc-500">没有待处理审批。</p>
|
||||
) : null}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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 ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||
</section>
|
||||
|
||||
{detail.status === "reviewing" || detail.approvalHistory.length ? (
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">审批</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={remindMutation.isPending}
|
||||
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||||
>
|
||||
{remindMutation.isPending ? "催办中..." : "催办"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{detail.approvalHistory.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.approvalHistory.map((item) => (
|
||||
<article key={item.id} className="mobile-list-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
|
||||
<div className="shrink-0 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||
</div>
|
||||
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-items">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
|
||||
|
||||
@@ -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 <SummaryPanel title="财务待处理" items={items} />;
|
||||
}
|
||||
|
||||
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 <SummaryPanel title={title} items={items} />;
|
||||
}
|
||||
|
||||
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 ? (
|
||||
<SummaryPanel title="审批处理" items={approvalSummaryItems} />
|
||||
) : null}
|
||||
{roleCapabilities.canApproveTasks ? (
|
||||
<ApprovalProductivityPanel summary={workflowSummaryQuery.data ?? null} />
|
||||
) : null}
|
||||
{roleCapabilities.canManageSystem ? (
|
||||
<ApprovalProductivityPanel
|
||||
title="全局审批效率摘要"
|
||||
summary={workflowSystemSummaryQuery.data ?? null}
|
||||
/>
|
||||
) : null}
|
||||
{roleCapabilities.canUseFinance ? (
|
||||
<FinanceSummaryPanel summary={payload.financeSummary ?? null} />
|
||||
) : null}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -978,6 +1007,17 @@ export function PurchaseRequestsPage() {
|
||||
>
|
||||
{withdrawMutation.isPending ? "撤回中..." : "撤回"}
|
||||
</Button>
|
||||
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||
<Button
|
||||
data-testid="purchase-remind-workflow"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={remindMutation.isPending}
|
||||
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||||
>
|
||||
{remindMutation.isPending ? "催办中..." : "催办审批"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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() {
|
||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||
<Link to="/reimbursements">返回列表</Link>
|
||||
</Button>
|
||||
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||
<Button
|
||||
data-testid="reimbursement-remind-workflow"
|
||||
disabled={remindMutation.isPending}
|
||||
onClick={() => remindMutation.mutate(pendingTaskId)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
{remindMutation.isPending ? "催办中..." : "催办审批"}
|
||||
</Button>
|
||||
) : null}
|
||||
{canDeleteReimbursement ? (
|
||||
<Button
|
||||
data-testid="reimbursement-delete-draft"
|
||||
@@ -1540,6 +1590,27 @@ export function ReimbursementDetailPage() {
|
||||
</DetailSideMeta>
|
||||
) : null}
|
||||
|
||||
<DetailSideMeta title="审批记录">
|
||||
{detail.approvalHistory.length ? (
|
||||
<div className="space-y-3" data-testid="reimbursement-approval-history">
|
||||
{detail.approvalHistory.map((item) => (
|
||||
<div key={item.id} className="rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
|
||||
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||
</div>
|
||||
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">暂无审批记录。</div>
|
||||
)}
|
||||
</DetailSideMeta>
|
||||
|
||||
<ReimbursementActions
|
||||
status={detail.status}
|
||||
canPay={canPay}
|
||||
|
||||
@@ -93,6 +93,8 @@ function workflowActionLabel(action: string | undefined | null) {
|
||||
return: "退回",
|
||||
transfer: "转审",
|
||||
withdraw: "撤回",
|
||||
remind: "催办",
|
||||
overdue_remind: "超时提醒",
|
||||
cancel: "取消",
|
||||
skip: "跳过",
|
||||
};
|
||||
|
||||
@@ -3,7 +3,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { approveWorkflowTask, fetchWorkflowTasks, type WorkflowTaskSummary } from "../api/workflow";
|
||||
import {
|
||||
approveWorkflowTask,
|
||||
fetchWorkflowTasks,
|
||||
fetchWorkflowTaskSummary,
|
||||
type WorkflowTaskSummary,
|
||||
} from "../api/workflow";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -103,6 +108,13 @@ function displayMetric(task: WorkflowTaskSummary) {
|
||||
return task.businessSummary?.amount ?? task.businessSummary?.duration ?? "-";
|
||||
}
|
||||
|
||||
function formatSummaryTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function isAlreadyProcessedTaskError(error: unknown) {
|
||||
return isApiError(error) && error.details?.task === "already_processed";
|
||||
}
|
||||
@@ -143,6 +155,11 @@ export function WorkflowTasksPage() {
|
||||
}),
|
||||
});
|
||||
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ["workflow", "tasks", "summary", "mine"],
|
||||
queryFn: () => fetchWorkflowTaskSummary(),
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
|
||||
onSuccess: async () => {
|
||||
@@ -173,6 +190,30 @@ export function WorkflowTasksPage() {
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<PageHeader title="审批中心" subtitle="集中处理待办、查看已办和业务审批历史。" />
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4" data-testid="workflow-task-summary">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="text-xs text-zinc-500">待办总数</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-zinc-900">{summaryQuery.data?.pendingTotal ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="text-xs text-zinc-500">超时待办</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-amber-700">{summaryQuery.data?.overdueTotal ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="text-xs text-zinc-500">最早待处理</div>
|
||||
<div className="mt-2 text-sm font-medium text-zinc-900">{formatSummaryTime(summaryQuery.data?.earliestPendingAt)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="text-xs text-zinc-500">业务分布</div>
|
||||
<div className="mt-2 truncate text-sm font-medium text-zinc-900">
|
||||
{(summaryQuery.data?.byEntityType ?? [])
|
||||
.filter((item) => item.pendingTotal > 0)
|
||||
.map((item) => `${entityLabel(item.key)} ${item.pendingTotal}`)
|
||||
.join(" / ") || "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<Tabs
|
||||
|
||||
在新工单中引用
屏蔽一个用户