统一业务列表 Ant Design 风格(05-31-unify-business-list-ant-design)
这个提交包含在:
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
|
||||
import { Wrench } from "lucide-react";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchMyDelegations } from "../api/delegations";
|
||||
@@ -25,7 +25,14 @@ 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 { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import {
|
||||
BusinessEmptyRow,
|
||||
BusinessEmptyState,
|
||||
BusinessListToolbar,
|
||||
BusinessMobileList,
|
||||
BusinessTable,
|
||||
} from "../components/patterns/BusinessListTemplate";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -49,32 +56,6 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
];
|
||||
|
||||
const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
|
||||
{
|
||||
value: "rejected",
|
||||
title: "被驳回待补正",
|
||||
icon: RotateCcw,
|
||||
accentClass: "border-red-200 bg-red-50 text-red-700",
|
||||
},
|
||||
{
|
||||
value: "draft",
|
||||
title: "草稿待完善",
|
||||
icon: FilePenLine,
|
||||
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
},
|
||||
{
|
||||
value: "reviewing",
|
||||
title: "审批中进度",
|
||||
icon: Clock3,
|
||||
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
|
||||
},
|
||||
] satisfies Array<{
|
||||
value: ReimbursementStatus;
|
||||
title: string;
|
||||
icon: typeof RotateCcw;
|
||||
accentClass: string;
|
||||
}>;
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||||
}
|
||||
@@ -198,21 +179,6 @@ export function ReimbursementsPage() {
|
||||
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"],
|
||||
@@ -251,11 +217,6 @@ export function ReimbursementsPage() {
|
||||
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);
|
||||
@@ -352,73 +313,22 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="flex flex-wrap gap-2" 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={`inline-flex min-h-10 items-center gap-2 rounded-full border px-3 py-1.5 text-sm transition hover:border-primary/40 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
|
||||
active ? "border-primary/40 bg-primary/5 text-primary" : "border-zinc-200 bg-white text-zinc-700"
|
||||
}`}
|
||||
data-testid={`reimbursements-task-card-${item.value}`}
|
||||
key={item.value}
|
||||
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
|
||||
<BusinessListToolbar
|
||||
actions={
|
||||
canShowLedgerEntry ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={showAdvancedTools ? "default" : "secondary"}
|
||||
onClick={() => setShowLedgerTools((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`flex size-7 items-center justify-center rounded-full border ${item.accentClass}`}>
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
</span>
|
||||
<span className="font-medium">{item.title}</span>
|
||||
<span className="font-semibold tabular-nums text-zinc-950">{displayCount}</span>
|
||||
</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">
|
||||
<Wrench aria-hidden className="mr-1 size-4" />
|
||||
台账工具
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
advanced={showAdvancedTools ? (
|
||||
<>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前列表</div>
|
||||
@@ -563,11 +473,28 @@ export function ReimbursementsPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</FilterBar>
|
||||
>
|
||||
<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>
|
||||
</BusinessListToolbar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
||||
|
||||
@@ -585,7 +512,7 @@ export function ReimbursementsPage() {
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<>
|
||||
<div className="space-y-3 md:hidden" data-testid="reimbursements-mobile-list">
|
||||
<BusinessMobileList testId="reimbursements-mobile-list">
|
||||
{items.length ? (
|
||||
items.map((item) => {
|
||||
const listAction = resolveListAction(item);
|
||||
@@ -669,12 +596,11 @@ export function ReimbursementsPage() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<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 ? (
|
||||
<BusinessEmptyState
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={status === "draft" && canCreateExpense ? (
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => startCreateReimbursement()}
|
||||
size="sm"
|
||||
@@ -683,12 +609,11 @@ export function ReimbursementsPage() {
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BusinessMobileList>
|
||||
|
||||
<PrimaryTable className="hidden md:block ops-table-focus">
|
||||
<table className="w-full border-collapse text-left" data-testid="reimbursements-table">
|
||||
<BusinessTable testId="reimbursements-table">
|
||||
<thead className="table-header">
|
||||
<tr>
|
||||
<th className="table-th">单号</th>
|
||||
@@ -783,29 +708,24 @@ export function ReimbursementsPage() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<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>
|
||||
<BusinessEmptyRow
|
||||
colSpan={6}
|
||||
title={emptyState.title}
|
||||
description={emptyState.description}
|
||||
action={status === "draft" && canCreateExpense ? (
|
||||
<Button
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => startCreateReimbursement()}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</Button>
|
||||
) : null}
|
||||
/>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrimaryTable>
|
||||
</BusinessTable>
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
|
||||
在新工单中引用
屏蔽一个用户