R56 A1-A6:费用标准管控

这个提交包含在:
2026-04-08 12:47:40 +08:00
父节点 40c3b7632a
当前提交 845558ab17
共修改 25 个文件,包含 2993 行新增和 10 行删除
+90
查看文件
@@ -0,0 +1,90 @@
import { apiRequestData } from "./http";
export type ExpenseStandardPerUnit = "item" | "day" | "trip";
export type ExpenseStandardControlMode = "warn" | "block";
export type ExpenseStandardScopeType = "global" | "department";
export type ExpenseStandard = {
id: string;
expenseTypeId: string;
expenseTypeName: string | null;
name: string;
maxAmount: string;
perUnit: ExpenseStandardPerUnit;
controlMode: ExpenseStandardControlMode;
scopeType: ExpenseStandardScopeType;
scopeKey: string | null;
scopeLabel: string;
enabled: boolean;
note: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type ExpenseStandardListResponse = {
expenseTypeId: string | null;
scopeType: ExpenseStandardScopeType | null;
enabled: boolean | null;
items: ExpenseStandard[];
};
export type ExpenseStandardListQuery = {
expenseTypeId?: string;
scopeType?: ExpenseStandardScopeType;
enabled?: boolean;
};
export type CreateExpenseStandardPayload = {
expenseTypeId: string;
name: string;
maxAmount: string;
perUnit?: ExpenseStandardPerUnit;
controlMode?: ExpenseStandardControlMode;
scopeType?: ExpenseStandardScopeType;
scopeKey?: string | null;
enabled?: boolean;
note?: string | null;
};
export type UpdateExpenseStandardPayload = Partial<CreateExpenseStandardPayload>;
function toQueryString(query: Record<string, string | number | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, String(value));
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchExpenseStandards(query: ExpenseStandardListQuery = {}) {
return apiRequestData<ExpenseStandardListResponse>(
`/api/expense-standards${toQueryString(query as Record<string, string | number | boolean | undefined>)}`,
);
}
export async function createExpenseStandard(payload: CreateExpenseStandardPayload) {
return apiRequestData<ExpenseStandard>("/api/expense-standards", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function updateExpenseStandard(standardId: string, payload: UpdateExpenseStandardPayload) {
return apiRequestData<ExpenseStandard>(`/api/expense-standards/${standardId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function deleteExpenseStandard(standardId: string) {
return apiRequestData<ExpenseStandard>(`/api/expense-standards/${standardId}`, {
method: "DELETE",
body: JSON.stringify({}),
});
}
+17
查看文件
@@ -19,6 +19,22 @@ export type RiskFlag = {
export type PrecheckDecision = "pass" | "warn" | "manual_review" | "block";
export type ExpenseStandardCheck = {
itemId: string;
expenseTypeId: string;
expenseTypeName: string;
amount: string;
standardId: string;
standardName: string;
maxAmount: string;
perUnit: "item" | "day" | "trip";
controlMode: "warn" | "block";
scopeType: "global" | "department";
scopeKey: string | null;
exceeded: boolean;
exceededAmount: string;
};
export type PrecheckResponse = {
entityType: "reimbursement" | "purchase_request" | "travel_request";
entityId: string;
@@ -29,6 +45,7 @@ export type PrecheckResponse = {
controlMode: "warn" | "block" | "manual_review";
} | null;
budgetHint: Record<string, unknown> | null;
standardChecks: ExpenseStandardCheck[];
warnings: Array<{ code: string; title: string; detail: string }>;
blockingReasons: Array<{ code: string; title: string; detail: string }>;
riskFlags: Array<{ code: string; level: "low" | "medium" | "high"; title: string; detail: string; willOpenOnSubmit: boolean }>;
+2 -1
查看文件
@@ -1,5 +1,5 @@
import { apiRequestData } from "./http";
import type { PrecheckResponse, RiskFlag } from "./feeControl";
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
export type ReimbursementStatus =
| "draft"
@@ -245,6 +245,7 @@ export type ReimbursementSubmitResponse = {
exceededBudget: boolean;
} | null;
precheckDecision: "pass" | "warn" | "manual_review";
standardWarnings: ExpenseStandardCheck[];
openedRiskFlagCount: number;
};
+4
查看文件
@@ -1,4 +1,5 @@
import { apiRequestData } from "./http";
import type { ExpenseStandardCheck } from "./feeControl";
export type WorkflowTaskSummary = {
id: string;
@@ -57,6 +58,9 @@ export type WorkflowTaskDetail = {
entityType: string;
currentStatus: string;
currentStepId: string;
contextData: {
standardViolations: ExpenseStandardCheck[];
};
startedAt: string | null;
completedAt: string | null;
} | null;
+521
查看文件
@@ -0,0 +1,521 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
createExpenseStandard,
deleteExpenseStandard,
fetchExpenseStandards,
updateExpenseStandard,
type ExpenseStandard,
type ExpenseStandardControlMode,
type ExpenseStandardPerUnit,
type ExpenseStandardScopeType,
} from "../api/expenseStandards";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
type FilterEnabled = "all" | "enabled" | "disabled";
type FormState = {
expenseTypeId: string;
name: string;
maxAmount: string;
perUnit: ExpenseStandardPerUnit;
controlMode: ExpenseStandardControlMode;
scopeType: ExpenseStandardScopeType;
scopeKey: string;
enabled: boolean;
note: string;
};
const PER_UNIT_OPTIONS: Array<{ value: ExpenseStandardPerUnit; label: string }> = [
{ value: "item", label: "每项" },
{ value: "day", label: "每天" },
{ value: "trip", label: "每次" },
];
const CONTROL_MODE_OPTIONS: Array<{ value: ExpenseStandardControlMode; label: string }> = [
{ value: "warn", label: "预警" },
{ value: "block", label: "阻断" },
];
function emptyForm(expenseTypeId = ""): FormState {
return {
expenseTypeId,
name: "",
maxAmount: "",
perUnit: "item",
controlMode: "warn",
scopeType: "global",
scopeKey: "",
enabled: true,
note: "",
};
}
function toForm(item: ExpenseStandard): FormState {
return {
expenseTypeId: item.expenseTypeId,
name: item.name,
maxAmount: item.maxAmount,
perUnit: item.perUnit,
controlMode: item.controlMode,
scopeType: item.scopeType,
scopeKey: item.scopeKey ?? "",
enabled: item.enabled,
note: item.note ?? "",
};
}
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试";
}
function toneClassForControlMode(mode: ExpenseStandardControlMode) {
return mode === "block"
? "border-red-200 bg-red-50 text-red-700"
: "border-amber-200 bg-amber-50 text-amber-700";
}
export function ExpenseStandardsPage() {
const queryClient = useQueryClient();
const [error, setError] = useState("");
const [selectedExpenseTypeId, setSelectedExpenseTypeId] = useState("");
const [enabledFilter, setEnabledFilter] = useState<FilterEnabled>("all");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ExpenseStandard | null>(null);
const [form, setForm] = useState<FormState>(emptyForm());
const [confirmDelete, setConfirmDelete] = useState<ExpenseStandard | null>(null);
const expenseTypesQuery = useQuery({
queryKey: ["expense-types", "expense-standards"],
queryFn: () => fetchExpenseTypes(),
});
const departmentsQuery = useQuery({
queryKey: ["departments", "expense-standards", "active"],
queryFn: () => fetchDepartments({ includeArchived: false }),
});
const standardsQuery = useQuery({
queryKey: ["expense-standards", selectedExpenseTypeId, enabledFilter],
queryFn: () =>
fetchExpenseStandards({
expenseTypeId: selectedExpenseTypeId || undefined,
enabled:
enabledFilter === "all"
? undefined
: enabledFilter === "enabled",
}),
});
useEffect(() => {
if (!dialogOpen) {
return;
}
if (editingItem) {
setForm(toForm(editingItem));
return;
}
const fallbackExpenseTypeId = selectedExpenseTypeId || expenseTypesQuery.data?.items?.[0]?.id || "";
setForm(emptyForm(fallbackExpenseTypeId));
}, [dialogOpen, editingItem, expenseTypesQuery.data?.items, selectedExpenseTypeId]);
const saveMutation = useMutation({
mutationFn: async () => {
const payload = {
expenseTypeId: form.expenseTypeId,
name: form.name.trim(),
maxAmount: form.maxAmount.trim(),
perUnit: form.perUnit,
controlMode: form.controlMode,
scopeType: form.scopeType,
scopeKey: form.scopeType === "department" ? form.scopeKey || null : null,
enabled: form.enabled,
note: form.note.trim() || null,
};
if (editingItem) {
return updateExpenseStandard(editingItem.id, payload);
}
return createExpenseStandard(payload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["expense-standards"] });
setDialogOpen(false);
setEditingItem(null);
setError("");
toast.success(editingItem ? "标准已更新" : "标准已创建");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setError(message);
toast.error(message);
},
});
const deleteMutation = useMutation({
mutationFn: (standardId: string) => deleteExpenseStandard(standardId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["expense-standards"] });
setConfirmDelete(null);
setError("");
toast.success("标准已停用");
},
onError: (error) => {
const message = resolveErrorMessage(error);
setError(message);
toast.error(message);
},
});
const items = standardsQuery.data?.items ?? [];
const expenseTypeOptions = expenseTypesQuery.data?.items ?? [];
const departmentOptions = departmentsQuery.data?.items ?? [];
const canSubmit =
form.expenseTypeId.trim().length > 0 &&
form.name.trim().length > 0 &&
form.maxAmount.trim().length > 0 &&
(form.scopeType !== "department" || form.scopeKey.trim().length > 0);
const selectedExpenseTypeName = useMemo(() => {
return expenseTypeOptions.find((item) => item.id === selectedExpenseTypeId)?.name ?? "全部费用类型";
}, [expenseTypeOptions, selectedExpenseTypeId]);
return (
<div className="space-y-6">
<PageHeader
title="费用标准管理"
subtitle="维护费用类型的通用报销上限与 warn/block 管控模式。"
actions={
<Button
type="button"
onClick={() => {
setEditingItem(null);
setDialogOpen(true);
}}
>
新增标准
</Button>
}
/>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<div className="card-std space-y-4 p-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1">
<Label htmlFor="expense-standard-filter-type">费用类型</Label>
<select
id="expense-standard-filter-type"
className="select-std"
value={selectedExpenseTypeId}
onChange={(event) => setSelectedExpenseTypeId(event.target.value)}
>
<option value="">全部费用类型</option>
{expenseTypeOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="expense-standard-filter-enabled">启用状态</Label>
<select
id="expense-standard-filter-enabled"
className="select-std"
value={enabledFilter}
onChange={(event) => setEnabledFilter(event.target.value as FilterEnabled)}
>
<option value="all">全部</option>
<option value="enabled">启用</option>
<option value="disabled">停用</option>
</select>
</div>
</div>
<div className="text-sm text-zinc-500">
当前筛选:{selectedExpenseTypeName} / {enabledFilter === "all" ? "全部状态" : enabledFilter === "enabled" ? "仅启用" : "仅停用"}
</div>
{standardsQuery.isLoading ? (
<TableSkeleton rows={5} />
) : standardsQuery.isError ? (
<p className="text-sm text-red-600">费用标准加载失败,请刷新页面重试。</p>
) : (
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>费用类型</TableHead>
<TableHead>标准名称</TableHead>
<TableHead>上限金额</TableHead>
<TableHead>计量单位</TableHead>
<TableHead>管控模式</TableHead>
<TableHead>适用范围</TableHead>
<TableHead>启用状态</TableHead>
<TableHead className="w-32">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length ? (
items.map((item) => (
<TableRow key={item.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.expenseTypeName ?? item.expenseTypeId}</div>
</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{item.name}</div>
<div className="text-xs text-zinc-500">{item.note || "-"}</div>
</TableCell>
<TableCell>¥{item.maxAmount}</TableCell>
<TableCell>{PER_UNIT_OPTIONS.find((option) => option.value === item.perUnit)?.label ?? item.perUnit}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${toneClassForControlMode(item.controlMode)}`}>
{item.controlMode === "block" ? "阻断" : "预警"}
</span>
</TableCell>
<TableCell>{item.scopeLabel}</TableCell>
<TableCell>{item.enabled ? "启用" : "停用"}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
type="button"
variant="secondary"
onClick={() => {
setEditingItem(item);
setDialogOpen(true);
}}
>
编辑
</Button>
<Button
size="sm"
type="button"
variant="secondary"
onClick={() => setConfirmDelete(item)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="py-6 text-center text-sm text-zinc-500">
当前筛选下暂无费用标准。
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setEditingItem(null);
setError("");
}
}}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingItem ? "编辑费用标准" : "新增费用标准"}</DialogTitle>
<DialogDescription>维护单条明细的金额上限,以及超标后是预警还是阻断提交。</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="expense-standard-type">费用类型</Label>
<select
id="expense-standard-type"
className="select-std"
value={form.expenseTypeId}
onChange={(event) => setForm((current) => ({ ...current, expenseTypeId: event.target.value }))}
disabled={Boolean(editingItem)}
>
<option value="">请选择费用类型</option>
{expenseTypeOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="expense-standard-name">标准名称</Label>
<Input
id="expense-standard-name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="例如:日常交通费上限"
/>
</div>
<div className="space-y-1">
<Label htmlFor="expense-standard-amount">上限金额</Label>
<Input
id="expense-standard-amount"
value={form.maxAmount}
onChange={(event) => setForm((current) => ({ ...current, maxAmount: event.target.value }))}
placeholder="例如 100.00"
/>
</div>
<div className="space-y-1">
<Label htmlFor="expense-standard-per-unit">计量单位</Label>
<select
id="expense-standard-per-unit"
className="select-std"
value={form.perUnit}
onChange={(event) => setForm((current) => ({ ...current, perUnit: event.target.value as ExpenseStandardPerUnit }))}
>
{PER_UNIT_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="expense-standard-control-mode">管控模式</Label>
<select
id="expense-standard-control-mode"
className="select-std"
value={form.controlMode}
onChange={(event) => setForm((current) => ({ ...current, controlMode: event.target.value as ExpenseStandardControlMode }))}
>
{CONTROL_MODE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="expense-standard-scope-type">适用范围</Label>
<select
id="expense-standard-scope-type"
className="select-std"
value={form.scopeType}
onChange={(event) =>
setForm((current) => ({
...current,
scopeType: event.target.value as ExpenseStandardScopeType,
scopeKey: event.target.value === "department" ? current.scopeKey : "",
}))
}
>
<option value="global">全局</option>
<option value="department">指定部门</option>
</select>
</div>
{form.scopeType === "department" ? (
<div className="space-y-1">
<Label htmlFor="expense-standard-department">部门</Label>
<select
id="expense-standard-department"
className="select-std"
value={form.scopeKey}
onChange={(event) => setForm((current) => ({ ...current, scopeKey: event.target.value }))}
>
<option value="">请选择部门</option>
{departmentOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
) : null}
<div className="space-y-1 md:col-span-2">
<Label htmlFor="expense-standard-note">备注</Label>
<Input
id="expense-standard-note"
value={form.note}
onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))}
placeholder="可选填写规则说明"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="expense-standard-enabled">启用状态</Label>
<select
id="expense-standard-enabled"
className="select-std max-w-xs"
value={form.enabled ? "true" : "false"}
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.value === "true" }))}
>
<option value="true">启用</option>
<option value="false">停用</option>
</select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button type="button" disabled={!canSubmit || saveMutation.isPending} onClick={() => saveMutation.mutate()}>
{saveMutation.isPending ? "保存中..." : editingItem ? "保存修改" : "创建标准"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(confirmDelete)}
onOpenChange={(open) => {
if (!open) {
setConfirmDelete(null);
}
}}
title="确认删除该费用标准?"
description={confirmDelete ? `删除后会将“${confirmDelete.name}”停用,不会物理移除历史记录。` : undefined}
confirmLabel="删除"
variant="destructive"
onConfirm={async () => {
if (confirmDelete) {
await deleteMutation.mutateAsync(confirmDelete.id);
}
}}
/>
</div>
);
}
export default ExpenseStandardsPage;
+119 -4
查看文件
@@ -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(""); }}
+1
查看文件
@@ -5,6 +5,7 @@ const ITEMS = [
{ to: "/system/workspace", title: "工作区设置", description: "调整小团队模式的导航与模块显隐。" },
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
{ to: "/system/expense-standards", title: "费用标准", description: "维护报销费用类型上限与 warn/block 管控模式。" },
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
{ to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
@@ -289,6 +289,31 @@ export function WorkflowTaskDetailPage() {
</div>
</SectionPanel>
{detail.workflowInstance?.entityType === "reimbursement" &&
detail.workflowInstance.contextData.standardViolations.length ? (
<SectionPanel title="费用标准提示" description="以下明细在提交时已被标记为超出费用标准,供审批判断时参考。">
<div className="space-y-2">
{detail.workflowInstance.contextData.standardViolations.map((item) => (
<div
key={`${item.itemId}-${item.standardId}`}
className={
item.controlMode === "block"
? "rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"
: "rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700"
}
>
<div className="font-medium text-zinc-900">{item.expenseTypeName}</div>
<div>
当前 ¥{item.amount} / 标准 ¥{item.maxAmount}/{item.perUnit}
</div>
<div>超出 ¥{item.exceededAmount} · {item.standardName}</div>
<div>管控模式:{item.controlMode === "block" ? "阻断" : "预警"}</div>
</div>
))}
</div>
</SectionPanel>
) : null}
<SectionPanel title="审批动作" description="把通过、驳回、退回和转审统一收口在同一块操作区。">
<div className="space-y-4">
{actionError ? (
@@ -16,6 +16,8 @@ export interface ReimbursementActionsProps {
status: ReimbursementStatus;
canPay: boolean;
actionError: string;
submitDisabled?: boolean;
submitHint?: string;
isPending: boolean;
onSubmit: () => void;
onWithdraw: (comment: string) => void;
@@ -26,6 +28,8 @@ export function ReimbursementActions({
status,
canPay,
actionError,
submitDisabled = false,
submitHint = "",
isPending,
onSubmit,
onWithdraw,
@@ -52,13 +56,14 @@ export function ReimbursementActions({
<div className="flex flex-wrap gap-2">
<Button
data-testid="reimbursement-submit"
disabled={isPending || status !== "draft"}
disabled={isPending || !["draft", "rejected"].includes(status) || submitDisabled}
onClick={onSubmit}
type="button"
>
提交报销
</Button>
</div>
{submitHint ? <p className="text-sm text-zinc-500">{submitHint}</p> : null}
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
<p className="text-sm font-medium text-zinc-800">撤回报销</p>
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { ExpenseType } from "../../api/expenseTypes";
import { fetchExpenseStandards } from "../../api/expenseStandards";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
@@ -25,6 +27,7 @@ export interface ReimbursementItemEditorProps {
expenseTypes: ExpenseType[];
expenseTypesLoading: boolean;
expenseTypesError: boolean;
departmentId: string;
initialValues: ItemEditorInitialValues;
onSave: (data: ItemEditorSaveData) => void;
onCancel: () => void;
@@ -36,6 +39,7 @@ export function ReimbursementItemEditor({
expenseTypes,
expenseTypesLoading,
expenseTypesError,
departmentId,
initialValues,
onSave,
onCancel,
@@ -54,6 +58,30 @@ export function ReimbursementItemEditor({
setDescription(initialValues.description);
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
const standardsQuery = useQuery({
enabled: Boolean(expenseTypeId),
queryKey: ["expense-standards", "editor", expenseTypeId],
queryFn: () =>
fetchExpenseStandards({
expenseTypeId,
enabled: true,
}),
});
const activeStandard = useMemo(() => {
const items = standardsQuery.data?.items ?? [];
return (
items.find((item) => item.scopeType === "department" && item.scopeKey === departmentId) ??
items.find((item) => item.scopeType === "global") ??
null
);
}, [departmentId, standardsQuery.data?.items]);
const numericAmount = Number(amount || 0);
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
const handleSave = () => {
onSave({ expenseTypeId, expenseDate, amount, description });
};
@@ -120,6 +148,19 @@ export function ReimbursementItemEditor({
placeholder="例如 123.45"
value={amount}
/>
{standardsQuery.isLoading ? (
<p className="text-xs text-zinc-500">正在加载费用标准...</p>
) : null}
{activeStandard ? (
<p className="text-xs text-zinc-500">
该费用类型上限 ¥{activeStandard.maxAmount}/{activeStandard.perUnit}
</p>
) : null}
{isExceeded ? (
<p className={`text-xs ${activeStandard?.controlMode === "block" ? "text-red-600" : "text-amber-600"}`}>
超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount}
</p>
) : null}
</div>
<div className="space-y-1 md:col-span-4">
+10
查看文件
@@ -21,6 +21,7 @@ import {
DelegationsPage,
DepartmentsPage,
ErpIntegrationPage,
ExpenseStandardsPage,
FeatureFlagsPage,
InventoryChecksPage,
InvoicePoolPage,
@@ -441,6 +442,15 @@ export function AppRouter() {
}
/>
<Route
path="system/expense-standards"
element={
<RequirePermission anyOf={["system.settings"]}>
{withRouteFallback(<ExpenseStandardsPage />)}
</RequirePermission>
}
/>
<Route
path="system/feature-flags"
element={
@@ -0,0 +1 @@
export { ExpenseStandardsPage as default } from "../../pages/ExpenseStandardsPage";
+1
查看文件
@@ -8,6 +8,7 @@ export const CutoverConsolePage = lazy(() => import("./CutoverConsolePage"));
export const DelegationsPage = lazy(() => import("./DelegationsPage"));
export const DepartmentsPage = lazy(() => import("./DepartmentsPage"));
export const ErpIntegrationPage = lazy(() => import("./ErpIntegrationPage"));
export const ExpenseStandardsPage = lazy(() => import("./ExpenseStandardsPage"));
export const FeatureFlagsPage = lazy(() => import("./FeatureFlagsPage"));
export const InventoryChecksPage = lazy(() => import("./InventoryChecksPage"));
export const InvoicePoolPage = lazy(() => import("./InvoicePoolPage"));