R101/A1-A7 收口企业微信移动端体验
这个提交包含在:
@@ -0,0 +1,175 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createReimbursement,
|
||||
fetchReimbursements,
|
||||
type ReimbursementListItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
|
||||
const STATUS_TABS: Array<{ value: ReimbursementStatus; label: string }> = [
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "reviewing", label: "审批中" },
|
||||
{ value: "approved", label: "已通过" },
|
||||
{ value: "paid", label: "已支付" },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<ReimbursementStatus, string> = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
approved: "已通过",
|
||||
paid: "已支付",
|
||||
rejected: "已驳回",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
|
||||
function formatCurrency(value: string | null | undefined) {
|
||||
const numberValue = Number(value ?? 0);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
return "¥0.00";
|
||||
}
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency",
|
||||
currency: "CNY",
|
||||
}).format(numberValue);
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
return value || "未填写日期";
|
||||
}
|
||||
|
||||
function ReimbursementCard({ item }: { item: ReimbursementListItem }) {
|
||||
const actionLabel = item.status === "draft" || item.status === "rejected" ? "继续编辑" : "查看详情";
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursement-card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{item.formNumber || item.id}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{formatDate(item.reimbursementDate)}</div>
|
||||
</div>
|
||||
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">金额</div>
|
||||
<div className="mt-1 text-lg font-semibold text-zinc-900">{formatCurrency(item.totalAmount)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">报销人</div>
|
||||
<div className="mt-1 truncate text-zinc-900">{item.owner?.name ?? item.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.description ? <p className="mt-3 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
|
||||
|
||||
<Button asChild className="mt-4 w-full" type="button">
|
||||
<Link to={`/reimbursements/${item.id}`}>{actionLabel}</Link>
|
||||
</Button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileReimbursementsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const [status, setStatus] = useState<ReimbursementStatus>("draft");
|
||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ["mobile", "reimbursements", status],
|
||||
queryFn: () => fetchReimbursements({ status, page: 1, pageSize: 20, scope: "own" }),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createReimbursement(),
|
||||
onSuccess: async (response) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
navigate(`/reimbursements/${response.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = listQuery.data?.total ?? 0;
|
||||
const totalAmount = useMemo(
|
||||
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageScaffold className="space-y-4">
|
||||
<PageHeader
|
||||
title="报销"
|
||||
actions={
|
||||
canCreateExpense ? (
|
||||
<Button
|
||||
data-testid="mobile-reimbursements-create"
|
||||
disabled={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
type="button"
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "新建报销"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前列表</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{total}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-zinc-500">当前金额</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-zinc-900">{formatCurrency(String(totalAmount))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm" data-testid="mobile-reimbursements-list">
|
||||
<Tabs value={status} onValueChange={(value) => setStatus(value as ReimbursementStatus)}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-4">
|
||||
{STATUS_TABS.map((item) => (
|
||||
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-reimbursements-tab-${item.value}`}>
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{listQuery.isLoading ? <div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">加载中...</div> : null}
|
||||
{listQuery.isError ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{resolveErrorMessage(listQuery.error)}
|
||||
</div>
|
||||
) : null}
|
||||
{!listQuery.isLoading && !listQuery.isError && !items.length ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-200 p-6 text-center text-sm text-zinc-500">暂无报销记录</div>
|
||||
) : null}
|
||||
{items.map((item) => (
|
||||
<ReimbursementCard item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileReimbursementsPage;
|
||||
在新工单中引用
屏蔽一个用户