feat(client): 报销明细编辑器与费用类型下拉(F6)
- 报销详情新增明细列表与新增/编辑/删除入口 - 接入费用类型只读接口并保持总金额自动刷新 - 补 data-testid 以便 E2E/回归
这个提交包含在:
@@ -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}` : ""}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户