869 行
35 KiB
TypeScript
869 行
35 KiB
TypeScript
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";
|
||
import { isApiError } from "../api/types";
|
||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||
import { fetchMyDelegations } from "../api/delegations";
|
||
import { exportReimbursements } from "../api/exports";
|
||
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
|
||
import {
|
||
createReimbursement,
|
||
deleteReimbursement,
|
||
fetchLinkableTravelRequests,
|
||
fetchReimbursements,
|
||
type ReimbursementCreatePayload,
|
||
type ReimbursementListItem,
|
||
type ReimbursementStatus,
|
||
} from "../api/reimbursements";
|
||
import { useAuth } from "../auth/AuthContext";
|
||
import { hasPermission } from "../auth/permissions";
|
||
import { toast } from "sonner";
|
||
import { downloadBlob } from "../lib/download";
|
||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||
import { Pagination } from "../components/common/Pagination";
|
||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||
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 {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "../components/ui/dropdown-menu";
|
||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
type ExportFormat = "csv" | "xlsx";
|
||
|
||
const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||
{ value: "draft", label: "草稿" },
|
||
{ value: "rejected", label: "被驳回" },
|
||
{ value: "reviewing", label: "审批中" },
|
||
{ value: "approved", label: "已通过" },
|
||
{ value: "paid", label: "已支付" },
|
||
{ value: "cancelled", label: "已取消" },
|
||
];
|
||
|
||
const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
|
||
{
|
||
value: "rejected",
|
||
title: "被驳回待补正",
|
||
idleNote: "暂无需要补正的报销",
|
||
activeNote: "优先补齐说明或附件后重新提交",
|
||
icon: RotateCcw,
|
||
accentClass: "border-red-200 bg-red-50 text-red-700",
|
||
},
|
||
{
|
||
value: "draft",
|
||
title: "草稿待完善",
|
||
idleNote: "还没有报销草稿",
|
||
activeNote: "继续完善明细、发票和附件",
|
||
icon: FilePenLine,
|
||
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
|
||
},
|
||
{
|
||
value: "reviewing",
|
||
title: "审批中进度",
|
||
idleNote: "暂无审批中的报销",
|
||
activeNote: "查看当前节点和处理进度",
|
||
icon: Clock3,
|
||
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
|
||
},
|
||
] satisfies Array<{
|
||
value: ReimbursementStatus;
|
||
title: string;
|
||
idleNote: string;
|
||
activeNote: string;
|
||
icon: typeof RotateCcw;
|
||
accentClass: string;
|
||
}>;
|
||
|
||
function formatCurrency(value: number) {
|
||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||
}
|
||
|
||
function resolveErrorMessage(error: unknown, fallback: string) {
|
||
if (isApiError(error)) {
|
||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||
}
|
||
return error instanceof Error ? error.message : fallback;
|
||
}
|
||
|
||
function resolveEmptyStateCopy(status: ReimbursementStatus) {
|
||
if (status === "draft") {
|
||
return {
|
||
title: "还没有报销草稿",
|
||
description: "需要报销时先新建单据,再补充明细、发票和附件。",
|
||
};
|
||
}
|
||
if (status === "rejected") {
|
||
return {
|
||
title: "暂无需要补正的报销",
|
||
description: "后续有驳回单据时,会在这里继续编辑并重新提交。",
|
||
};
|
||
}
|
||
if (status === "reviewing") {
|
||
return {
|
||
title: "暂无审批中的报销",
|
||
description: "提交后的报销会在这里显示当前审批进度。",
|
||
};
|
||
}
|
||
if (status === "approved") {
|
||
return {
|
||
title: "暂无已通过记录",
|
||
description: "审批通过但未支付的报销会在这里留存。",
|
||
};
|
||
}
|
||
if (status === "paid") {
|
||
return {
|
||
title: "暂无已支付记录",
|
||
description: "财务支付完成后会在这里查看付款结果。",
|
||
};
|
||
}
|
||
return {
|
||
title: "暂无已取消记录",
|
||
description: "撤回或取消的报销记录会在这里留存。",
|
||
};
|
||
}
|
||
|
||
function resolveWorkflowHint(item: ReimbursementListItem) {
|
||
const stepName = item.pendingWorkflow?.task?.stepName;
|
||
if (!stepName) {
|
||
return null;
|
||
}
|
||
|
||
const actionableByCurrentUser = item.pendingWorkflow?.allowedActions.length ? true : false;
|
||
return {
|
||
label: `${actionableByCurrentUser ? "待你处理" : "当前节点"}:${stepName}`,
|
||
className: actionableByCurrentUser ? "text-amber-700" : "text-zinc-500",
|
||
};
|
||
}
|
||
|
||
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 [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
|
||
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
||
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
|
||
const [showLedgerTools, setShowLedgerTools] = useState(false);
|
||
|
||
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
|
||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||
const canUseFinanceTools =
|
||
hasPermission(permissions, "expense.finance.pay") ||
|
||
hasPermission(permissions, "payment.batch.manage") ||
|
||
hasPermission(permissions, "risk.review.manage") ||
|
||
hasPermission(permissions, "invoice.manage");
|
||
const isAdministration = (me?.roleCodes ?? []).includes("administration");
|
||
const showProxyOwnerSelector = canProxySubmit && isAdministration;
|
||
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
|
||
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || showProxyOwnerSelector);
|
||
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense;
|
||
|
||
const listQuery = useQuery({
|
||
queryKey: ["reimbursements", "list", status, page, scope],
|
||
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
|
||
});
|
||
|
||
const rejectedSummaryQuery = useQuery({
|
||
queryKey: ["reimbursements", "task-summary", "own", "rejected"],
|
||
queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }),
|
||
});
|
||
|
||
const draftSummaryQuery = useQuery({
|
||
queryKey: ["reimbursements", "task-summary", "own", "draft"],
|
||
queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }),
|
||
});
|
||
|
||
const reviewingSummaryQuery = useQuery({
|
||
queryKey: ["reimbursements", "task-summary", "own", "reviewing"],
|
||
queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }),
|
||
});
|
||
|
||
const proxyDelegationsQuery = useQuery({
|
||
enabled: showProxyOwnerSelector,
|
||
queryKey: ["delegations", "mine", "active"],
|
||
queryFn: () => fetchMyDelegations("active"),
|
||
});
|
||
|
||
const templateOptionsQuery = useQuery({
|
||
enabled: shouldLoadCreateOptions,
|
||
queryKey: ["reimbursement-templates", "enabled", "create-menu"],
|
||
queryFn: () => fetchReimbursementTemplates({ enabled: true }),
|
||
});
|
||
|
||
const linkableTravelRequestsQuery = useQuery({
|
||
enabled: shouldLoadCreateOptions,
|
||
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
|
||
queryFn: () =>
|
||
fetchLinkableTravelRequests({
|
||
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
||
}),
|
||
});
|
||
|
||
const items = listQuery.data?.items ?? [];
|
||
const stats = useMemo(() => {
|
||
return items.reduce(
|
||
(acc, item) => {
|
||
acc.currentPageCount += 1;
|
||
acc.currentPageAmount += Number(item.totalAmount ?? 0);
|
||
if (item.status === "reviewing") acc.reviewing += 1;
|
||
if (item.status === "paid") acc.paid += 1;
|
||
return acc;
|
||
},
|
||
{ currentPageCount: 0, currentPageAmount: 0, reviewing: 0, paid: 0 },
|
||
);
|
||
}, [items]);
|
||
|
||
const templateOptions = templateOptionsQuery.data?.items ?? [];
|
||
const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? [];
|
||
|
||
const taskSummaryTotalByStatus = {
|
||
rejected: rejectedSummaryQuery.data?.total,
|
||
draft: draftSummaryQuery.data?.total,
|
||
reviewing: reviewingSummaryQuery.data?.total,
|
||
} satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>;
|
||
const canShowCreateOptionsMenu =
|
||
canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0);
|
||
const emptyState = resolveEmptyStateCopy(status);
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (payload: ReimbursementCreatePayload) => createReimbursement(payload),
|
||
onSuccess: async (response, payload) => {
|
||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||
if (payload.linkedTravelRequestId) {
|
||
toast.success("已按差旅申请创建报销单");
|
||
} else if (payload.templateId) {
|
||
toast.success("已按模板创建报销单");
|
||
} else {
|
||
toast.success("报销单已创建");
|
||
}
|
||
navigate(`/reimbursements/${response.id}`);
|
||
},
|
||
onError: (error) => {
|
||
toast.error(resolveErrorMessage(error, "创建报销单失败"));
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
||
onSuccess: async () => {
|
||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||
toast.success("草稿已删除");
|
||
},
|
||
onError: (error) => {
|
||
toast.error(resolveErrorMessage(error, "删除草稿失败"));
|
||
},
|
||
});
|
||
|
||
const handleDeleteDraft = (item: ReimbursementListItem) => {
|
||
setDeleteTarget(item);
|
||
};
|
||
|
||
const exportMutation = useMutation({
|
||
mutationFn: (format: ExportFormat) => exportReimbursements({ format, status, scope }),
|
||
onSuccess: (response, format) => {
|
||
downloadBlob(response.blob, response.filename ?? `reimbursements.${format}`);
|
||
setExportError("");
|
||
toast.success("导出完成");
|
||
},
|
||
onError: (error) => {
|
||
const message = resolveErrorMessage(error, "导出失败");
|
||
setExportError(message);
|
||
toast.error(message);
|
||
},
|
||
});
|
||
|
||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||
|
||
function startCreateReimbursement(payload: Omit<ReimbursementCreatePayload, "ownerEmployeeId"> = {}) {
|
||
createMutation.mutate({
|
||
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
|
||
...payload,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<ListWorkspace>
|
||
<WorkspaceHeader
|
||
eyebrow="报销"
|
||
title={scope === "all" ? "报销台账" : "我的报销"}
|
||
actions={
|
||
canCreateExpense ? (
|
||
<Button
|
||
data-testid="reimbursements-create"
|
||
disabled={createMutation.isPending}
|
||
onClick={() => startCreateReimbursement()}
|
||
type="button"
|
||
>
|
||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||
</Button>
|
||
) : null
|
||
}
|
||
/>
|
||
|
||
{createMutation.error && isApiError(createMutation.error) ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{createMutation.error.code}:
|
||
{mapApiErrorMessage(
|
||
createMutation.error.code,
|
||
createMutation.error.details,
|
||
createMutation.error.message,
|
||
)}
|
||
</p>
|
||
) : null}
|
||
|
||
{exportError ? (
|
||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||
{exportError}
|
||
</p>
|
||
) : null}
|
||
|
||
<section className="grid gap-3 md:grid-cols-3" aria-label="我的报销任务">
|
||
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
|
||
const Icon = item.icon;
|
||
const count = taskSummaryTotalByStatus[item.value];
|
||
const active = status === item.value && scope === "own";
|
||
const displayCount = typeof count === "number" ? count : "-";
|
||
|
||
return (
|
||
<button
|
||
className={`card-std min-h-[124px] p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
|
||
active ? "border-primary/40 bg-primary/5" : ""
|
||
}`}
|
||
data-testid={`reimbursements-task-card-${item.value}`}
|
||
key={item.value}
|
||
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
|
||
type="button"
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<span className={`flex size-9 items-center justify-center rounded-lg border ${item.accentClass}`}>
|
||
<Icon aria-hidden className="size-4" />
|
||
</span>
|
||
<ArrowRight aria-hidden className="mt-1 size-4 text-zinc-300" />
|
||
</div>
|
||
<div className="mt-4 flex items-end justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
|
||
<div className="mt-1 text-xs leading-5 text-zinc-500">
|
||
{typeof count === "number" && count > 0 ? item.activeNote : item.idleNote}
|
||
</div>
|
||
</div>
|
||
<div className="text-3xl font-semibold tabular-nums text-zinc-950">{displayCount}</div>
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</section>
|
||
|
||
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
|
||
<div className="ops-command-bar__row">
|
||
<div className="min-w-0 space-y-2">
|
||
<div className="text-xs font-semibold text-zinc-500">报销记录</div>
|
||
<Tabs
|
||
data-testid="reimbursements-status-tabs"
|
||
value={status}
|
||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||
>
|
||
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
|
||
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
|
||
<TabsTrigger
|
||
data-testid={`reimbursements-tab-${item.value}`}
|
||
key={item.value}
|
||
value={item.value}
|
||
>
|
||
{item.label}
|
||
</TabsTrigger>
|
||
))}
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
|
||
<div className="ops-command-bar__actions">
|
||
{canShowLedgerEntry ? (
|
||
<Button
|
||
size="sm"
|
||
variant={showAdvancedTools ? "default" : "secondary"}
|
||
onClick={() => setShowLedgerTools((current) => !current)}
|
||
type="button"
|
||
>
|
||
<Wrench aria-hidden className="mr-1 size-4" />
|
||
台账工具
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{showAdvancedTools ? (
|
||
<div className="rounded-lg border border-slate-200 bg-slate-50/70 p-3">
|
||
<div className="grid gap-3 text-sm md:grid-cols-4">
|
||
<div>
|
||
<div className="text-xs text-zinc-500">当前列表</div>
|
||
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-zinc-500">当前金额</div>
|
||
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-zinc-500">审批中</div>
|
||
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-zinc-500">已支付</div>
|
||
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
{showProxyOwnerSelector ? (
|
||
<select
|
||
className="select-sm"
|
||
data-testid="reimbursements-owner-select"
|
||
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
|
||
value={proxyOwnerEmployeeId}
|
||
>
|
||
<option value="">自己</option>
|
||
{(proxyDelegationsQuery.data?.received ?? [])
|
||
.filter((item) => item.status === "active")
|
||
.map((item) => (
|
||
<option key={item.id} value={item.principalEmployeeId}>
|
||
{item.principal?.name ?? item.principalEmployeeId}
|
||
</option>
|
||
))}
|
||
</select>
|
||
) : null}
|
||
|
||
{canShowCreateOptionsMenu ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button size="sm" variant="secondary" type="button">
|
||
更多创建方式
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end" className="w-80">
|
||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
||
从模板创建
|
||
</div>
|
||
{templateOptions.length ? (
|
||
templateOptions.map((template) => (
|
||
<DropdownMenuItem
|
||
key={template.id}
|
||
onSelect={() => startCreateReimbursement({ templateId: template.id })}
|
||
>
|
||
{template.name}
|
||
</DropdownMenuItem>
|
||
))
|
||
) : (
|
||
<DropdownMenuItem disabled>当前无可用模板</DropdownMenuItem>
|
||
)}
|
||
|
||
<DropdownMenuSeparator />
|
||
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
|
||
关联差旅申请
|
||
</div>
|
||
{linkableTravelRequests.length ? (
|
||
linkableTravelRequests.map((travelRequest) => (
|
||
<DropdownMenuItem
|
||
key={travelRequest.id}
|
||
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
|
||
>
|
||
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
|
||
</DropdownMenuItem>
|
||
))
|
||
) : (
|
||
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
|
||
{canViewAll ? (
|
||
<>
|
||
<Button
|
||
data-testid="reimbursements-reviewing-ledger"
|
||
size="sm"
|
||
variant={status === "reviewing" && scope === "all" ? "default" : "secondary"}
|
||
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
|
||
type="button"
|
||
>
|
||
审批中台账
|
||
</Button>
|
||
<Button
|
||
data-testid="reimbursements-scope-own"
|
||
size="sm"
|
||
variant={scope === "own" ? "default" : "secondary"}
|
||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||
type="button"
|
||
>
|
||
仅自己
|
||
</Button>
|
||
<Button
|
||
data-testid="reimbursements-scope-all"
|
||
size="sm"
|
||
variant={scope === "all" ? "default" : "secondary"}
|
||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||
type="button"
|
||
>
|
||
全范围
|
||
</Button>
|
||
<Button asChild size="sm" variant="secondary" type="button">
|
||
<Link to="/reimbursements/dashboard">统计分析</Link>
|
||
</Button>
|
||
</>
|
||
) : null}
|
||
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
data-testid="reimbursements-export"
|
||
size="sm"
|
||
variant="secondary"
|
||
type="button"
|
||
disabled={exportMutation.isPending}
|
||
>
|
||
{exportMutation.isPending ? "导出中..." : "导出"}
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem
|
||
data-testid="reimbursements-export-csv"
|
||
onSelect={() => setExportConfirmFormat("csv")}
|
||
>
|
||
导出 CSV
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
data-testid="reimbursements-export-xlsx"
|
||
onSelect={() => setExportConfirmFormat("xlsx")}
|
||
>
|
||
导出 Excel
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</FilterBar>
|
||
|
||
{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">
|
||
{isApiError(listQuery.error)
|
||
? `${listQuery.error.code}: ${mapApiErrorMessage(
|
||
listQuery.error.code,
|
||
listQuery.error.details,
|
||
listQuery.error.message,
|
||
)}`
|
||
: "加载报销列表失败"}
|
||
</p>
|
||
) : null}
|
||
|
||
{!listQuery.isLoading && !listQuery.isError ? (
|
||
<>
|
||
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
|
||
{items.length ? (
|
||
items.map((item) => {
|
||
const listAction = resolveListAction(item);
|
||
const displayCode = resolveReimbursementDisplayCode(item);
|
||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||
const workflowHint = resolveWorkflowHint(item);
|
||
|
||
return (
|
||
<article
|
||
className="card-std space-y-3 p-4"
|
||
data-testid="reimbursement-mobile-card"
|
||
key={item.id}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-semibold text-zinc-900">
|
||
{displayCode}
|
||
</div>
|
||
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
|
||
</div>
|
||
<StatusBadge className="shrink-0" status={item.status} />
|
||
</div>
|
||
|
||
{item.project ? (
|
||
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||
项目:{item.project.projectNo} · {item.project.name}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||
<div>
|
||
<div className="text-xs text-zinc-500">金额</div>
|
||
<div className="font-semibold text-zinc-900">
|
||
{item.totalAmount != null
|
||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
|
||
Number(item.totalAmount),
|
||
)
|
||
: "-"}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-zinc-500">报销人</div>
|
||
<div className="truncate font-medium text-zinc-900">{item.owner?.name ?? "-"}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{workflowHint ? (
|
||
<div className={`rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs ${workflowHint.className}`}>
|
||
{workflowHint.label}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<Button
|
||
asChild
|
||
className="min-h-11 flex-1"
|
||
size="sm"
|
||
variant={listAction.primary ? "default" : "secondary"}
|
||
>
|
||
<Link
|
||
to={listAction.to}
|
||
data-testid="reimbursement-mobile-detail-link"
|
||
>
|
||
{listAction.mobileLabel}
|
||
</Link>
|
||
</Button>
|
||
{canDeleteDraft ? (
|
||
<Button
|
||
className="min-h-11"
|
||
disabled={deleteMutation.isPending}
|
||
onClick={() => handleDeleteDraft(item)}
|
||
size="sm"
|
||
type="button"
|
||
variant="destructive"
|
||
>
|
||
删除
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</article>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
|
||
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
|
||
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
|
||
{status === "draft" && canCreateExpense ? (
|
||
<Button
|
||
className="mt-3"
|
||
disabled={createMutation.isPending}
|
||
onClick={() => startCreateReimbursement()}
|
||
size="sm"
|
||
type="button"
|
||
>
|
||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<PrimaryTable className="hidden md:block ops-table-focus">
|
||
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||
<thead className="table-header">
|
||
<tr>
|
||
<th className="table-th">单号</th>
|
||
<th className="table-th">状态</th>
|
||
<th className="table-th">金额</th>
|
||
<th className="table-th">报销人</th>
|
||
<th className="table-th">日期</th>
|
||
<th className="table-th w-24">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-zinc-50">
|
||
{items.length ? (
|
||
items.map((item) => {
|
||
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}>
|
||
<td className="table-td">
|
||
<div className="font-semibold text-zinc-900">{displayCode}</div>
|
||
{item.project ? (
|
||
<div className="text-xs text-zinc-500">
|
||
项目:{item.project.projectNo} · {item.project.name}
|
||
</div>
|
||
) : null}
|
||
{workflowHint ? (
|
||
<div className={`text-xs ${workflowHint.className}`}>
|
||
{workflowHint.label}
|
||
</div>
|
||
) : null}
|
||
</td>
|
||
<td className="table-td">
|
||
<StatusBadge status={item.status} />
|
||
</td>
|
||
<td className="table-td-strong">
|
||
{item.totalAmount != null
|
||
? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(
|
||
Number(item.totalAmount),
|
||
)
|
||
: "-"}
|
||
</td>
|
||
<td className="table-td">
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex size-6 items-center justify-center rounded-full bg-zinc-100 text-[10px] font-bold uppercase text-zinc-700">
|
||
{(item.owner?.name ?? "U").charAt(0)}
|
||
</div>
|
||
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||
</div>
|
||
</td>
|
||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||
<td className="table-td">
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
asChild
|
||
size="sm"
|
||
variant={listAction.primary ? "default" : "secondary"}
|
||
>
|
||
<Link
|
||
to={listAction.to}
|
||
data-testid="reimbursement-detail-link"
|
||
>
|
||
{listAction.label}
|
||
</Link>
|
||
</Button>
|
||
{canDeleteDraft ? (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
disabled={deleteMutation.isPending}
|
||
size="sm"
|
||
type="button"
|
||
variant="secondary"
|
||
>
|
||
更多
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem
|
||
className="text-red-600"
|
||
onSelect={() => handleDeleteDraft(item)}
|
||
>
|
||
删除草稿
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
) : (
|
||
<tr>
|
||
<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>
|
||
{status === "draft" && canCreateExpense ? (
|
||
<Button
|
||
className="mt-3"
|
||
disabled={createMutation.isPending}
|
||
onClick={() => startCreateReimbursement()}
|
||
size="sm"
|
||
type="button"
|
||
>
|
||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</PrimaryTable>
|
||
|
||
<div className="mt-4">
|
||
<Pagination
|
||
page={page}
|
||
totalPages={totalPages}
|
||
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
|
||
testIdPrefix="reimbursements"
|
||
/>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
|
||
<ActionConfirmDialog
|
||
open={Boolean(deleteTarget)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setDeleteTarget(null);
|
||
}}
|
||
kind="delete"
|
||
targetName={deleteTarget ? resolveReimbursementDisplayCode(deleteTarget) : undefined}
|
||
description="删除后无法恢复,已提交或审批中的报销单不会受到影响。"
|
||
onConfirm={async () => {
|
||
if (deleteTarget) {
|
||
deleteMutation.mutate(deleteTarget.id);
|
||
}
|
||
}}
|
||
/>
|
||
<ActionConfirmDialog
|
||
open={Boolean(exportConfirmFormat)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setExportConfirmFormat(null);
|
||
}}
|
||
kind="sensitiveExport"
|
||
targetName={exportConfirmFormat === "xlsx" ? "报销 Excel" : "报销 CSV"}
|
||
onConfirm={async () => {
|
||
if (exportConfirmFormat) {
|
||
exportMutation.mutate(exportConfirmFormat);
|
||
}
|
||
}}
|
||
/>
|
||
</ListWorkspace>
|
||
);
|
||
}
|