R56 A1-A6:费用标准管控
这个提交包含在:
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl";
|
||||
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
||||
import { fetchProjects } from "../api/projects";
|
||||
import {
|
||||
@@ -92,6 +93,7 @@ export function ReimbursementDetailPage() {
|
||||
const [itemError, setItemError] = useState("");
|
||||
const [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [isErpPreviewOpen, setIsErpPreviewOpen] = useState(false);
|
||||
const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState<PrecheckResponse | null>(null);
|
||||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||
expenseTypeId: "",
|
||||
expenseDate: "",
|
||||
@@ -116,6 +118,13 @@ export function ReimbursementDetailPage() {
|
||||
});
|
||||
|
||||
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
||||
const canSubmitExpense = hasPermission(permissions, "expense.submit");
|
||||
|
||||
const reimbursementPrecheckQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canSubmitExpense && ["draft", "rejected"].includes(detailQuery.data?.status ?? ""),
|
||||
queryKey: ["reimbursements", reimbursementId, "precheck"],
|
||||
queryFn: () => precheckReimbursement(reimbursementId),
|
||||
});
|
||||
|
||||
const erpPreviewQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
||||
@@ -147,8 +156,12 @@ export function ReimbursementDetailPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
]);
|
||||
setActionError("");
|
||||
if (params.action === "submit") {
|
||||
setSubmitConfirmPrecheck(null);
|
||||
}
|
||||
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
|
||||
toast.success(actionMessages[params.action] ?? "操作成功");
|
||||
},
|
||||
@@ -197,6 +210,7 @@ export function ReimbursementDetailPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
]);
|
||||
setItemEditor(null);
|
||||
setItemError("");
|
||||
@@ -233,6 +247,7 @@ export function ReimbursementDetailPage() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "precheck"] }),
|
||||
]);
|
||||
setItemError("");
|
||||
toast.success("明细已删除");
|
||||
@@ -349,6 +364,9 @@ export function ReimbursementDetailPage() {
|
||||
const budgetHint = detail.budgetHint;
|
||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||
const precheckStandardChecks = reimbursementPrecheckQuery.data?.standardChecks ?? [];
|
||||
const blockStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "block");
|
||||
const warnStandardChecks = precheckStandardChecks.filter((item) => item.exceeded && item.controlMode === "warn");
|
||||
|
||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||
|
||||
@@ -406,7 +424,8 @@ export function ReimbursementDetailPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
|
||||
if (precheck.decision === "warn" || precheck.decision === "manual_review") {
|
||||
setSubmitConfirmPrecheck(precheck);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -546,6 +565,10 @@ export function ReimbursementDetailPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{reimbursementPrecheckQuery.isLoading && canEditItems ? (
|
||||
<p className="text-sm text-zinc-500">正在刷新费用标准校验结果...</p>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table data-testid="reimbursement-items-table">
|
||||
<TableHeader>
|
||||
@@ -560,7 +583,17 @@ export function ReimbursementDetailPage() {
|
||||
<TableBody>
|
||||
{detail.items.length ? (
|
||||
detail.items.map((item) => (
|
||||
<TableRow data-testid="reimbursement-item-row" key={item.id}>
|
||||
<TableRow
|
||||
data-testid="reimbursement-item-row"
|
||||
key={item.id}
|
||||
className={
|
||||
blockStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-red-50/70"
|
||||
: warnStandardChecks.some((check) => check.itemId === item.id)
|
||||
? "bg-amber-50/70"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">
|
||||
{item.expenseType?.name ?? item.expenseTypeId}
|
||||
@@ -568,8 +601,33 @@ export function ReimbursementDetailPage() {
|
||||
<div className="text-xs text-zinc-500">{item.expenseType?.code ?? "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.expenseDate ?? "-"}</TableCell>
|
||||
<TableCell>{item.amount}</TableCell>
|
||||
<TableCell>{item.description ?? "-"}</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.amount}</div>
|
||||
{precheckStandardChecks
|
||||
.filter((check) => check.itemId === item.id)
|
||||
.map((check) => (
|
||||
<div
|
||||
key={check.standardId}
|
||||
className={`mt-1 text-xs ${check.exceeded ? (check.controlMode === "block" ? "text-red-600" : "text-amber-600") : "text-zinc-500"}`}
|
||||
>
|
||||
标准 ¥{check.maxAmount}/{check.perUnit}
|
||||
{check.exceeded ? `,超出 ¥${check.exceededAmount}` : ""}
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.description ?? "-"}</div>
|
||||
{precheckStandardChecks
|
||||
.filter((check) => check.itemId === item.id && check.exceeded)
|
||||
.map((check) => (
|
||||
<div
|
||||
key={`${check.standardId}-warning`}
|
||||
className={`mt-1 text-xs ${check.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}
|
||||
>
|
||||
{check.standardName}:当前金额超出费用标准。
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditItems ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -614,6 +672,7 @@ export function ReimbursementDetailPage() {
|
||||
expenseTypes={expenseTypes}
|
||||
expenseTypesLoading={expenseTypesQuery.isLoading}
|
||||
expenseTypesError={expenseTypesQuery.isError}
|
||||
departmentId={detail.departmentId}
|
||||
initialValues={editorInitialValues}
|
||||
onSave={handleItemSave}
|
||||
onCancel={handleItemCancel}
|
||||
@@ -785,6 +844,8 @@ export function ReimbursementDetailPage() {
|
||||
status={detail.status}
|
||||
canPay={canPay}
|
||||
actionError={actionError}
|
||||
submitDisabled={blockStandardChecks.length > 0}
|
||||
submitHint={blockStandardChecks.length > 0 ? "请修改超标明细后提交" : ""}
|
||||
isPending={mutation.isPending}
|
||||
onSubmit={() => void handleSubmitAction()}
|
||||
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||||
@@ -828,6 +889,60 @@ export function ReimbursementDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(submitConfirmPrecheck)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSubmitConfirmPrecheck(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认提交报销单?</DialogTitle>
|
||||
<DialogDescription>以下校验项不会阻止提交,但会作为预警或复核信息进入审批流程。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{submitConfirmPrecheck ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 whitespace-pre-wrap">
|
||||
{buildPrecheckDialogMessage(submitConfirmPrecheck)}
|
||||
</div>
|
||||
|
||||
{submitConfirmPrecheck.standardChecks.some((item) => item.exceeded && item.controlMode === "warn") ? (
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-zinc-900">超出费用标准的明细</h4>
|
||||
{submitConfirmPrecheck.standardChecks
|
||||
.filter((item) => item.exceeded && item.controlMode === "warn")
|
||||
.map((item: ExpenseStandardCheck) => (
|
||||
<div key={`${item.itemId}-${item.standardId}`} className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-amber-700">
|
||||
<div className="font-medium">{item.expenseTypeName}</div>
|
||||
<div>
|
||||
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit} / 超出 ¥{item.exceededAmount}
|
||||
</div>
|
||||
<div>{item.standardName}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setSubmitConfirmPrecheck(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => mutation.mutate({ action: "submit" })}
|
||||
>
|
||||
{mutation.isPending ? "提交中..." : "确认提交"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDeleteItemId)}
|
||||
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
|
||||
|
||||
在新工单中引用
屏蔽一个用户