R102/A1-A8 优化报销主流程
这个提交包含在:
@@ -8,9 +8,11 @@ import { exportReimbursements } from "../api/exports";
|
||||
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
|
||||
import {
|
||||
createReimbursement,
|
||||
deleteReimbursement,
|
||||
fetchLinkableTravelRequests,
|
||||
fetchReimbursements,
|
||||
type ReimbursementCreatePayload,
|
||||
type ReimbursementListItem,
|
||||
type ReimbursementStatus,
|
||||
} from "../api/reimbursements";
|
||||
import {
|
||||
@@ -22,6 +24,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -107,6 +110,7 @@ export function ReimbursementsPage() {
|
||||
const page = Number(searchParams.get("page") ?? "1") || 1;
|
||||
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
|
||||
const canCreateExpense = hasPermission(permissions, "expense.create");
|
||||
const canDeleteExpense = hasPermission(permissions, "expense.delete_own");
|
||||
const canViewAll = hasPermission(permissions, "expense.view_all");
|
||||
const canApproveExpense = hasPermission(permissions, "expense.approve");
|
||||
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
|
||||
@@ -201,6 +205,25 @@ export function ReimbursementsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (reimbursementId: string) => deleteReimbursement(reimbursementId),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
|
||||
toast.success("草稿已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error, "删除草稿失败"));
|
||||
},
|
||||
});
|
||||
|
||||
const handleDeleteDraft = (item: ReimbursementListItem) => {
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
if (!window.confirm(`确认删除 ${displayCode}?删除后无法恢复。`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(item.id);
|
||||
};
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (format: "csv" | "xlsx") => exportReimbursements({ format, status, scope }),
|
||||
onSuccess: (response, format) => {
|
||||
@@ -594,6 +617,8 @@ export function ReimbursementsPage() {
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -604,7 +629,7 @@ export function ReimbursementsPage() {
|
||||
<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}
|
||||
{displayCode}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{item.reimbursementDate ?? "未填写日期"}</div>
|
||||
</div>
|
||||
@@ -648,7 +673,7 @@ export function ReimbursementsPage() {
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
disabled={!canSelectRow}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
||||
aria-label={`选择报销单 ${displayCode}`}
|
||||
/>
|
||||
选择
|
||||
</label>
|
||||
@@ -666,6 +691,18 @@ export function ReimbursementsPage() {
|
||||
{actionLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => handleDeleteDraft(item)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -696,6 +733,8 @@ export function ReimbursementsPage() {
|
||||
const canSelectRow = Boolean(
|
||||
item.pendingWorkflow?.task?.id && item.pendingWorkflow.allowedActions.includes("approve"),
|
||||
);
|
||||
const displayCode = resolveReimbursementDisplayCode(item);
|
||||
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
|
||||
|
||||
return (
|
||||
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
|
||||
@@ -706,7 +745,7 @@ export function ReimbursementsPage() {
|
||||
type="checkbox"
|
||||
checked={selectedReimbursementIds.includes(item.id)}
|
||||
onChange={() => toggleBatchSelection(item.id)}
|
||||
aria-label={`选择报销单 ${item.formNumber ?? item.id}`}
|
||||
aria-label={`选择报销单 ${displayCode}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-400">-</span>
|
||||
@@ -714,10 +753,7 @@ export function ReimbursementsPage() {
|
||||
</td>
|
||||
) : null}
|
||||
<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>
|
||||
) : null}
|
||||
<div className="font-semibold text-zinc-900">{displayCode}</div>
|
||||
{item.project ? (
|
||||
<div className="text-xs text-zinc-500">
|
||||
项目:{item.project.projectNo} · {item.project.name}
|
||||
@@ -749,13 +785,26 @@ export function ReimbursementsPage() {
|
||||
</td>
|
||||
<td className="table-td">{item.reimbursementDate ?? "-"}</td>
|
||||
<td className="table-td">
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
to={`/reimbursements/${item.id}`}
|
||||
className={["draft", "rejected"].includes(item.status) ? "btn-primary" : "btn-secondary"}
|
||||
data-testid="reimbursement-detail-link"
|
||||
>
|
||||
{["draft", "rejected"].includes(item.status) ? "编辑" : "详情"}
|
||||
</Link>
|
||||
{canDeleteDraft ? (
|
||||
<Button
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => handleDeleteDraft(item)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -819,7 +868,7 @@ export function ReimbursementsPage() {
|
||||
共 {selectedItems.length} 项:
|
||||
<div className="mt-2 max-h-40 space-y-1 overflow-auto">
|
||||
{selectedItems.map((item) => (
|
||||
<div key={item.id}>{item.formNumber ?? item.id}</div>
|
||||
<div key={item.id}>{resolveReimbursementDisplayCode(item)}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -874,7 +923,9 @@ export function ReimbursementsPage() {
|
||||
<div key={`${item.taskId}-${item.outcome}`} className="rounded-xl border border-zinc-200 bg-white px-4 py-3">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-zinc-900">{item.formNumber ?? item.reimbursementId ?? "-"}</div>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-zinc-700">
|
||||
|
||||
在新工单中引用
屏蔽一个用户