R57 A1-A6:报销模板与差旅申请关联
这个提交包含在:
@@ -0,0 +1,513 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||
import {
|
||||
createReimbursementTemplate,
|
||||
deleteReimbursementTemplate,
|
||||
fetchReimbursementTemplates,
|
||||
updateReimbursementTemplate,
|
||||
type ReimbursementTemplate,
|
||||
type ReimbursementTemplateItem,
|
||||
} from "../api/reimbursementTemplates";
|
||||
import { isApiError } from "../api/types";
|
||||
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) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试";
|
||||
}
|
||||
|
||||
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;
|
||||
在新工单中引用
屏蔽一个用户