feat(client): 报销明细编辑器与费用类型下拉(F6)

- 报销详情新增明细列表与新增/编辑/删除入口
- 接入费用类型只读接口并保持总金额自动刷新
- 补 data-testid 以便 E2E/回归
这个提交包含在:
2026-02-09 17:54:59 +08:00
父节点 2bb804bc94
当前提交 a4f38792f1
共修改 3 个文件,包含 435 行新增和 0 行删除
+31
查看文件
@@ -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<ExpenseTypeListResponse>(`/api/expense-types${qs ? `?${qs}` : ""}`);
}
+75
查看文件
@@ -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<string, string | number | undefined>) {
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<ReimbursementItemMutationResponse>(
`/api/reimbursements/${reimbursementId}/items`,
{
method: "POST",
body: JSON.stringify(payload),
},
);
}
export async function updateReimbursementItem(
reimbursementId: string,
itemId: string,
payload: Partial<ReimbursementItemPayload>,
) {
return apiRequestData<ReimbursementItemMutationResponse>(
`/api/reimbursements/${reimbursementId}/items/${itemId}`,
{
method: "PATCH",
body: JSON.stringify(payload),
},
);
}
export async function deleteReimbursementItem(reimbursementId: string, itemId: string) {
return apiRequestData<ReimbursementItemDeleteResponse>(
`/api/reimbursements/${reimbursementId}/items/${itemId}`,
{
method: "DELETE",
},
);
}
@@ -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<ItemEditorState>(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 (
<Card className="p-6">
@@ -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 (
<div className="space-y-6">
@@ -139,6 +262,212 @@ export function ReimbursementDetailPage() {
<div>说明:{detail.description || "-"}</div>
</Card>
<Card className="space-y-4 p-6" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold text-slate-900">报销明细</h3>
<p className="text-xs text-slate-500">明细变更会自动重算总金额。</p>
</div>
{canEditItems ? (
<Button
data-testid="reimbursement-item-add"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
variant="secondary"
>
新增明细
</Button>
) : (
<p className="text-xs text-slate-500">当前状态不可编辑明细</p>
)}
</div>
{itemError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
data-testid="reimbursement-item-error"
>
{itemError}
</p>
) : null}
<div className="rounded-lg border border-slate-200">
<Table data-testid="reimbursement-items-table">
<TableHeader>
<tr>
<TableHead>费用类型</TableHead>
<TableHead>发生日期</TableHead>
<TableHead>金额</TableHead>
<TableHead>说明</TableHead>
<TableHead className="w-32">操作</TableHead>
</tr>
</TableHeader>
<TableBody>
{detail.items.length ? (
detail.items.map((item) => (
<TableRow data-testid="reimbursement-item-row" key={item.id}>
<TableCell>
<div className="font-medium text-slate-900">
{item.expenseType?.name ?? item.expenseTypeId}
</div>
<div className="text-xs text-slate-500">{item.expenseType?.code ?? "-"}</div>
</TableCell>
<TableCell>{item.expenseDate ?? "-"}</TableCell>
<TableCell>{item.amount}</TableCell>
<TableCell>{item.description ?? "-"}</TableCell>
<TableCell>
{canEditItems ? (
<div className="flex flex-wrap gap-2">
<Button
data-testid="reimbursement-item-edit"
size="sm"
type="button"
variant="secondary"
onClick={() => startEditItem(item)}
>
编辑
</Button>
<Button
data-testid="reimbursement-item-delete"
size="sm"
type="button"
variant="secondary"
disabled={itemDeleteMutation.isPending}
onClick={() => {
if (!window.confirm("确认删除该明细?")) return;
itemDeleteMutation.mutate(item.id);
}}
>
删除
</Button>
</div>
) : null}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-slate-500" colSpan={5}>
暂无明细
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{itemEditor ? (
<div className="space-y-3 rounded-lg border border-slate-200 p-4" data-testid="reimbursement-item-editor">
<p className="text-sm font-medium text-slate-800">
{itemEditor.mode === "create" ? "新增明细" : "编辑明细"}
</p>
{expenseTypesQuery.isLoading ? (
<p className="text-sm text-slate-500">加载费用类型中...</p>
) : null}
{expenseTypesQuery.isError ? (
<p className="text-sm text-red-600">费用类型加载失败,请刷新页面重试。</p>
) : null}
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="expenseTypeId">
费用类型
</label>
<select
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
data-testid="reimbursement-item-expense-type"
disabled={expenseTypes.length === 0 || itemSaveMutation.isPending}
id="expenseTypeId"
onChange={(event) => setItemExpenseTypeId(event.target.value)}
value={itemExpenseTypeId}
>
{expenseTypes.length === 0 ? <option value="">暂无可用费用类型</option> : null}
{expenseTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="expenseDate">
发生日期
</label>
<Input
data-testid="reimbursement-item-expense-date"
disabled={itemSaveMutation.isPending}
id="expenseDate"
onChange={(event) => setItemExpenseDate(event.target.value)}
type="date"
value={itemExpenseDate}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="amount">
金额
</label>
<Input
data-testid="reimbursement-item-amount"
disabled={itemSaveMutation.isPending}
id="amount"
onChange={(event) => setItemAmount(event.target.value)}
placeholder="例如 123.45"
value={itemAmount}
/>
</div>
<div className="space-y-1 md:col-span-4">
<label className="text-xs text-slate-600" htmlFor="itemDescription">
说明
</label>
<Textarea
data-testid="reimbursement-item-description"
disabled={itemSaveMutation.isPending}
id="itemDescription"
onChange={(event) => setItemDescription(event.target.value)}
rows={2}
value={itemDescription}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
data-testid="reimbursement-item-save"
disabled={
itemSaveMutation.isPending ||
!itemExpenseTypeId ||
!itemExpenseDate ||
!itemAmount.trim()
}
onClick={() => itemSaveMutation.mutate()}
type="button"
>
{itemSaveMutation.isPending ? "保存中..." : "保存明细"}
</Button>
<Button
data-testid="reimbursement-item-cancel"
variant="secondary"
disabled={itemSaveMutation.isPending}
onClick={() => {
setItemEditor(null);
setItemError("");
}}
type="button"
>
取消
</Button>
</div>
</div>
) : null}
</Card>
<Card className="space-y-4 p-6">
<h3 className="text-base font-semibold text-slate-900">可执行动作</h3>