522 行
19 KiB
TypeScript
522 行
19 KiB
TypeScript
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;
|