R29-1 R29-6: 重构海音达工作台壳层与高频页面
这个提交包含在:
+142
-144
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
@@ -11,15 +11,21 @@ import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
|
||||
}
|
||||
|
||||
export function ReimbursementsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { permissions, me } = useAuth();
|
||||
@@ -47,6 +53,20 @@ export function ReimbursementsPage() {
|
||||
queryFn: () => fetchMyDelegations("active"),
|
||||
});
|
||||
|
||||
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 createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createReimbursement({
|
||||
@@ -87,7 +107,7 @@ export function ReimbursementsPage() {
|
||||
const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ListWorkspace>
|
||||
<PageHeader
|
||||
title="我的报销"
|
||||
subtitle="报销列表、详情与提交/撤回/支付动作入口。"
|
||||
@@ -167,86 +187,62 @@ export function ReimbursementsPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="card-std">
|
||||
<p className="card-title">待我审批</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="card-value">12</h3>
|
||||
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
|
||||
2.4%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-std">
|
||||
<p className="card-title">已报销总额</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="card-value">¥124,500</h3>
|
||||
<div className="flex items-center text-emerald-600 text-xs font-bold mb-1">
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_upward</span>
|
||||
5.1%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-std">
|
||||
<p className="card-title">异常警报</p>
|
||||
<div className="flex items-end justify-between">
|
||||
<h3 className="card-value">2</h3>
|
||||
<div className="flex items-center text-red-500 text-xs font-bold mb-1">
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_downward</span>
|
||||
1.2%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<MetricCard title="当前列表" value={stats.currentPageCount} note={`共 ${listQuery.data?.total ?? 0} 条记录`} />
|
||||
<MetricCard title="当前金额" value={formatCurrency(stats.currentPageAmount)} note="按当前页与筛选统计" />
|
||||
<MetricCard title="审批中" value={stats.reviewing} note="待继续推进的报销单" tone={stats.reviewing > 0 ? "warning" : "neutral"} />
|
||||
<MetricCard title="已支付" value={stats.paid} note={`当前范围:${scope === "all" ? "全范围" : "仅自己"}`} />
|
||||
</section>
|
||||
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-2">
|
||||
<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}
|
||||
<FilterBar className="space-y-4">
|
||||
<div className="flex flex-col gap-4 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 ? (
|
||||
<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"
|
||||
>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{canViewAll ? (
|
||||
<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
|
||||
data-testid="reimbursements-scope-all"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
仅自己
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="reimbursements-scope-all"
|
||||
variant={scope === "all" ? "default" : "secondary"}
|
||||
onClick={() => setSearchParams({ status, page: "1", scope: "all" })}
|
||||
type="button"
|
||||
>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{listQuery.isLoading ? <TableSkeleton columns={6} rows={5} /> : null}
|
||||
|
||||
@@ -263,71 +259,73 @@ export function ReimbursementsPage() {
|
||||
) : null}
|
||||
|
||||
{!listQuery.isLoading && !listQuery.isError ? (
|
||||
<div className="table-container mt-4">
|
||||
<table className="w-full text-left border-collapse" 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">
|
||||
{(listQuery.data?.items ?? []).length ? (
|
||||
listQuery.data?.items.map((item) => (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
{item.formNumber && item.formNumber !== item.id && (
|
||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||
)}
|
||||
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</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="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
|
||||
{(item.owner?.name ?? "U").charAt(0)}
|
||||
<>
|
||||
<PrimaryTable>
|
||||
<table className="w-full text-left border-collapse" 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) => (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
<td className="table-td">
|
||||
<div className="font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
{item.formNumber && item.formNumber !== item.id && (
|
||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||
)}
|
||||
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</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="size-6 rounded-full bg-zinc-100 flex items-center justify-center text-[10px] font-bold text-zinc-700 uppercase">
|
||||
{(item.owner?.name ?? "U").charAt(0)}
|
||||
</div>
|
||||
<span className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</span>
|
||||
</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">
|
||||
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
||||
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<Link to={`/reimbursements/${item.id}`} className="btn-ghost" data-testid="reimbursement-detail-link">
|
||||
<span className="material-symbols-outlined text-[18px]">more_horiz</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</PrimaryTable>
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
|
||||
testIdPrefix="reimbursements"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(p) => setSearchParams({ status, scope, page: String(p) })}
|
||||
testIdPrefix="reimbursements"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</ListWorkspace>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户