实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -80,6 +80,7 @@ import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { isWecomWebView } from "../lib/runtimeContext";
|
||||
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
|
||||
import { shouldShowOfficeElectronicsHint } from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
|
||||
type ItemFormState = {
|
||||
mode: "create" | "edit";
|
||||
@@ -88,6 +89,7 @@ type ItemFormState = {
|
||||
expenseDate: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
assetPriorApprovalBasis: string;
|
||||
};
|
||||
|
||||
type SelectedInvoiceMap = Record<string, string>;
|
||||
@@ -177,11 +179,13 @@ export function MobileReimbursementDetailPage() {
|
||||
const [invoiceSearch, setInvoiceSearch] = useState("");
|
||||
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
|
||||
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
|
||||
const [invoiceUploadLinkedItemId, setInvoiceUploadLinkedItemId] = useState("");
|
||||
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
|
||||
const [invoiceActionError, setInvoiceActionError] = useState("");
|
||||
const [materialPreviewError, setMaterialPreviewError] = useState("");
|
||||
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
|
||||
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
|
||||
const [pendingOcrLinkedItemIds, setPendingOcrLinkedItemIds] = useState<Record<string, string>>({});
|
||||
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
|
||||
|
||||
const detailQuery = useQuery({
|
||||
@@ -327,6 +331,7 @@ export function MobileReimbursementDetailPage() {
|
||||
expenseDate: detail?.reimbursementDate ?? todayDate(),
|
||||
amount: "",
|
||||
description: "",
|
||||
assetPriorApprovalBasis: "",
|
||||
});
|
||||
setItemError("");
|
||||
};
|
||||
@@ -339,6 +344,7 @@ export function MobileReimbursementDetailPage() {
|
||||
expenseDate: item.expenseDate ?? detail?.reimbursementDate ?? todayDate(),
|
||||
amount: item.amount,
|
||||
description: item.description ?? "",
|
||||
assetPriorApprovalBasis: item.assetPriorApprovalBasis ?? "",
|
||||
});
|
||||
setItemError("");
|
||||
};
|
||||
@@ -354,6 +360,7 @@ export function MobileReimbursementDetailPage() {
|
||||
expenseDate: form.expenseDate,
|
||||
amount: form.amount,
|
||||
description: form.description.trim() || undefined,
|
||||
assetPriorApprovalBasis: form.assetPriorApprovalBasis.trim() || null,
|
||||
};
|
||||
|
||||
if (form.mode === "edit" && form.itemId) {
|
||||
@@ -458,6 +465,9 @@ export function MobileReimbursementDetailPage() {
|
||||
setSelectedInvoices((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||
);
|
||||
setPendingOcrLinkedItemIds((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !succeededIds.includes(invoiceId))),
|
||||
);
|
||||
|
||||
if (!failed.length) {
|
||||
setInvoiceActionError("");
|
||||
@@ -487,9 +497,14 @@ export function MobileReimbursementDetailPage() {
|
||||
if (!invoiceIds.length) {
|
||||
return;
|
||||
}
|
||||
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
|
||||
bindInvoicesMutation.mutate(
|
||||
invoiceIds.map((invoiceId) => ({
|
||||
invoiceId,
|
||||
linkedItemId: pendingOcrLinkedItemIds[invoiceId] || undefined,
|
||||
})),
|
||||
);
|
||||
},
|
||||
[bindInvoicesMutation],
|
||||
[bindInvoicesMutation, pendingOcrLinkedItemIds],
|
||||
);
|
||||
|
||||
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null }>) => {
|
||||
@@ -498,6 +513,10 @@ export function MobileReimbursementDetailPage() {
|
||||
.join(";");
|
||||
setInvoiceActionError(message);
|
||||
setInvoiceActionMessage("");
|
||||
const unavailableIds = new Set(items.map((item) => item.id));
|
||||
setPendingOcrLinkedItemIds((previous) =>
|
||||
Object.fromEntries(Object.entries(previous).filter(([invoiceId]) => !unavailableIds.has(invoiceId))),
|
||||
);
|
||||
toast.error("部分发票需要处理");
|
||||
}, []);
|
||||
|
||||
@@ -577,7 +596,10 @@ export function MobileReimbursementDetailPage() {
|
||||
});
|
||||
} else if (uploadResult.status === "available") {
|
||||
try {
|
||||
await bindInvoice(uploadResult.invoicePoolId, { reimbursementId });
|
||||
await bindInvoice(uploadResult.invoicePoolId, {
|
||||
reimbursementId,
|
||||
...(detail.invoiceGuardEnabled ? { linkedItemId: invoiceUploadLinkedItemId } : {}),
|
||||
});
|
||||
boundCount += 1;
|
||||
} catch (error) {
|
||||
failures.push({ file, message: `上传成功,添加失败:${resolveErrorMessage(error)}` });
|
||||
@@ -603,6 +625,12 @@ export function MobileReimbursementDetailPage() {
|
||||
await bindableInvoicesQuery.refetch();
|
||||
|
||||
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
|
||||
if (detail?.invoiceGuardEnabled && invoiceUploadLinkedItemId) {
|
||||
setPendingOcrLinkedItemIds((previous) => ({
|
||||
...previous,
|
||||
...Object.fromEntries(result.pendingIds.map((invoiceId) => [invoiceId, invoiceUploadLinkedItemId])),
|
||||
}));
|
||||
}
|
||||
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
|
||||
if (!result.failures.length) {
|
||||
if (fileInputRef.current) {
|
||||
@@ -771,7 +799,8 @@ export function MobileReimbursementDetailPage() {
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (!detail.invoices.length) {
|
||||
const requiresAnyInvoice = detail.items.some((item) => item.expenseType?.requiresInvoice !== false);
|
||||
if (requiresAnyInvoice && !detail.invoices.length) {
|
||||
const message = "请先添加至少 1 张发票";
|
||||
setActionError(message);
|
||||
toast.error(message);
|
||||
@@ -809,6 +838,7 @@ export function MobileReimbursementDetailPage() {
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoices({});
|
||||
setSelectedInvoiceFiles([]);
|
||||
setInvoiceUploadLinkedItemId("");
|
||||
setInvoiceActionError("");
|
||||
setInvoiceActionMessage("");
|
||||
setConfirmDeleteInvoiceId("");
|
||||
@@ -1029,6 +1059,11 @@ export function MobileReimbursementDetailPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description ? (
|
||||
<span className="text-xs text-zinc-500">
|
||||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.description}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm text-zinc-700">
|
||||
<span>日期</span>
|
||||
@@ -1058,11 +1093,29 @@ export function MobileReimbursementDetailPage() {
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, description: event.target.value } : current)}
|
||||
/>
|
||||
</label>
|
||||
{expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" ? (
|
||||
<label className="col-span-2 block space-y-1 text-sm text-zinc-700">
|
||||
<span>事前批准依据</span>
|
||||
<Textarea
|
||||
disabled={itemSaveMutation.isPending}
|
||||
placeholder="填写批准人、批准时间和简短依据"
|
||||
rows={2}
|
||||
value={itemForm.assetPriorApprovalBasis}
|
||||
onChange={(event) => setItemForm((current) => current ? { ...current, assetPriorApprovalBasis: event.target.value } : current)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{shouldShowOfficeElectronicsHint(
|
||||
expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.code,
|
||||
itemForm.amount,
|
||||
) ? (
|
||||
<p className="col-span-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-two-action-grid">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || itemSaveMutation.isPending}
|
||||
disabled={!itemForm.expenseTypeId || !itemForm.expenseDate || !Number.isFinite(Number(itemForm.amount)) || Number(itemForm.amount) <= 0 || (expenseTypes.find((type) => type.id === itemForm.expenseTypeId)?.category === "assets" && !itemForm.assetPriorApprovalBasis.trim()) || itemSaveMutation.isPending}
|
||||
onClick={() => itemSaveMutation.mutate(itemForm)}
|
||||
>
|
||||
{itemSaveMutation.isPending ? "保存中..." : "保存"}
|
||||
@@ -1086,6 +1139,12 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatMoney(item.amount)}</div>
|
||||
</div>
|
||||
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
|
||||
{shouldShowOfficeElectronicsHint(item.expenseType?.code, item.amount) ? (
|
||||
<p className="mt-2 text-xs text-amber-600">该金额通常应选择办公电子设备并填写事前批准依据。</p>
|
||||
) : null}
|
||||
{item.assetPriorApprovalBasis ? (
|
||||
<p className="mt-2 text-xs text-zinc-500">事前批准依据:{item.assetPriorApprovalBasis}</p>
|
||||
) : null}
|
||||
{canMaintainReimbursement ? (
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
|
||||
@@ -1443,11 +1502,27 @@ export function MobileReimbursementDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail.invoiceGuardEnabled ? (
|
||||
<select
|
||||
aria-label="新上传发票关联明细"
|
||||
className="select-std w-full"
|
||||
value={invoiceUploadLinkedItemId}
|
||||
onChange={(event) => setInvoiceUploadLinkedItemId(event.target.value)}
|
||||
>
|
||||
<option value="">请选择新上传发票对应的费用明细</option>
|
||||
{detail.items.map((detailItem) => (
|
||||
<option key={detailItem.id} value={detailItem.id}>
|
||||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||
type="button"
|
||||
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
|
||||
disabled={!selectedInvoiceFiles.length || (detail.invoiceGuardEnabled && !invoiceUploadLinkedItemId) || uploadInvoiceMutation.isPending}
|
||||
onClick={() => uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
|
||||
>
|
||||
{uploadInvoiceMutation.isPending
|
||||
@@ -1540,9 +1615,24 @@ export function MobileReimbursementDetailPage() {
|
||||
) : null}
|
||||
{isAvailable && checked ? (
|
||||
<div className="mt-3 rounded-md bg-primary-50 px-3 py-2 text-xs text-primary-700">
|
||||
添加后自动生成或匹配报销明细
|
||||
{detail.invoiceGuardEnabled ? "请选择该发票对应的费用明细" : "添加后自动生成或匹配报销明细"}
|
||||
</div>
|
||||
) : null}
|
||||
{isAvailable && checked && detail.invoiceGuardEnabled ? (
|
||||
<select
|
||||
aria-label="关联费用明细"
|
||||
className="select-std mt-3 w-full"
|
||||
value={selectedInvoices[invoice.id] ?? ""}
|
||||
onChange={(event) => setSelectedInvoices((previous) => ({ ...previous, [invoice.id]: event.target.value }))}
|
||||
>
|
||||
<option value="">请选择费用明细</option>
|
||||
{detail.items.map((detailItem) => (
|
||||
<option key={detailItem.id} value={detailItem.id}>
|
||||
{detailItem.expenseType?.name ?? "费用"} · {detailItem.expenseDate ?? "未填写日期"} · {detailItem.description ?? "未填写说明"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Button
|
||||
className="mt-3 w-full"
|
||||
size="sm"
|
||||
@@ -1561,12 +1651,12 @@ export function MobileReimbursementDetailPage() {
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
disabled={!selectedInvoiceEntries.length || bindInvoicesMutation.isPending}
|
||||
disabled={!selectedInvoiceEntries.length || (detail.invoiceGuardEnabled && selectedInvoiceEntries.some(([, linkedItemId]) => !linkedItemId)) || bindInvoicesMutation.isPending}
|
||||
onClick={() =>
|
||||
bindInvoicesMutation.mutate(
|
||||
selectedInvoiceEntries.map(([invoiceId]) => ({
|
||||
selectedInvoiceEntries.map(([invoiceId, linkedItemId]) => ({
|
||||
invoiceId,
|
||||
linkedItemId: undefined,
|
||||
linkedItemId: linkedItemId || undefined,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户