feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-09 23:19:02 +08:00
父节点 b87cc55aca
当前提交 e5685f18b2
共修改 28 个文件,包含 1616 行新增和 121 行删除
@@ -0,0 +1,56 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequestData } from "../../api/http";
import type { ExpenseType } from "../../api/expenseTypes";
import type { ReimbursementItem } from "../../api/reimbursements";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { Button } from "../ui/button";
export function ReimbursementBatchClassification({ reimbursementId, items, expenseTypes }: {
reimbursementId: string;
items: ReimbursementItem[];
expenseTypes: ExpenseType[];
}) {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expenseTypeId, setExpenseTypeId] = useState("");
const queryClient = useQueryClient();
const visibleIds = selectedIds.filter((id) => items.some((item) => item.id === id));
const mutation = useMutation({
mutationFn: () => apiRequestData(`/api/reimbursements/${reimbursementId}/items/classify`, {
method: "POST", body: JSON.stringify({ itemIds: visibleIds, expenseTypeId }),
}),
onSuccess: async () => {
setSelectedIds([]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
queryClient.invalidateQueries({ queryKey: ["mobile", "reimbursements"] }),
]);
},
});
if (!items.length) return null;
const unclassified = items.filter((item) => !item.expenseType || !item.expenseType.enabled).length;
return (
<details className="rounded-lg border border-zinc-200 p-3" open={unclassified > 0 ? true : undefined}>
<summary className="cursor-pointer text-sm font-medium">费用分类{unclassified ? `:${unclassified} 条待补充` : "(可批量修改)"}</summary>
<p className="my-2 text-xs text-zinc-500">选中同类明细,一次设置;未选中的明细不会修改。</p>
<div className="space-y-2">
{items.map((item, index) => (
<label key={item.id} className="flex items-start gap-2 text-sm">
<input type="checkbox" checked={visibleIds.includes(item.id)} disabled={mutation.isPending}
onChange={(event) => setSelectedIds((previous) => event.target.checked ? [...new Set([...previous, item.id])] : previous.filter((id) => id !== item.id))} />
<span>明细 {index + 1} · {item.expenseType?.name ?? "待补充类型"} · ¥{item.amount} · {item.description || "待补充说明"}</span>
</label>
))}
<select aria-label="批量费用类型" className="select-std w-full" value={expenseTypeId} disabled={mutation.isPending}
onChange={(event) => setExpenseTypeId(event.target.value)}>
<option value="">选择所选明细的费用类型</option>
{expenseTypes.filter((type) => type.enabled).map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}
</select>
<Button type="button" disabled={!visibleIds.length || visibleIds.length > 100 || !expenseTypeId || mutation.isPending}
onClick={() => mutation.mutate()}>{mutation.isPending ? "设置中…" : `设置所选 ${visibleIds.length} 条分类`}</Button>
{visibleIds.length > 100 ? <p role="alert">每次最多设置 100 条,请分批选择。</p> : null}
{mutation.isError ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error, "分类设置失败,请刷新后重试")}</p> : null}
</div>
</details>
);
}
@@ -15,6 +15,7 @@ export type ReimbursementEvidenceCapabilities = {
onOpenMaterialBundle?: () => void;
onDeleteAttachment?: (attachment: ReimbursementAttachmentEvidence) => void;
onEditItem?: (item: ReimbursementItemEvidence) => void;
onAddInvoices?: (item: ReimbursementItemEvidence) => void;
onLinkInvoice?: (invoice: ReimbursementInvoiceEvidence, linkedItemId: string) => void;
linkInvoicePending?: boolean;
linkInvoiceErrors?: Record<string, string>;
@@ -95,7 +96,7 @@ function ExpenseItemsTable({ amountSummary, items, capabilities }: { amountSumma
<td className="px-3 py-3 align-top text-zinc-700"><p>{item.description || "未填写明细说明"}</p><ItemIssues item={item} /></td>
<td className="px-3 py-3 text-right align-top font-medium tabular-nums text-zinc-900">{formatMoney(item.amount)}</td>
<td className={hasBlockingIssue ? "px-3 py-3 align-top font-medium text-red-700" : "px-3 py-3 align-top text-zinc-600"}>{itemInvoiceStatus(item)}</td>
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button></td> : null}
{editable ? <td className="px-3 py-2 text-right align-top"><Button className="min-h-11" onClick={() => capabilities.onEditItem?.(item)} size="sm" type="button" variant="secondary">编辑</Button>{capabilities.onAddInvoices ? <Button className="min-h-11" onClick={() => capabilities.onAddInvoices?.(item)} size="sm" type="button" variant="secondary">为明细添加发票</Button> : null}</td> : null}
</tr>
);
})}
@@ -125,6 +126,7 @@ function ExpenseItemCards({ amountSummary, items, capabilities }: { amountSummar
<p className={hasBlockingIssue ? "mt-2 text-sm font-medium text-red-700" : "mt-2 text-sm text-zinc-600"}>票据:{itemInvoiceStatus(item)}</p>
<ItemIssues item={item} />
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
{capabilities.onAddInvoices ? <Button className="mt-2 min-h-11 w-full" onClick={() => capabilities.onAddInvoices?.(item)} type="button" variant="secondary">为明细添加发票</Button> : null}
</article>
);
})}