507 行
18 KiB
TypeScript
507 行
18 KiB
TypeScript
import { resolveErrorMessage as resolveUserErrorMessage } from "../lib/resolveErrorMessage";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import { fetchExpenseTypes } from "../api/expenseTypes";
|
|
import {
|
|
createReimbursementTemplate,
|
|
deleteReimbursementTemplate,
|
|
fetchReimbursementTemplates,
|
|
updateReimbursementTemplate,
|
|
type ReimbursementTemplate,
|
|
type ReimbursementTemplateItem,
|
|
} from "../api/reimbursementTemplates";
|
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
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 { Textarea } from "../components/ui/textarea";
|
|
|
|
type FilterEnabled = "all" | "enabled" | "disabled";
|
|
|
|
type FormState = {
|
|
name: string;
|
|
description: string;
|
|
sortOrder: string;
|
|
enabled: boolean;
|
|
items: Array<{
|
|
expenseTypeId: string;
|
|
description: string;
|
|
defaultAmount: string;
|
|
}>;
|
|
};
|
|
|
|
function resolveErrorMessage(error: unknown) {
|
|
return resolveUserErrorMessage(error, "请求失败,请稍后重试");
|
|
}
|
|
|
|
function emptyForm(expenseTypeId = ""): FormState {
|
|
return {
|
|
name: "",
|
|
description: "",
|
|
sortOrder: "0",
|
|
enabled: true,
|
|
items: [
|
|
{
|
|
expenseTypeId,
|
|
description: "",
|
|
defaultAmount: "",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function toForm(item: ReimbursementTemplate): FormState {
|
|
return {
|
|
name: item.name,
|
|
description: item.description ?? "",
|
|
sortOrder: String(item.sortOrder),
|
|
enabled: item.enabled,
|
|
items: item.items.length
|
|
? item.items.map((entry) => ({
|
|
expenseTypeId: entry.expenseTypeId,
|
|
description: entry.description,
|
|
defaultAmount: entry.defaultAmount ?? "",
|
|
}))
|
|
: [
|
|
{
|
|
expenseTypeId: "",
|
|
description: "",
|
|
defaultAmount: "",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
export function ReimbursementTemplatesPage() {
|
|
const queryClient = useQueryClient();
|
|
const [enabledFilter, setEnabledFilter] = useState<FilterEnabled>("all");
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [editingItem, setEditingItem] = useState<ReimbursementTemplate | null>(null);
|
|
const [form, setForm] = useState<FormState>(emptyForm());
|
|
const [confirmDelete, setConfirmDelete] = useState<ReimbursementTemplate | null>(null);
|
|
const [error, setError] = useState("");
|
|
|
|
const templatesQuery = useQuery({
|
|
queryKey: ["reimbursement-templates", enabledFilter],
|
|
queryFn: () =>
|
|
fetchReimbursementTemplates({
|
|
enabled:
|
|
enabledFilter === "all"
|
|
? undefined
|
|
: enabledFilter === "enabled",
|
|
}),
|
|
});
|
|
|
|
const expenseTypesQuery = useQuery({
|
|
queryKey: ["expense-types", "reimbursement-templates"],
|
|
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!dialogOpen) {
|
|
return;
|
|
}
|
|
|
|
if (editingItem) {
|
|
setForm(toForm(editingItem));
|
|
return;
|
|
}
|
|
|
|
const fallbackExpenseTypeId = expenseTypesQuery.data?.items?.[0]?.id ?? "";
|
|
setForm(emptyForm(fallbackExpenseTypeId));
|
|
}, [dialogOpen, editingItem, expenseTypesQuery.data?.items]);
|
|
|
|
const expenseTypeMap = useMemo(() => {
|
|
return new Map((expenseTypesQuery.data?.items ?? []).map((item) => [item.id, item]));
|
|
}, [expenseTypesQuery.data?.items]);
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const items: ReimbursementTemplateItem[] = form.items
|
|
.filter((item) => item.expenseTypeId.trim() || item.description.trim() || item.defaultAmount.trim())
|
|
.map((item) => ({
|
|
expenseTypeId: item.expenseTypeId,
|
|
description: item.description.trim(),
|
|
...(item.defaultAmount.trim() ? { defaultAmount: item.defaultAmount.trim() } : {}),
|
|
}));
|
|
|
|
const payload = {
|
|
name: form.name.trim(),
|
|
description: form.description.trim() || null,
|
|
sortOrder: Number(form.sortOrder || 0),
|
|
enabled: form.enabled,
|
|
items,
|
|
};
|
|
|
|
if (editingItem) {
|
|
return updateReimbursementTemplate(editingItem.id, payload);
|
|
}
|
|
|
|
return createReimbursementTemplate(payload);
|
|
},
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["reimbursement-templates"] });
|
|
setDialogOpen(false);
|
|
setEditingItem(null);
|
|
setError("");
|
|
toast.success(editingItem ? "模板已更新" : "模板已创建");
|
|
},
|
|
onError: (mutationError) => {
|
|
const message = resolveErrorMessage(mutationError);
|
|
setError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (templateId: string) => deleteReimbursementTemplate(templateId),
|
|
onSuccess: async () => {
|
|
await queryClient.invalidateQueries({ queryKey: ["reimbursement-templates"] });
|
|
setConfirmDelete(null);
|
|
setError("");
|
|
toast.success("模板已停用");
|
|
},
|
|
onError: (mutationError) => {
|
|
const message = resolveErrorMessage(mutationError);
|
|
setError(message);
|
|
toast.error(message);
|
|
},
|
|
});
|
|
|
|
const templates = templatesQuery.data?.items ?? [];
|
|
const canSubmit =
|
|
form.name.trim().length > 0 &&
|
|
form.items.some((item) => item.expenseTypeId.trim() && item.description.trim());
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PageHeader
|
|
title="报销模板管理"
|
|
subtitle="维护常见报销场景的预设明细,缩短员工建单时间。"
|
|
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:max-w-xs">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reimbursement-template-enabled-filter">启用状态</Label>
|
|
<select
|
|
id="reimbursement-template-enabled-filter"
|
|
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>
|
|
|
|
{templatesQuery.isLoading ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<div key={index} className="card-std h-56 animate-pulse bg-zinc-100" />
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{templatesQuery.isError ? (
|
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
{resolveErrorMessage(templatesQuery.error)}
|
|
</p>
|
|
) : null}
|
|
|
|
{!templatesQuery.isLoading && !templatesQuery.isError ? (
|
|
templates.length ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{templates.map((item) => (
|
|
<article key={item.id} className="card-std flex h-full flex-col gap-4 p-6">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-zinc-900">{item.name}</h3>
|
|
<p className="mt-1 text-sm text-zinc-600">{item.description || "未填写模板说明"}</p>
|
|
</div>
|
|
<span
|
|
className={`rounded-full px-3 py-1 text-xs font-medium ${
|
|
item.enabled
|
|
? "bg-emerald-50 text-emerald-700"
|
|
: "bg-zinc-100 text-zinc-600"
|
|
}`}
|
|
>
|
|
{item.enabled ? "启用" : "停用"}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs uppercase tracking-[0.2em] text-zinc-400">预设明细</div>
|
|
<div className="space-y-2">
|
|
{item.items.length ? (
|
|
item.items.map((entry, index) => (
|
|
<div key={`${item.id}-${index}`} className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
|
|
<div className="font-medium text-zinc-900">
|
|
{expenseTypeMap.get(entry.expenseTypeId)?.name ?? entry.expenseTypeId}
|
|
</div>
|
|
<div className="mt-1 text-zinc-600">{entry.description}</div>
|
|
<div className="mt-2 text-xs text-zinc-500">
|
|
默认金额:{entry.defaultAmount ? `¥${entry.defaultAmount}` : "未预填"}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="rounded-xl border border-dashed border-zinc-200 px-4 py-6 text-sm text-zinc-500">
|
|
该模板暂未配置预设明细。
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-auto flex items-center justify-between gap-3 text-sm text-zinc-500">
|
|
<span>排序:{item.sortOrder}</span>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
setEditingItem(item);
|
|
setDialogOpen(true);
|
|
}}
|
|
>
|
|
编辑
|
|
</Button>
|
|
<Button type="button" variant="secondary" onClick={() => setConfirmDelete(item)}>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="card-std px-6 py-10 text-sm text-zinc-500">当前筛选下暂无模板。</div>
|
|
)
|
|
) : null}
|
|
|
|
<Dialog
|
|
open={dialogOpen}
|
|
onOpenChange={(open) => {
|
|
setDialogOpen(open);
|
|
if (!open) {
|
|
setEditingItem(null);
|
|
setError("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-4xl">
|
|
<DialogHeader>
|
|
<DialogTitle>{editingItem ? "编辑报销模板" : "新增报销模板"}</DialogTitle>
|
|
<DialogDescription>模板明细会在创建报销时自动生成对应报销项。</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reimbursement-template-name">模板名称</Label>
|
|
<Input
|
|
id="reimbursement-template-name"
|
|
value={form.name}
|
|
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
|
placeholder="例如:国内差旅报销"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reimbursement-template-sort-order">排序</Label>
|
|
<Input
|
|
id="reimbursement-template-sort-order"
|
|
type="number"
|
|
value={form.sortOrder}
|
|
onChange={(event) => setForm((current) => ({ ...current, sortOrder: event.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label htmlFor="reimbursement-template-description">模板说明</Label>
|
|
<Textarea
|
|
id="reimbursement-template-description"
|
|
rows={2}
|
|
value={form.description}
|
|
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
|
|
placeholder="描述适用场景或填单注意事项"
|
|
/>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.enabled}
|
|
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
|
/>
|
|
启用该模板
|
|
</label>
|
|
|
|
<div className="space-y-3 rounded-2xl border border-zinc-200 p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h4 className="text-base font-semibold text-zinc-900">预设明细</h4>
|
|
<p className="text-xs text-zinc-500">费用类型与说明必填,默认金额可选并按字符串原样提交。</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() =>
|
|
setForm((current) => ({
|
|
...current,
|
|
items: [
|
|
...current.items,
|
|
{
|
|
expenseTypeId: expenseTypesQuery.data?.items?.[0]?.id ?? "",
|
|
description: "",
|
|
defaultAmount: "",
|
|
},
|
|
],
|
|
}))
|
|
}
|
|
>
|
|
添加明细
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{form.items.map((item, index) => (
|
|
<div key={index} className="grid gap-3 rounded-xl border border-zinc-200 p-4 md:grid-cols-[1.4fr_1.8fr_1fr_auto]">
|
|
<div className="space-y-1">
|
|
<Label>费用类型</Label>
|
|
<select
|
|
className="select-std"
|
|
value={item.expenseTypeId}
|
|
onChange={(event) =>
|
|
setForm((current) => ({
|
|
...current,
|
|
items: current.items.map((entry, entryIndex) =>
|
|
entryIndex === index
|
|
? { ...entry, expenseTypeId: event.target.value }
|
|
: entry,
|
|
),
|
|
}))
|
|
}
|
|
>
|
|
<option value="">请选择费用类型</option>
|
|
{(expenseTypesQuery.data?.items ?? []).map((expenseType) => (
|
|
<option key={expenseType.id} value={expenseType.id}>
|
|
{expenseType.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>说明</Label>
|
|
<Input
|
|
value={item.description}
|
|
onChange={(event) =>
|
|
setForm((current) => ({
|
|
...current,
|
|
items: current.items.map((entry, entryIndex) =>
|
|
entryIndex === index
|
|
? { ...entry, description: event.target.value }
|
|
: entry,
|
|
),
|
|
}))
|
|
}
|
|
placeholder="例如:出差往返交通"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<Label>默认金额</Label>
|
|
<Input
|
|
value={item.defaultAmount}
|
|
onChange={(event) =>
|
|
setForm((current) => ({
|
|
...current,
|
|
items: current.items.map((entry, entryIndex) =>
|
|
entryIndex === index
|
|
? { ...entry, defaultAmount: event.target.value }
|
|
: entry,
|
|
),
|
|
}))
|
|
}
|
|
placeholder="可选,例 100.00"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-end">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
disabled={form.items.length <= 1}
|
|
onClick={() =>
|
|
setForm((current) => ({
|
|
...current,
|
|
items: current.items.filter((_, entryIndex) => entryIndex !== index),
|
|
}))
|
|
}
|
|
>
|
|
删除
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</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 ReimbursementTemplatesPage;
|