R56 A1-A6:费用标准管控
这个提交包含在:
@@ -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({}),
|
||||
});
|
||||
}
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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(""); }}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
@@ -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"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户