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"
|
| "rejected"
|
||||||
| "cancelled";
|
| "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 = {
|
export type ReimbursementListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
formNumber: string | null;
|
formNumber: string | null;
|
||||||
@@ -81,6 +96,7 @@ export type ReimbursementDetail = {
|
|||||||
assignee_role: string | null;
|
assignee_role: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
items: ReimbursementItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ReimbursementListQuery = {
|
export type ReimbursementListQuery = {
|
||||||
@@ -96,6 +112,29 @@ export type ReimbursementPayPayload = {
|
|||||||
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
|
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>) {
|
function toQueryString(query: Record<string, string | number | undefined>) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
@@ -150,3 +189,39 @@ export async function payReimbursement(reimbursementId: string, payload: Reimbur
|
|||||||
body: JSON.stringify(payload),
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||||
import {
|
import {
|
||||||
|
createReimbursementItem,
|
||||||
|
deleteReimbursementItem,
|
||||||
fetchReimbursementDetail,
|
fetchReimbursementDetail,
|
||||||
payReimbursement,
|
payReimbursement,
|
||||||
submitReimbursement,
|
submitReimbursement,
|
||||||
|
updateReimbursementItem,
|
||||||
withdrawReimbursement,
|
withdrawReimbursement,
|
||||||
|
type ReimbursementItem,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
@@ -15,8 +20,11 @@ import { PageHeader } from "../components/common/PageHeader";
|
|||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Card } from "../components/ui/card";
|
import { Card } from "../components/ui/card";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
|
||||||
|
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||||
|
|
||||||
export function ReimbursementDetailPage() {
|
export function ReimbursementDetailPage() {
|
||||||
const { reimbursementId = "" } = useParams();
|
const { reimbursementId = "" } = useParams();
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
@@ -30,12 +38,24 @@ export function ReimbursementDetailPage() {
|
|||||||
);
|
);
|
||||||
const [actionError, setActionError] = useState("");
|
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({
|
const detailQuery = useQuery({
|
||||||
enabled: Boolean(reimbursementId),
|
enabled: Boolean(reimbursementId),
|
||||||
queryKey: ["reimbursements", reimbursementId],
|
queryKey: ["reimbursements", reimbursementId],
|
||||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const expenseTypesQuery = useQuery({
|
||||||
|
queryKey: ["expenseTypes", "enabled"],
|
||||||
|
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (action: "submit" | "withdraw" | "pay") => {
|
mutationFn: async (action: "submit" | "withdraw" | "pay") => {
|
||||||
if (!reimbursementId) {
|
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) {
|
if (!reimbursementId) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -117,6 +212,34 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
const detail = detailQuery.data;
|
const detail = detailQuery.data;
|
||||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -139,6 +262,212 @@ export function ReimbursementDetailPage() {
|
|||||||
<div>说明:{detail.description || "-"}</div>
|
<div>说明:{detail.description || "-"}</div>
|
||||||
</Card>
|
</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">
|
<Card className="space-y-4 p-6">
|
||||||
<h3 className="text-base font-semibold text-slate-900">可执行动作</h3>
|
<h3 className="text-base font-semibold text-slate-900">可执行动作</h3>
|
||||||
|
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户