文件
hyb-oa/client/src/pages/ReimbursementTemplatesPage.tsx
T

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;