refactor(ui): [UI7-1] 深度翻新报销、发票与用户管理等核心视窗

这个提交包含在:
2026-03-06 09:48:11 +08:00
父节点 61180e2b3b
当前提交 c632648e16
共修改 8 个文件,包含 1105 行新增和 1250 行删除
+100 -237
查看文件
@@ -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>
);
}