292 行
10 KiB
TypeScript
292 行
10 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import {
|
|
createReimbursement,
|
|
deleteReimbursement,
|
|
fetchReimbursements,
|
|
type ReimbursementListItem,
|
|
type ReimbursementStatus,
|
|
} from "../api/reimbursements";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { hasPermission } from "../auth/permissions";
|
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
|
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 { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
|
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 isReimbursementStatus(value: string | null): value is ReimbursementStatus {
|
|
return ["draft", "reviewing", "approved", "paid", "rejected", "cancelled"].includes(value ?? "");
|
|
}
|
|
|
|
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,
|
|
canDeleteDraft,
|
|
currentMobileSource,
|
|
isDeleting,
|
|
onDelete,
|
|
}: {
|
|
item: ReimbursementListItem;
|
|
canDeleteDraft: boolean;
|
|
currentMobileSource: string;
|
|
isDeleting: boolean;
|
|
onDelete: (item: ReimbursementListItem) => void;
|
|
}) {
|
|
const displayCode = resolveReimbursementDisplayCode(item);
|
|
|
|
return (
|
|
<article className="mobile-card" data-testid="mobile-reimbursement-card">
|
|
<Link className="block" to={`/mobile/reimbursements/${item.id}`} state={{ from: currentMobileSource }}>
|
|
<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">{formatDate(item.reimbursementDate)}</div>
|
|
</div>
|
|
<span className="badge-neutral shrink-0">{STATUS_LABEL[item.status]}</span>
|
|
</div>
|
|
|
|
<div className="mt-3 text-sm text-zinc-600">
|
|
金额:{formatCurrency(item.totalAmount)}
|
|
{" · "}
|
|
报销人:{item.owner?.name ?? item.submitter?.name ?? "-"}
|
|
</div>
|
|
|
|
{item.description ? <p className="mt-2 line-clamp-2 text-sm text-zinc-500">{item.description}</p> : null}
|
|
</Link>
|
|
|
|
{canDeleteDraft ? (
|
|
<details className="mt-3 text-sm">
|
|
<summary className="cursor-pointer select-none text-zinc-500">更多</summary>
|
|
<div className="mt-2">
|
|
<Button
|
|
data-testid="mobile-reimbursement-delete"
|
|
disabled={isDeleting}
|
|
onClick={() => onDelete(item)}
|
|
size="sm"
|
|
type="button"
|
|
variant="destructive"
|
|
>
|
|
删除草稿
|
|
</Button>
|
|
</div>
|
|
</details>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function MobileReimbursementsPage() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
const { permissions } = useAuth();
|
|
const [status, setStatus] = useState<ReimbursementStatus>(() => {
|
|
const queryStatus = searchParams.get("status");
|
|
return isReimbursementStatus(queryStatus) ? queryStatus : "draft";
|
|
});
|
|
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
|
|
const createIntentHandledRef = useRef(false);
|
|
const canCreateExpense = hasPermission(permissions, "expense.create");
|
|
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
|
const currentMobileSource = useMemo(() => {
|
|
const params = new URLSearchParams(location.search);
|
|
params.delete("intent");
|
|
params.set("status", status);
|
|
const query = params.toString();
|
|
return `${location.pathname}${query ? `?${query}` : ""}`;
|
|
}, [location.pathname, location.search, status]);
|
|
|
|
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(`/mobile/reimbursements/${response.id}`, { state: { from: currentMobileSource } });
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error));
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const queryStatus = searchParams.get("status");
|
|
if (isReimbursementStatus(queryStatus)) {
|
|
setStatus(queryStatus);
|
|
}
|
|
}, [searchParams]);
|
|
|
|
function handleStatusChange(value: string) {
|
|
if (!isReimbursementStatus(value)) {
|
|
return;
|
|
}
|
|
setStatus(value);
|
|
const nextParams = new URLSearchParams(searchParams);
|
|
nextParams.delete("intent");
|
|
nextParams.set("status", value);
|
|
setSearchParams(nextParams, { replace: true });
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (searchParams.get("intent") !== "create" || !canCreateExpense || createIntentHandledRef.current) {
|
|
return;
|
|
}
|
|
|
|
createIntentHandledRef.current = true;
|
|
const nextParams = new URLSearchParams(searchParams);
|
|
nextParams.delete("intent");
|
|
setSearchParams(nextParams, { replace: true });
|
|
createMutation.mutate();
|
|
}, [canCreateExpense, createMutation, searchParams, setSearchParams]);
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
|
onSuccess: async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
|
]);
|
|
setDeleteTarget(null);
|
|
toast.success("草稿已删除");
|
|
},
|
|
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="mobile-card">
|
|
<div className="mobile-info-grid">
|
|
<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="mobile-card" data-testid="mobile-reimbursements-list">
|
|
<Tabs value={status} onValueChange={handleStatusChange}>
|
|
<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="mobile-empty-state">加载中...</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="mobile-empty-state">暂无报销记录</div>
|
|
) : null}
|
|
{items.map((item) => (
|
|
<ReimbursementCard
|
|
canDeleteDraft={canDeleteExpense && item.status === "draft"}
|
|
currentMobileSource={currentMobileSource}
|
|
isDeleting={deleteMutation.isPending}
|
|
item={item}
|
|
key={item.id}
|
|
onDelete={setDeleteTarget}
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<ConfirmDialog
|
|
open={Boolean(deleteTarget)}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteTarget(null);
|
|
}
|
|
}}
|
|
title="删除草稿?"
|
|
description="删除后无法恢复,已添加发票会回到可用状态。"
|
|
variant="destructive"
|
|
confirmLabel="删除草稿"
|
|
onConfirm={async () => {
|
|
if (!deleteTarget) {
|
|
return;
|
|
}
|
|
await deleteMutation.mutateAsync(deleteTarget.id);
|
|
}}
|
|
/>
|
|
</PageScaffold>
|
|
);
|
|
}
|
|
|
|
export default MobileReimbursementsPage;
|