diff --git a/client/src/api/expenseTypes.ts b/client/src/api/expenseTypes.ts new file mode 100644 index 00000000..88a4f9aa --- /dev/null +++ b/client/src/api/expenseTypes.ts @@ -0,0 +1,31 @@ +import { apiRequestData } from "./http"; + +export type ExpenseType = { + id: string; + code: string; + name: string; + description: string | null; + category: string; + sortOrder: number; + accountCodeDefault: string | null; + isSystemDefault: boolean; + enabled: boolean; + createdAt: string | null; + updatedAt: string | null; +}; + +export type ExpenseTypeListResponse = { + enabled: boolean | null; + items: ExpenseType[]; +}; + +export async function fetchExpenseTypes(query: { enabled?: boolean } = {}) { + const params = new URLSearchParams(); + if (query.enabled != null) { + params.set("enabled", query.enabled ? "true" : "false"); + } + + const qs = params.toString(); + return apiRequestData(`/api/expense-types${qs ? `?${qs}` : ""}`); +} + diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 12e72e9f..4aaa9a40 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -8,6 +8,21 @@ export type ReimbursementStatus = | "rejected" | "cancelled"; +export type ReimbursementItem = { + id: string; + expenseTypeId: string; + expenseDate: string | null; + amount: string; + description: string | null; + expenseType: { + id: string; + code: string; + name: string; + category: string; + enabled: boolean; + } | null; +}; + export type ReimbursementListItem = { id: string; formNumber: string | null; @@ -81,6 +96,7 @@ export type ReimbursementDetail = { assignee_role: string | null; } | null; } | null; + items: ReimbursementItem[]; }; export type ReimbursementListQuery = { @@ -96,6 +112,29 @@ export type ReimbursementPayPayload = { paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other"; }; +export type ReimbursementItemPayload = { + expenseTypeId: string; + expenseDate: string; + amount: string; + description?: string; +}; + +export type ReimbursementItemMutationResponse = { + item: { + id: string; + expenseTypeId: string; + expenseDate: string; + amount: string; + description: string | null; + }; + totalAmount: string; +}; + +export type ReimbursementItemDeleteResponse = { + outcome: "deleted"; + totalAmount: string; +}; + function toQueryString(query: Record) { const params = new URLSearchParams(); @@ -150,3 +189,39 @@ export async function payReimbursement(reimbursementId: string, payload: Reimbur body: JSON.stringify(payload), }); } + +export async function createReimbursementItem( + reimbursementId: string, + payload: ReimbursementItemPayload, +) { + return apiRequestData( + `/api/reimbursements/${reimbursementId}/items`, + { + method: "POST", + body: JSON.stringify(payload), + }, + ); +} + +export async function updateReimbursementItem( + reimbursementId: string, + itemId: string, + payload: Partial, +) { + return apiRequestData( + `/api/reimbursements/${reimbursementId}/items/${itemId}`, + { + method: "PATCH", + body: JSON.stringify(payload), + }, + ); +} + +export async function deleteReimbursementItem(reimbursementId: string, itemId: string) { + return apiRequestData( + `/api/reimbursements/${reimbursementId}/items/${itemId}`, + { + method: "DELETE", + }, + ); +} diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index addae9de..6ff297e1 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -3,11 +3,16 @@ import { Link, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; +import { fetchExpenseTypes } from "../api/expenseTypes"; import { + createReimbursementItem, + deleteReimbursementItem, fetchReimbursementDetail, payReimbursement, submitReimbursement, + updateReimbursementItem, withdrawReimbursement, + type ReimbursementItem, } from "../api/reimbursements"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; @@ -15,8 +20,11 @@ import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; import { Card } from "../components/ui/card"; import { Input } from "../components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Textarea } from "../components/ui/textarea"; +type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null; + export function ReimbursementDetailPage() { const { reimbursementId = "" } = useParams(); const { permissions } = useAuth(); @@ -30,12 +38,24 @@ export function ReimbursementDetailPage() { ); const [actionError, setActionError] = useState(""); + const [itemEditor, setItemEditor] = useState(null); + const [itemExpenseTypeId, setItemExpenseTypeId] = useState(""); + const [itemExpenseDate, setItemExpenseDate] = useState(""); + const [itemAmount, setItemAmount] = useState(""); + const [itemDescription, setItemDescription] = useState(""); + const [itemError, setItemError] = useState(""); + const detailQuery = useQuery({ enabled: Boolean(reimbursementId), queryKey: ["reimbursements", reimbursementId], queryFn: () => fetchReimbursementDetail(reimbursementId), }); + const expenseTypesQuery = useQuery({ + queryKey: ["expenseTypes", "enabled"], + queryFn: () => fetchExpenseTypes({ enabled: true }), + }); + const mutation = useMutation({ mutationFn: async (action: "submit" | "withdraw" | "pay") => { if (!reimbursementId) { @@ -80,6 +100,81 @@ export function ReimbursementDetailPage() { }, }); + const itemSaveMutation = useMutation({ + mutationFn: async () => { + if (!reimbursementId) { + throw new Error("missing reimbursementId"); + } + if (!itemEditor) { + throw new Error("missing itemEditor"); + } + + const payload = { + expenseTypeId: itemExpenseTypeId, + expenseDate: itemExpenseDate, + amount: itemAmount, + description: itemDescription.trim() || undefined, + }; + + if (itemEditor.mode === "create") { + return createReimbursementItem(reimbursementId, payload); + } + + return updateReimbursementItem(reimbursementId, itemEditor.itemId, payload); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + ]); + setItemEditor(null); + setItemError(""); + }, + onError: (error) => { + if (isApiError(error)) { + setItemError(`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`); + return; + } + + if (error instanceof Error) { + setItemError(error.message); + return; + } + + setItemError("保存明细失败,请稍后重试"); + }, + }); + + const itemDeleteMutation = useMutation({ + mutationFn: async (itemId: string) => { + if (!reimbursementId) { + throw new Error("missing reimbursementId"); + } + + return deleteReimbursementItem(reimbursementId, itemId); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["reimbursements"] }), + queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }), + ]); + setItemError(""); + }, + onError: (error) => { + if (isApiError(error)) { + setItemError(`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`); + return; + } + + if (error instanceof Error) { + setItemError(error.message); + return; + } + + setItemError("删除明细失败,请稍后重试"); + }, + }); + if (!reimbursementId) { return ( @@ -117,6 +212,34 @@ export function ReimbursementDetailPage() { const detail = detailQuery.data; const canPay = hasPermission(permissions, "expense.finance.pay"); + const canEditItems = ["draft", "rejected"].includes(detail.status); + + const expenseTypes = expenseTypesQuery.data?.items ?? []; + const ensureEditorDefaults = (item?: ReimbursementItem) => { + const defaultDate = + item?.expenseDate ?? + detail.reimbursementDate ?? + new Date().toISOString().slice(0, 10); + + const defaultExpenseTypeId = + item?.expenseTypeId ?? expenseTypes[0]?.id ?? itemExpenseTypeId; + + setItemExpenseDate(defaultDate); + setItemExpenseTypeId(defaultExpenseTypeId || ""); + setItemAmount(item?.amount ?? ""); + setItemDescription(item?.description ?? ""); + setItemError(""); + }; + + const startCreateItem = () => { + setItemEditor({ mode: "create" }); + ensureEditorDefaults(); + }; + + const startEditItem = (item: ReimbursementItem) => { + setItemEditor({ mode: "edit", itemId: item.id }); + ensureEditorDefaults(item); + }; return (
@@ -139,6 +262,212 @@ export function ReimbursementDetailPage() {
说明:{detail.description || "-"}
+ +
+
+

报销明细

+

明细变更会自动重算总金额。

+
+ + {canEditItems ? ( + + ) : ( +

当前状态不可编辑明细

+ )} +
+ + {itemError ? ( +

+ {itemError} +

+ ) : null} + +
+ + + + 费用类型 + 发生日期 + 金额 + 说明 + 操作 + + + + {detail.items.length ? ( + detail.items.map((item) => ( + + +
+ {item.expenseType?.name ?? item.expenseTypeId} +
+
{item.expenseType?.code ?? "-"}
+
+ {item.expenseDate ?? "-"} + {item.amount} + {item.description ?? "-"} + + {canEditItems ? ( +
+ + +
+ ) : null} +
+
+ )) + ) : ( + + + 暂无明细 + + + )} +
+
+
+ + {itemEditor ? ( +
+

+ {itemEditor.mode === "create" ? "新增明细" : "编辑明细"} +

+ + {expenseTypesQuery.isLoading ? ( +

加载费用类型中...

+ ) : null} + + {expenseTypesQuery.isError ? ( +

费用类型加载失败,请刷新页面重试。

+ ) : null} + +
+
+ + +
+ +
+ + setItemExpenseDate(event.target.value)} + type="date" + value={itemExpenseDate} + /> +
+ +
+ + setItemAmount(event.target.value)} + placeholder="例如 123.45" + value={itemAmount} + /> +
+ +
+ +