收口导航与报销审批入口(05-30-nav-reimbursement-approval-surface)
这个提交包含在:
@@ -26,7 +26,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/assets", label: "资产", icon: "inventory_2", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
|
||||
{ to: "/weekly-reports", label: "周报", icon: "article" },
|
||||
{ to: "/announcements", label: "公告", icon: "campaign" },
|
||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_all", "project.manage"] },
|
||||
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
||||
{ to: "/system/settings", label: "管理", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage", "system.audit.view"] },
|
||||
{ to: "/workflow/tasks", label: "待我处理", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/payment-batches", "/ap-operations", "/invoice-pool", "/projects", "/team", "/leave-requests"];
|
||||
export const MAIN_NAV_ORDER = ["/portal", "/workflow/tasks", "/reimbursements", "/purchase-requests", "/contracts-registry", "/projects", "/assets", "/payment-batches", "/ap-operations", "/invoice-pool", "/team", "/leave-requests"];
|
||||
export const ADMIN_NAV_ORDER = ["/system/settings"];
|
||||
export const BACKUP_NAV_ORDER = ["/assets", "/weekly-reports", "/announcements", "/purchase-requests", "/contracts-registry", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"];
|
||||
export const BACKUP_NAV_ORDER = ["/weekly-reports", "/announcements", "/budgets", "/attendance", "/wecom-approval-archive", "/travel-requests", "/inventory-checks", "/dashboard"];
|
||||
|
||||
const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
|
||||
{ path: "/portal", title: "工作台", group: "工作台" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
|
||||
@@ -16,11 +16,6 @@ import {
|
||||
type ReimbursementListItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
import {
|
||||
batchApproveWorkflowTasks,
|
||||
batchRejectWorkflowTasks,
|
||||
type WorkflowBatchActionResponse,
|
||||
} from "../api/workflow";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
@@ -32,14 +27,6 @@ import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -48,24 +35,11 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../components/ui/dropdown-menu";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
type BatchAction = "approve" | "reject";
|
||||
type ExportFormat = "csv" | "xlsx";
|
||||
|
||||
type BatchResultState = {
|
||||
action: BatchAction;
|
||||
summary: WorkflowBatchActionResponse["summary"];
|
||||
results: Array<
|
||||
WorkflowBatchActionResponse["results"][number] & {
|
||||
reimbursementId: string | null;
|
||||
formNumber: string | null;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "rejected", label: "被驳回" },
|
||||
@@ -120,22 +94,6 @@ function resolveErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function formatBatchOutcome(action: BatchAction, outcome: string) {
|
||||
if (outcome === "approved") {
|
||||
return "已通过";
|
||||
}
|
||||
if (outcome === "rejected") {
|
||||
return "已驳回";
|
||||
}
|
||||
if (outcome === "skipped") {
|
||||
return "已跳过";
|
||||
}
|
||||
if (outcome === "error") {
|
||||
return "失败";
|
||||
}
|
||||
return action === "approve" ? "已通过" : "已驳回";
|
||||
}
|
||||
|
||||
function resolveEmptyStateCopy(status: ReimbursementStatus) {
|
||||
if (status === "draft") {
|
||||
return {
|
||||
@@ -186,19 +144,41 @@ function resolveWorkflowHint(item: ReimbursementListItem) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveListAction(item: ReimbursementListItem) {
|
||||
if (["draft", "rejected"].includes(item.status)) {
|
||||
return {
|
||||
label: "编辑",
|
||||
mobileLabel: "继续编辑",
|
||||
to: `/reimbursements/${item.id}`,
|
||||
primary: true,
|
||||
};
|
||||
}
|
||||
|
||||
const taskId = item.pendingWorkflow?.task?.id;
|
||||
if (taskId && item.pendingWorkflow?.allowedActions.length) {
|
||||
return {
|
||||
label: "查看处理",
|
||||
mobileLabel: "查看处理",
|
||||
to: `/workflow/tasks/${taskId}`,
|
||||
primary: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: item.status === "reviewing" ? "查看进度" : "详情",
|
||||
mobileLabel: item.status === "reviewing" ? "查看进度" : "查看详情",
|
||||
to: `/reimbursements/${item.id}`,
|
||||
primary: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function ReimbursementsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { permissions, me } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [exportError, setExportError] = useState("");
|
||||
const [batchError, setBatchError] = useState("");
|
||||
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [selectedReimbursementIds, setSelectedReimbursementIds] = useState<string[]>([]);
|
||||
const [batchDialogAction, setBatchDialogAction] = useState<BatchAction | null>(null);
|
||||
const [batchComment, setBatchComment] = useState("");
|
||||
const [batchResult, setBatchResult] = useState<BatchResultState | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
||||
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
|
||||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||||
@@ -209,7 +189,6 @@ export function ReimbursementsPage() {
|
||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||
const canUseFinanceTools =
|
||||
hasPermission(permissions, "expense.finance.pay") ||
|
||||
@@ -219,7 +198,7 @@ export function ReimbursementsPage() {
|
||||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||||
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
|
||||
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector);
|
||||
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || showProxyOwnerSelector);
|
||||
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense;
|
||||
|
||||
const listQuery = useQuery({
|
||||
@@ -280,24 +259,6 @@ export function ReimbursementsPage() {
|
||||
const templateOptions = templateOptionsQuery.data?.items ?? [];
|
||||
const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? [];
|
||||
|
||||
const actionableItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(item) =>
|
||||
Boolean(item.pendingWorkflow?.task?.id) &&
|
||||
Boolean(item.pendingWorkflow?.allowedActions.includes("approve")),
|
||||
),
|
||||
[items],
|
||||
);
|
||||
const actionableIdSet = useMemo(
|
||||
() => new Set(actionableItems.map((item) => item.id)),
|
||||
[actionableItems],
|
||||
);
|
||||
const canShowBatchEntry =
|
||||
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
|
||||
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
|
||||
const allActionableSelected =
|
||||
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
|
||||
const taskSummaryTotalByStatus = {
|
||||
rejected: rejectedSummaryQuery.data?.total,
|
||||
draft: draftSummaryQuery.data?.total,
|
||||
@@ -307,14 +268,6 @@ export function ReimbursementsPage() {
|
||||
canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0);
|
||||
const emptyState = resolveEmptyStateCopy(status);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id)));
|
||||
if (!canShowBatchEntry) {
|
||||
setBatchMode(false);
|
||||
setBatchDialogAction(null);
|
||||
}
|
||||
}, [actionableIdSet, canShowBatchEntry]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: ReimbursementCreatePayload) => createReimbursement(payload),
|
||||
onSuccess: async (response, payload) => {
|
||||
@@ -362,121 +315,8 @@ export function ReimbursementsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const batchMutation = useMutation({
|
||||
mutationFn: async (input: { action: BatchAction; comment: string; selectedItems: typeof actionableItems }) => {
|
||||
const taskIds = input.selectedItems
|
||||
.map((item) => item.pendingWorkflow?.task?.id ?? null)
|
||||
.filter((taskId): taskId is string => Boolean(taskId));
|
||||
|
||||
if (!taskIds.length) {
|
||||
throw new Error("请先选择至少一条可处理的报销待办。");
|
||||
}
|
||||
|
||||
const response =
|
||||
input.action === "approve"
|
||||
? await batchApproveWorkflowTasks({
|
||||
taskIds,
|
||||
comment: input.comment.trim() || undefined,
|
||||
})
|
||||
: await batchRejectWorkflowTasks({
|
||||
taskIds,
|
||||
comment: input.comment.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
action: input.action,
|
||||
response,
|
||||
selectedItems: input.selectedItems.map((item) => ({
|
||||
reimbursementId: item.id,
|
||||
formNumber: item.formNumber,
|
||||
taskId: item.pendingWorkflow?.task?.id ?? null,
|
||||
})),
|
||||
};
|
||||
},
|
||||
onSuccess: async (payload) => {
|
||||
const formByTaskId = new Map(
|
||||
payload.selectedItems
|
||||
.filter((item) => item.taskId)
|
||||
.map((item) => [item.taskId!, item]),
|
||||
);
|
||||
|
||||
setBatchResult({
|
||||
action: payload.action,
|
||||
summary: payload.response.summary,
|
||||
results: payload.response.results.map((item) => {
|
||||
const related = formByTaskId.get(item.taskId);
|
||||
return {
|
||||
...item,
|
||||
reimbursementId: related?.reimbursementId ?? null,
|
||||
formNumber: related?.formNumber ?? null,
|
||||
};
|
||||
}),
|
||||
});
|
||||
setBatchDialogAction(null);
|
||||
setBatchComment("");
|
||||
setBatchMode(false);
|
||||
setSelectedReimbursementIds([]);
|
||||
setBatchError("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
toast.success(payload.action === "approve" ? "批量通过已执行" : "批量驳回已执行");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveErrorMessage(error, "批量处理失败");
|
||||
setBatchError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
function toggleBatchSelection(reimbursementId: string) {
|
||||
setSelectedReimbursementIds((current) =>
|
||||
current.includes(reimbursementId)
|
||||
? current.filter((id) => id !== reimbursementId)
|
||||
: [...current, reimbursementId],
|
||||
);
|
||||
}
|
||||
|
||||
function toggleSelectAllActionable() {
|
||||
if (allActionableSelected) {
|
||||
setSelectedReimbursementIds([]);
|
||||
return;
|
||||
}
|
||||
setSelectedReimbursementIds(actionableItems.map((item) => item.id));
|
||||
}
|
||||
|
||||
function openBatchDialog(action: BatchAction) {
|
||||
if (!selectedItems.length) {
|
||||
setBatchError("请先选择至少一条可批量处理的报销待办。");
|
||||
return;
|
||||
}
|
||||
setBatchError("");
|
||||
setBatchDialogAction(action);
|
||||
}
|
||||
|
||||
function handleCloseBatchDialog(open: boolean) {
|
||||
if (!open) {
|
||||
setBatchDialogAction(null);
|
||||
setBatchComment("");
|
||||
}
|
||||
}
|
||||
|
||||
function submitBatchAction() {
|
||||
if (!batchDialogAction) {
|
||||
return;
|
||||
}
|
||||
if (batchDialogAction === "reject" && !batchComment.trim()) {
|
||||
setBatchError("批量驳回必须填写统一理由。");
|
||||
return;
|
||||
}
|
||||
|
||||
batchMutation.mutate({
|
||||
action: batchDialogAction,
|
||||
comment: batchComment,
|
||||
selectedItems,
|
||||
});
|
||||
}
|
||||
|
||||
function startCreateReimbursement(payload: Omit<ReimbursementCreatePayload, "ownerEmployeeId"> = {}) {
|
||||
createMutation.mutate({
|
||||
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
||||
@@ -520,12 +360,6 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{batchError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{batchError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 md:grid-cols-3" aria-label="我的报销任务">
|
||||
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@@ -748,34 +582,12 @@ export function ReimbursementsPage() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{canShowBatchEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={batchMode ? "default" : "secondary"}
|
||||
onClick={() => {
|
||||
setBatchMode((current) => !current);
|
||||
setSelectedReimbursementIds([]);
|
||||
setBatchDialogAction(null);
|
||||
setBatchComment("");
|
||||
setBatchError("");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{batchMode ? "退出批量模式" : "批量操作"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{batchMode ? (
|
||||
<div className="ops-inline-alert border-amber-200 bg-amber-50 text-amber-800">
|
||||
仅勾选你可处理的审批中报销;驳回需填写理由。
|
||||
</div>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={batchMode ? 7 : 6} rows={5} /> : null}
|
||||
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
||||
|
||||
{listQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
@@ -794,10 +606,7 @@ export function ReimbursementsPage() {
|
||||
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const canSelectRow = Boolean(
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
||||
const listAction = resolveListAction(item);
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
const workflowHint = resolveWorkflowHint(item);
|
||||
@@ -848,29 +657,17 @@ export function ReimbursementsPage() {
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{batchMode ? (
|
||||
<label className="flex min-h-10 items-center gap-2 rounded-lg border border-zinc-200 px-3 text-sm text-zinc-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
disabled={!canSelectRow}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${displayCode}`}
|
||||
/>
|
||||
选择
|
||||
</label>
|
||||
) : null}
|
||||
<Button
|
||||
asChild
|
||||
className="min-h-11 flex-1"
|
||||
size="sm"
|
||||
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
||||
variant={listAction.primary ? "default" : "secondary"}
|
||||
>
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
to={listAction.to}
|
||||
data-testid="reimbursement-mobile-detail-link"
|
||||
>
|
||||
{actionLabel}
|
||||
{listAction.mobileLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteDraft ? (
|
||||
@@ -912,7 +709,6 @@ export function ReimbursementsPage() {
|
||||
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
{batchMode ? <th className="table-th w-14">选择</th> : null}
|
||||
<th className="table-th">单号</th>
|
||||
<th className="table-th">状态</th>
|
||||
<th className="table-th">金额</th>
|
||||
@@ -924,29 +720,13 @@ export function ReimbursementsPage() {
|
||||
<tbody className="divide-y divide-zinc-50">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const canSelectRow = Boolean(
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
const listAction = resolveListAction(item);
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
const workflowHint = resolveWorkflowHint(item);
|
||||
|
||||
return (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
{batchMode ? (
|
||||
<td className="table-td">
|
||||
{canSelectRow ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${displayCode}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
) : null}
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{displayCode}</div>
|
||||
{item.project ? (
|
||||
@@ -984,13 +764,13 @@ export function ReimbursementsPage() {
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant={["draft", "rejected"].includes(item.status) ? "default" : "secondary"}
|
||||
variant={listAction.primary ? "default" : "secondary"}
|
||||
>
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
to={listAction.to}
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||
{listAction.label}
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteDraft ? (
|
||||
@@ -1022,7 +802,7 @@ export function ReimbursementsPage() {
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
<div className="mx-auto max-w-sm py-3">
|
||||
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
|
||||
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
|
||||
@@ -1056,118 +836,6 @@ export function ReimbursementsPage() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{batchMode ? (
|
||||
<div className="fixed bottom-6 left-1/2 z-40 w-[min(760px,calc(100vw-2rem))] -translate-x-1/2">
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-3.5 shadow-lg">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-zinc-700">已选 {selectedItems.length} 项可处理报销待办</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="secondary" onClick={toggleSelectAllActionable}>
|
||||
{allActionableSelected ? "取消全选" : "全选可处理项"}
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => openBatchDialog("approve")}>
|
||||
批量通过
|
||||
</Button>
|
||||
<Button type="button" variant="destructive" onClick={() => openBatchDialog("reject")}>
|
||||
批量驳回
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Dialog open={Boolean(batchDialogAction)} onOpenChange={handleCloseBatchDialog}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{batchDialogAction === "approve" ? "批量通过报销待办" : "批量驳回报销待办"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{batchDialogAction === "approve"
|
||||
? "统一意见可空。所有选中待办会按当前顺序逐条调用在线审批接口。"
|
||||
: "驳回必须填写统一理由,所有选中待办将复用这条说明。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3.5 text-sm text-zinc-700">
|
||||
共 {selectedItems.length} 项:
|
||||
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
|
||||
{selectedItems.map((item) => (
|
||||
<div key={item.id}>{resolveReimbursementDisplayCode(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-900" htmlFor="reimbursements-batch-comment">
|
||||
{batchDialogAction === "reject" ? "统一驳回理由" : "统一审批意见"}
|
||||
</label>
|
||||
<Textarea
|
||||
id="reimbursements-batch-comment"
|
||||
value={batchComment}
|
||||
onChange={(event) => setBatchComment(event.target.value)}
|
||||
placeholder={batchDialogAction === "reject" ? "请输入批量驳回理由" : "可选填写统一审批意见"}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" type="button" onClick={() => handleCloseBatchDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={batchDialogAction === "reject" ? "destructive" : "default"}
|
||||
disabled={batchMutation.isPending || (batchDialogAction === "reject" && !batchComment.trim())}
|
||||
onClick={submitBatchAction}
|
||||
>
|
||||
{batchMutation.isPending
|
||||
? "处理中..."
|
||||
: batchDialogAction === "approve"
|
||||
? "确认批量通过"
|
||||
: "确认批量驳回"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={Boolean(batchResult)} onOpenChange={(open) => !open && setBatchResult(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{batchResult?.action === "approve" ? "批量通过结果" : "批量驳回结果"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{batchResult
|
||||
? `成功 ${batchResult.summary.approved ?? 0},驳回 ${batchResult.summary.rejected ?? 0},跳过 ${batchResult.summary.skipped},失败 ${batchResult.summary.errored}`
|
||||
: "查看批量操作逐条执行结果。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[60vh] space-y-2 overflow-auto rounded-lg border border-zinc-200 bg-zinc-50 p-3.5">
|
||||
{batchResult?.results.map((item) => (
|
||||
<div key={`${item.taskId}-${item.outcome}`} className="rounded-lg border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-zinc-700">
|
||||
{formatBatchOutcome(batchResult.action, item.outcome)}
|
||||
</div>
|
||||
</div>
|
||||
{item.reason ? <div className="mt-2 text-sm text-red-600">{item.reason}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setBatchResult(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ActionConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户