feat(client): 报销页精修与 testid(E5)
- 报销列表使用 Tabs/Table 收敛展示 - 报销详情动作区(submit/withdraw/pay)统一组件与错误展示 - 支付凭证字段补齐 data-testid 供 E2E
这个提交包含在:
@@ -6,6 +6,10 @@ import { createReimbursement, fetchReimbursements, type ReimbursementStatus } fr
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Card } from "../components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -39,14 +43,14 @@ export function ReimbursementsPage() {
|
||||
title="我的报销"
|
||||
subtitle="报销列表、详情与提交/撤回/支付动作入口。"
|
||||
actions={
|
||||
<button
|
||||
className="btn-primary"
|
||||
<Button
|
||||
data-testid="reimbursements-create"
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -61,43 +65,52 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="card-std space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "paid", label: "已支付" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
].map((item) => (
|
||||
<button
|
||||
className={status === item.value ? "btn-primary" : "btn-secondary"}
|
||||
key={item.value}
|
||||
onClick={() => setSearchParams({ status: item.value, page: "1", scope })}
|
||||
type="button"
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<Card className="space-y-4 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Tabs
|
||||
data-testid="reimbursements-status-tabs"
|
||||
value={status}
|
||||
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
|
||||
>
|
||||
<TabsList className="flex w-full flex-wrap justify-start gap-1 md:w-auto">
|
||||
{[
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "paid", label: "已支付" },
|
||||
{ value: "rejected", label: "已驳回" },
|
||||
{ value: "cancelled", label: "已取消" },
|
||||
].map((item) => (
|
||||
<TabsTrigger
|
||||
data-testid={`reimbursements-tab-${item.value}`}
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{canViewAll ? (
|
||||
<>
|
||||
<button
|
||||
className={scope === "own" ? "btn-primary" : "btn-secondary"}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
data-testid="reimbursements-scope-own"
|
||||
variant={scope === "own" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "own" })}
|
||||
type="button"
|
||||
>
|
||||
仅自己
|
||||
</button>
|
||||
<button
|
||||
className={scope === "all" ? "btn-primary" : "btn-secondary"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-all"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</button>
|
||||
</>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -116,46 +129,51 @@ export function ReimbursementsPage() {
|
||||
) : null}
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-slate-50 text-slate-600">
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<Table data-testid="reimbursements-table">
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<th className="px-3 py-2">单号</th>
|
||||
<th className="px-3 py-2">状态</th>
|
||||
<th className="px-3 py-2">金额</th>
|
||||
<th className="px-3 py-2">报销人</th>
|
||||
<th className="px-3 py-2">日期</th>
|
||||
<th className="px-3 py-2">操作</th>
|
||||
<TableHead>单号</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>报销人</TableHead>
|
||||
<TableHead>日期</TableHead>
|
||||
<TableHead className="w-24">操作</TableHead>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(listQuery.data?.items ?? []).length ? (
|
||||
listQuery.data?.items.map((item) => (
|
||||
<tr className="border-t border-slate-100" key={item.id}>
|
||||
<td className="px-3 py-2">
|
||||
<TableRow data-testid="reimbursement-row" key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-slate-900">{item.formNumber ?? item.id}</div>
|
||||
<div className="text-xs text-slate-500">{item.id}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.status}</td>
|
||||
<td className="px-3 py-2">{item.totalAmount ?? "-"}</td>
|
||||
<td className="px-3 py-2">{item.owner?.name ?? "-"}</td>
|
||||
<td className="px-3 py-2">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Link className="btn-secondary" to={`/reimbursements/${item.id}`}>
|
||||
详情
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>{item.totalAmount ?? "-"}</TableCell>
|
||||
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
||||
<TableCell>{item.reimbursementDate ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
<Link to={`/reimbursements/${item.id}`}>详情</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="px-3 py-3 text-slate-500" colSpan={6}>
|
||||
<TableRow>
|
||||
<TableCell className="py-3 text-slate-500" colSpan={6}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -164,8 +182,9 @@ export function ReimbursementsPage() {
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn-secondary"
|
||||
<Button
|
||||
data-testid="reimbursements-prev-page"
|
||||
variant="secondary"
|
||||
disabled={page <= 1}
|
||||
onClick={() =>
|
||||
setSearchParams({ status, scope, page: String(Math.max(1, page - 1)) })
|
||||
@@ -173,9 +192,10 @@ export function ReimbursementsPage() {
|
||||
type="button"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
className="btn-secondary"
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-next-page"
|
||||
variant="secondary"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() =>
|
||||
setSearchParams({ status, scope, page: String(Math.min(totalPages, page + 1)) })
|
||||
@@ -183,10 +203,10 @@ export function ReimbursementsPage() {
|
||||
type="button"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户