feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验

这个提交包含在:
2026-08-04 23:28:56 +08:00
父节点 f33c67eb20
当前提交 50c14c1b85
共修改 28 个文件,包含 1095 行新增和 30 行删除
@@ -0,0 +1,102 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateReimbursement } from "../../api/reimbursements";
import { isApiError } from "../../api/types";
import { mapApiErrorMessage } from "../../api/errorMessages";
import { useUnsavedChangesGuard } from "../common/UnsavedChangesGuard";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Textarea } from "../ui/textarea";
type Props = {
reimbursementId: string;
reimbursementDate: string | null;
description: string | null;
editable: boolean;
mobile?: boolean;
};
export function ReimbursementHeaderEditor({
reimbursementId,
reimbursementDate,
description,
editable,
mobile = false,
}: Props) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [date, setDate] = useState(reimbursementDate ?? "");
const [reason, setReason] = useState(description ?? "");
const [error, setError] = useState("");
const dirty = editing && (date !== (reimbursementDate ?? "") || reason !== (description ?? ""));
const runGuarded = useUnsavedChangesGuard(dirty, "报销事由和日期");
useEffect(() => {
if (editing) return;
setDate(reimbursementDate ?? "");
setReason(description ?? "");
}, [description, editing, reimbursementDate]);
const mutation = useMutation({
mutationFn: () => updateReimbursement(reimbursementId, {
reimbursementDate: date,
description: reason.trim() || null,
}),
onSuccess: async () => {
setError("");
setEditing(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
]);
},
onError: (mutationError) => {
setError(isApiError(mutationError)
? mapApiErrorMessage(mutationError.code, mutationError.details, mutationError.message)
: "保存报销信息失败,请重试");
},
});
if (!editing) {
return (
<div className={mobile ? "mt-3" : "mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2"}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-sm leading-6 text-zinc-600">
<span className="font-medium text-zinc-800">报销事由:</span>
{description || "未填写"}
</div>
{editable ? (
<Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>
编辑
</Button>
) : null}
</div>
</div>
);
}
return (
<div className="mt-3 space-y-3 rounded-lg border border-primary-100 bg-primary-50/40 p-3" data-testid="reimbursement-header-editor">
<div className={mobile ? "space-y-3" : "grid gap-3 md:grid-cols-[12rem_1fr]"}>
<label className="space-y-1 text-sm text-zinc-700">
<span>报销日期</span>
<Input type="date" value={date} disabled={mutation.isPending} onChange={(event) => setDate(event.target.value)} />
</label>
<label className="space-y-1 text-sm text-zinc-700">
<span>报销事由</span>
<Textarea rows={2} maxLength={500} value={reason} disabled={mutation.isPending} onChange={(event) => setReason(event.target.value)} />
</label>
</div>
{error ? <p className="text-sm text-red-600">{error}</p> : null}
<div className="flex gap-2">
<Button type="button" disabled={!date || mutation.isPending} onClick={() => mutation.mutate()}>
{mutation.isPending ? "保存中..." : "保存"}
</Button>
<Button type="button" variant="secondary" disabled={mutation.isPending} onClick={() => runGuarded(() => setEditing(false))}>
取消
</Button>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { Link } from "react-router-dom";
import type { PrecheckResponse } from "../../api/feeControl";
import type { ReimbursementItem } from "../../api/reimbursements";
type Props = {
precheck: PrecheckResponse | null | undefined;
items: ReimbursementItem[];
canAccessAssets: boolean;
mobile?: boolean;
};
export function ReimbursementOperationalHints({ precheck, items, canAccessAssets, mobile = false }: Props) {
const evidence = precheck?.evidenceSummary;
const timeliness = precheck?.timelinessChecks ?? [];
const relevantDates = timeliness.filter((item) => item.overdue || item.crossCalendarYear);
const itemById = new Map(items.map((item) => [item.id, item]));
if (!evidence && !relevantDates.length && !precheck?.assetHint?.required) return null;
return (
<div className={`${mobile ? "mt-3" : "mt-4"} space-y-3`} data-testid={mobile ? "mobile-reimbursement-operational-hints" : "reimbursement-operational-hints"}>
{evidence ? (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-700">
<div className="font-medium text-zinc-900">材料对应情况</div>
<p className="mt-1 text-xs leading-5 text-zinc-500">
已添加发票 {evidence.invoiceCount} 张,整单补充材料 {evidence.attachmentCount} 份。
</p>
<ul className="mt-2 space-y-1 text-xs leading-5">
{evidence.items.map((entry) => {
const item = itemById.get(entry.itemId);
return (
<li key={entry.itemId}>
{item?.expenseType?.name ?? "报销明细"}:
{entry.linkedInvoiceCount > 0
? `已关联 ${entry.linkedInvoiceCount} 张发票`
: "待关联发票或补充无票说明"}
</li>
);
})}
</ul>
</div>
) : null}
{relevantDates.length ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm text-amber-800">
<div className="font-medium">费用日期提示</div>
<ul className="mt-1 space-y-1 text-xs leading-5">
{relevantDates.map((check) => (
<li key={check.itemId}>
{itemById.get(check.itemId)?.expenseType?.name ?? "报销明细"}({check.expenseDate}):
{[
check.overdue ? `距今 ${check.ageDays} 天,超过当前 ${check.maxAgeDays} 天期限` : "",
check.crossCalendarYear ? "与当前日期跨自然年度" : "",
].filter(Boolean).join(";")},请与财务确认。
</li>
))}
</ul>
</div>
) : null}
{precheck?.assetHint?.required ? (
<div className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-3 text-sm text-blue-800">
<div className="font-medium">资产登记提醒</div>
<p className="mt-1 text-xs leading-5">
{precheck.assetHint.itemCount} 条明细可能形成资产,报销完成后由管理员核对并手动建卡。
</p>
{canAccessAssets ? <Link className="mt-2 inline-flex text-xs font-medium text-primary hover:underline" to="/assets">查看资产台账</Link> : null}
</div>
) : null}
</div>
);
}