feat: 选票自动生成报销明细并支持集中分类(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
在新工单中引用
屏蔽一个用户