feat(reimbursement-small-team-form-validation): 完善小团队报销填报校验
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户