refactor(ui): [UI7-1] 深度翻新报销、发票与用户管理等核心视窗
这个提交包含在:
@@ -14,14 +14,21 @@ import {
|
||||
withdrawReimbursement,
|
||||
type ReimbursementItem,
|
||||
} from "../api/reimbursements";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
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";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
ReimbursementItemEditor,
|
||||
type ItemEditorInitialValues,
|
||||
type ItemEditorSaveData,
|
||||
} from "./reimbursement-detail/ReimbursementItemEditor";
|
||||
|
||||
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
|
||||
|
||||
@@ -30,20 +37,17 @@ export function ReimbursementDetailPage() {
|
||||
const { permissions } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
|
||||
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
|
||||
const [paymentDate, setPaymentDate] = useState("2026-02-08");
|
||||
const [paymentChannel, setPaymentChannel] = useState<"bank" | "alipay" | "wechat" | "cash" | "other">(
|
||||
"bank",
|
||||
);
|
||||
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 [confirmDeleteItemId, setConfirmDeleteItemId] = useState("");
|
||||
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
|
||||
expenseTypeId: "",
|
||||
expenseDate: "",
|
||||
amount: "",
|
||||
description: "",
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId),
|
||||
@@ -57,51 +61,55 @@ export function ReimbursementDetailPage() {
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (action: "submit" | "withdraw" | "pay") => {
|
||||
mutationFn: async (params: { action: "submit" } | { action: "withdraw"; comment: string } | { action: "pay"; payload: PayPayload }) => {
|
||||
if (!reimbursementId) {
|
||||
throw new Error("missing reimbursementId");
|
||||
}
|
||||
|
||||
if (action === "submit") {
|
||||
if (params.action === "submit") {
|
||||
return submitReimbursement(reimbursementId);
|
||||
}
|
||||
|
||||
if (action === "withdraw") {
|
||||
return withdrawReimbursement(reimbursementId, withdrawComment.trim());
|
||||
if (params.action === "withdraw") {
|
||||
return withdrawReimbursement(reimbursementId, params.comment);
|
||||
}
|
||||
|
||||
return payReimbursement(reimbursementId, {
|
||||
paymentVoucherNo: paymentVoucherNo.trim() || undefined,
|
||||
paymentDate: paymentDate || undefined,
|
||||
paymentChannel,
|
||||
paymentVoucherNo: params.payload.paymentVoucherNo || undefined,
|
||||
paymentDate: params.payload.paymentDate || undefined,
|
||||
paymentChannel: params.payload.paymentChannel,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_data, params) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
]);
|
||||
setActionError("");
|
||||
const actionMessages: Record<string, string> = { submit: "已提交", withdraw: "已撤回", pay: "付款成功" };
|
||||
toast.success(actionMessages[params.action] ?? "操作成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isApiError(error)) {
|
||||
setActionError(
|
||||
`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`,
|
||||
);
|
||||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
setActionError(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
setActionError(error.message);
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setActionError("操作失败,请稍后重试");
|
||||
toast.error("操作失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
const itemSaveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (data: ItemEditorSaveData) => {
|
||||
if (!reimbursementId) {
|
||||
throw new Error("missing reimbursementId");
|
||||
}
|
||||
@@ -110,10 +118,10 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
const payload = {
|
||||
expenseTypeId: itemExpenseTypeId,
|
||||
expenseDate: itemExpenseDate,
|
||||
amount: itemAmount,
|
||||
description: itemDescription.trim() || undefined,
|
||||
expenseTypeId: data.expenseTypeId,
|
||||
expenseDate: data.expenseDate,
|
||||
amount: data.amount,
|
||||
description: data.description.trim() || undefined,
|
||||
};
|
||||
|
||||
if (itemEditor.mode === "create") {
|
||||
@@ -129,19 +137,24 @@ export function ReimbursementDetailPage() {
|
||||
]);
|
||||
setItemEditor(null);
|
||||
setItemError("");
|
||||
toast.success("明细已保存");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isApiError(error)) {
|
||||
setItemError(`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`);
|
||||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
setItemError(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
setItemError(error.message);
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setItemError("保存明细失败,请稍后重试");
|
||||
toast.error("保存明细失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -159,19 +172,24 @@ export function ReimbursementDetailPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
]);
|
||||
setItemError("");
|
||||
toast.success("明细已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isApiError(error)) {
|
||||
setItemError(`${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`);
|
||||
const msg = `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
setItemError(msg);
|
||||
toast.error(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
setItemError(error.message);
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
setItemError("删除明细失败,请稍后重试");
|
||||
toast.error("删除明细失败,请稍后重试");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,11 +202,7 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-slate-500">加载报销详情中...</p>
|
||||
</Card>
|
||||
);
|
||||
return <CardSkeleton lines={8} />;
|
||||
}
|
||||
|
||||
if (detailQuery.isError || !detailQuery.data) {
|
||||
@@ -216,30 +230,43 @@ export function ReimbursementDetailPage() {
|
||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||
|
||||
const expenseTypes = expenseTypesQuery.data?.items ?? [];
|
||||
const ensureEditorDefaults = (item?: ReimbursementItem) => {
|
||||
|
||||
const computeEditorDefaults = (item?: ReimbursementItem): ItemEditorInitialValues => {
|
||||
const defaultDate =
|
||||
item?.expenseDate ??
|
||||
detail.reimbursementDate ??
|
||||
new Date().toISOString().slice(0, 10);
|
||||
|
||||
const defaultExpenseTypeId =
|
||||
item?.expenseTypeId ?? expenseTypes[0]?.id ?? itemExpenseTypeId;
|
||||
item?.expenseTypeId ?? expenseTypes[0]?.id ?? "";
|
||||
|
||||
setItemExpenseDate(defaultDate);
|
||||
setItemExpenseTypeId(defaultExpenseTypeId || "");
|
||||
setItemAmount(item?.amount ?? "");
|
||||
setItemDescription(item?.description ?? "");
|
||||
setItemError("");
|
||||
return {
|
||||
expenseDate: defaultDate,
|
||||
expenseTypeId: defaultExpenseTypeId,
|
||||
amount: item?.amount ?? "",
|
||||
description: item?.description ?? "",
|
||||
};
|
||||
};
|
||||
|
||||
const startCreateItem = () => {
|
||||
setItemEditor({ mode: "create" });
|
||||
ensureEditorDefaults();
|
||||
setEditorInitialValues(computeEditorDefaults());
|
||||
setItemError("");
|
||||
};
|
||||
|
||||
const startEditItem = (item: ReimbursementItem) => {
|
||||
setItemEditor({ mode: "edit", itemId: item.id });
|
||||
ensureEditorDefaults(item);
|
||||
setEditorInitialValues(computeEditorDefaults(item));
|
||||
setItemError("");
|
||||
};
|
||||
|
||||
const handleItemSave = (data: ItemEditorSaveData) => {
|
||||
itemSaveMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleItemCancel = () => {
|
||||
setItemEditor(null);
|
||||
setItemError("");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -359,10 +386,7 @@ export function ReimbursementDetailPage() {
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={itemDeleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (!window.confirm("确认删除该明细?")) return;
|
||||
itemDeleteMutation.mutate(item.id);
|
||||
}}
|
||||
onClick={() => setConfirmDeleteItemId(item.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
@@ -383,199 +407,29 @@ export function ReimbursementDetailPage() {
|
||||
</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>
|
||||
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="reimbursement-action-error"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
data-testid="reimbursement-submit"
|
||||
disabled={mutation.isPending || detail.status !== "draft"}
|
||||
onClick={() => mutation.mutate("submit")}
|
||||
type="button"
|
||||
>
|
||||
提交报销
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-slate-200 p-4">
|
||||
<p className="text-sm font-medium text-slate-800">撤回报销</p>
|
||||
<Textarea
|
||||
data-testid="reimbursement-withdraw-comment"
|
||||
onChange={(event) => setWithdrawComment(event.target.value)}
|
||||
rows={2}
|
||||
value={withdrawComment}
|
||||
<ReimbursementItemEditor
|
||||
mode={itemEditor.mode}
|
||||
expenseTypes={expenseTypes}
|
||||
expenseTypesLoading={expenseTypesQuery.isLoading}
|
||||
expenseTypesError={expenseTypesQuery.isError}
|
||||
initialValues={editorInitialValues}
|
||||
onSave={handleItemSave}
|
||||
onCancel={handleItemCancel}
|
||||
isPending={itemSaveMutation.isPending}
|
||||
/>
|
||||
<Button
|
||||
data-testid="reimbursement-withdraw"
|
||||
variant="secondary"
|
||||
disabled={mutation.isPending || detail.status !== "reviewing" || withdrawComment.trim().length < 1}
|
||||
onClick={() => mutation.mutate("withdraw")}
|
||||
type="button"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{canPay ? (
|
||||
<div className="space-y-2 rounded-lg border border-slate-200 p-4">
|
||||
<p className="text-sm font-medium text-slate-800">财务支付</p>
|
||||
<Input
|
||||
data-testid="reimbursement-pay-voucher-no"
|
||||
onChange={(event) => setPaymentVoucherNo(event.target.value)}
|
||||
placeholder="付款凭证号"
|
||||
value={paymentVoucherNo}
|
||||
/>
|
||||
<Input
|
||||
data-testid="reimbursement-pay-date"
|
||||
onChange={(event) => setPaymentDate(event.target.value)}
|
||||
type="date"
|
||||
value={paymentDate}
|
||||
/>
|
||||
<select
|
||||
className="h-11 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
data-testid="reimbursement-pay-channel"
|
||||
onChange={(event) =>
|
||||
setPaymentChannel(event.target.value as "bank" | "alipay" | "wechat" | "cash" | "other")
|
||||
}
|
||||
value={paymentChannel}
|
||||
>
|
||||
<option value="bank">bank</option>
|
||||
<option value="alipay">alipay</option>
|
||||
<option value="wechat">wechat</option>
|
||||
<option value="cash">cash</option>
|
||||
<option value="other">other</option>
|
||||
</select>
|
||||
<Button
|
||||
data-testid="reimbursement-pay"
|
||||
variant="secondary"
|
||||
disabled={mutation.isPending || !["approved", "paid"].includes(detail.status)}
|
||||
onClick={() => mutation.mutate("pay")}
|
||||
type="button"
|
||||
>
|
||||
执行支付
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<ReimbursementActions
|
||||
status={detail.status}
|
||||
canPay={canPay}
|
||||
actionError={actionError}
|
||||
isPending={mutation.isPending}
|
||||
onSubmit={() => mutation.mutate({ action: "submit" })}
|
||||
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||||
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
|
||||
/>
|
||||
|
||||
<Card className="space-y-2 p-6" data-testid="reimbursement-invoices">
|
||||
<h3 className="text-base font-semibold text-slate-900">关联票据</h3>
|
||||
{(detail.invoices ?? []).length ? (
|
||||
@@ -593,6 +447,15 @@ export function ReimbursementDetailPage() {
|
||||
<p className="text-sm text-slate-500">暂无关联票据</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDeleteItemId)}
|
||||
onOpenChange={(open) => { if (!open) setConfirmDeleteItemId(""); }}
|
||||
title="确认删除该明细?"
|
||||
variant="destructive"
|
||||
confirmLabel="删除"
|
||||
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户