From 88c987ae8f85c5a3827b2dc418be4c16966df213 Mon Sep 17 00:00:00 2001 From: shunli Date: Wed, 8 Apr 2026 21:16:30 +0800 Subject: [PATCH] =?UTF-8?q?R57=20A1-A6=EF=BC=9A=E6=8A=A5=E9=94=80=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E4=B8=8E=E5=B7=AE=E6=97=85=E7=94=B3=E8=AF=B7=E5=85=B3?= =?UTF-8?q?=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/reimbursementTemplates.ts | 86 +++ client/src/api/reimbursements.ts | 67 +- client/src/pages/ReimbursementDetailPage.tsx | 37 + .../src/pages/ReimbursementTemplatesPage.tsx | 513 +++++++++++++ client/src/pages/ReimbursementsPage.tsx | 138 +++- client/src/pages/SystemSettingsPage.tsx | 1 + client/src/router/index.tsx | 10 + .../router/lazy/ReimbursementTemplatesPage.ts | 1 + client/src/router/lazy/index.ts | 1 + ...2026-04-08_r57-reimbursement-templates.csv | 7 + .../migration.sql | 28 + server/prisma/schema.prisma | 21 + server/src/app.js | 2 + server/src/routes/reimbursementTemplates.js | 417 +++++++++++ server/src/routes/reimbursements.js | 599 +++++++++++++-- .../routes/reimbursementTemplates.test.js | 353 +++++++++ .../unit/routes/reimbursements.create.test.js | 681 ++++++++++++++++++ 17 files changed, 2886 insertions(+), 76 deletions(-) create mode 100644 client/src/api/reimbursementTemplates.ts create mode 100644 client/src/pages/ReimbursementTemplatesPage.tsx create mode 100644 client/src/router/lazy/ReimbursementTemplatesPage.ts create mode 100644 issues/2026-04-08_r57-reimbursement-templates.csv create mode 100644 server/prisma/migrations/20260408120000_r57_reimbursement_templates/migration.sql create mode 100644 server/src/routes/reimbursementTemplates.js create mode 100644 server/tests/unit/routes/reimbursementTemplates.test.js create mode 100644 server/tests/unit/routes/reimbursements.create.test.js diff --git a/client/src/api/reimbursementTemplates.ts b/client/src/api/reimbursementTemplates.ts new file mode 100644 index 00000000..e9aab2d9 --- /dev/null +++ b/client/src/api/reimbursementTemplates.ts @@ -0,0 +1,86 @@ +import { apiRequestData } from "./http"; + +export type ReimbursementTemplateItem = { + expenseTypeId: string; + description: string; + defaultAmount?: string; +}; + +export type ReimbursementTemplate = { + id: string; + name: string; + description: string | null; + items: ReimbursementTemplateItem[]; + sortOrder: number; + enabled: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +export type ReimbursementTemplateListResponse = { + enabled: boolean | null; + items: ReimbursementTemplate[]; +}; + +export type ReimbursementTemplateListQuery = { + enabled?: boolean; +}; + +export type CreateReimbursementTemplatePayload = { + name: string; + description?: string | null; + items?: ReimbursementTemplateItem[]; + sortOrder?: number; + enabled?: boolean; +}; + +export type UpdateReimbursementTemplatePayload = { + name?: string; + description?: string | null; + items?: ReimbursementTemplateItem[]; + sortOrder?: number; + enabled?: boolean; +}; + +function toQueryString(query: Record) { + 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 fetchReimbursementTemplates(query: ReimbursementTemplateListQuery = {}) { + return apiRequestData( + `/api/reimbursement-templates${toQueryString(query as Record)}`, + ); +} + +export async function createReimbursementTemplate(payload: CreateReimbursementTemplatePayload) { + return apiRequestData("/api/reimbursement-templates", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateReimbursementTemplate( + templateId: string, + payload: UpdateReimbursementTemplatePayload, +) { + return apiRequestData(`/api/reimbursement-templates/${templateId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function deleteReimbursementTemplate(templateId: string) { + return apiRequestData(`/api/reimbursement-templates/${templateId}`, { + method: "DELETE", + }); +} diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 406b7ffb..b7792767 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -24,6 +24,15 @@ export type ReimbursementItem = { } | null; }; +export type LinkedTravelRequestSummary = { + id: string; + requestNo: string | null; + purpose: string | null; + startDate: string | null; + endDate: string | null; + estimatedAmount: string; +}; + export type ReimbursementListItem = { id: string; formNumber: string | null; @@ -113,6 +122,8 @@ export type ReimbursementDetail = { totalAmount: string | null; description: string | null; projectId: string | null; + linkedTravelRequestId: string | null; + linkedTravelRequest: LinkedTravelRequestSummary | null; project: { id: string; projectNo: string; @@ -207,6 +218,48 @@ export type ReimbursementPayPayload = { paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other"; }; +export type ReimbursementCreatePayload = { + ownerEmployeeId?: string; + reimbursementDate?: string; + description?: string; + templateId?: string; + linkedTravelRequestId?: string; +}; + +export type ReimbursementCreateResponse = { + id: string; + formNumber: string | null; + submitterId: string; + ownerId: string; + reimbursementDate: string | null; + status: ReimbursementStatus; + description: string | null; + projectId: string | null; + project: { + id: string; + projectNo: string; + name: string; + } | null; + totalAmount: string; + linkedTravelRequestId: string | null; + linkedTravelRequest: LinkedTravelRequestSummary | null; + items: ReimbursementItem[]; +}; + +export type LinkableTravelRequestSummary = { + id: string; + requestNo: string | null; + purpose: string; + startDate: string | null; + endDate: string | null; + estimatedAmount: string; +}; + +export type LinkableTravelRequestListResponse = { + ownerEmployeeId: string; + items: LinkableTravelRequestSummary[]; +}; + export type ReimbursementItemPayload = { expenseTypeId: string; expenseDate: string; @@ -301,17 +354,19 @@ export async function fetchReimbursementDetail(reimbursementId: string) { return apiRequestData(`/api/reimbursements/${reimbursementId}`); } -export async function createReimbursement(payload: { - ownerEmployeeId?: string; - reimbursementDate?: string; - description?: string; -} = {}) { - return apiRequestData<{ id: string }>(`/api/reimbursements`, { +export async function createReimbursement(payload: ReimbursementCreatePayload = {}) { + return apiRequestData(`/api/reimbursements`, { method: "POST", body: JSON.stringify(payload), }); } +export async function fetchLinkableTravelRequests(query: { ownerEmployeeId?: string } = {}) { + return apiRequestData( + `/api/reimbursements/linkable-travel-requests${toQueryString(query as Record)}`, + ); +} + export async function precheckReimbursement(reimbursementId: string) { return apiRequestData(`/api/reimbursements/${reimbursementId}/precheck`, { method: "POST", diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index a68805e3..22b4f2f9 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -503,6 +503,43 @@ export function ReimbursementDetailPage() {
说明:{detail.description || "-"}
+ {detail.linkedTravelRequest ? ( +
+
+
+

+ 关联差旅申请:{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id} +

+

{detail.linkedTravelRequest.purpose || "未填写出差目的"}

+
+ + 查看申请 + +
+
+
+
日期范围
+
+ {detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"} +
+
+
+
预估金额
+
¥{detail.linkedTravelRequest.estimatedAmount}
+
+
+
申请编号
+
+ {detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id} +
+
+
+
+ ) : null} +
diff --git a/client/src/pages/ReimbursementTemplatesPage.tsx b/client/src/pages/ReimbursementTemplatesPage.tsx new file mode 100644 index 00000000..c4bc3d12 --- /dev/null +++ b/client/src/pages/ReimbursementTemplatesPage.tsx @@ -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("all"); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingItem, setEditingItem] = useState(null); + const [form, setForm] = useState(emptyForm()); + const [confirmDelete, setConfirmDelete] = useState(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 ( +
+ { + setEditingItem(null); + setDialogOpen(true); + }} + > + 新增模板 + + )} + /> + + {error ? ( +

{error}

+ ) : null} + +
+
+
+ + +
+
+
+ + {templatesQuery.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : null} + + {templatesQuery.isError ? ( +

+ {resolveErrorMessage(templatesQuery.error)} +

+ ) : null} + + {!templatesQuery.isLoading && !templatesQuery.isError ? ( + templates.length ? ( +
+ {templates.map((item) => ( +
+
+
+

{item.name}

+

{item.description || "未填写模板说明"}

+
+ + {item.enabled ? "启用" : "停用"} + +
+ +
+
预设明细
+
+ {item.items.length ? ( + item.items.map((entry, index) => ( +
+
+ {expenseTypeMap.get(entry.expenseTypeId)?.name ?? entry.expenseTypeId} +
+
{entry.description}
+
+ 默认金额:{entry.defaultAmount ? `¥${entry.defaultAmount}` : "未预填"} +
+
+ )) + ) : ( +
+ 该模板暂未配置预设明细。 +
+ )} +
+
+ +
+ 排序:{item.sortOrder} +
+ + +
+
+
+ ))} +
+ ) : ( +
当前筛选下暂无模板。
+ ) + ) : null} + + { + setDialogOpen(open); + if (!open) { + setEditingItem(null); + setError(""); + } + }} + > + + + {editingItem ? "编辑报销模板" : "新增报销模板"} + 模板明细会在创建报销时自动生成对应报销项。 + + +
+
+
+ + setForm((current) => ({ ...current, name: event.target.value }))} + placeholder="例如:国内差旅报销" + /> +
+ +
+ + setForm((current) => ({ ...current, sortOrder: event.target.value }))} + /> +
+
+ +
+ +