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

这个提交包含在:
2026-03-06 09:48:11 +08:00
父节点 61180e2b3b
当前提交 c632648e16
共修改 8 个文件,包含 1105 行新增和 1250 行删除
@@ -0,0 +1,131 @@
import { useState } from "react";
import type { ReimbursementStatus } from "../../api/reimbursements";
import { Button } from "../../components/ui/button";
import { Card } from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
type PaymentChannel = "bank" | "alipay" | "wechat" | "cash" | "other";
export type PayPayload = {
paymentVoucherNo: string;
paymentDate: string;
paymentChannel: PaymentChannel;
};
export interface ReimbursementActionsProps {
status: ReimbursementStatus;
canPay: boolean;
actionError: string;
isPending: boolean;
onSubmit: () => void;
onWithdraw: (comment: string) => void;
onPay: (payload: PayPayload) => void;
}
export function ReimbursementActions({
status,
canPay,
actionError,
isPending,
onSubmit,
onWithdraw,
onPay,
}: ReimbursementActionsProps) {
const [withdrawComment, setWithdrawComment] = useState("申请撤回并补充说明后重提");
const [paymentVoucherNo, setPaymentVoucherNo] = useState("");
const [paymentDate, setPaymentDate] = useState("2026-02-08");
const [paymentChannel, setPaymentChannel] = useState<PaymentChannel>("bank");
return (
<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={isPending || status !== "draft"}
onClick={onSubmit}
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}
/>
<Button
data-testid="reimbursement-withdraw"
variant="secondary"
disabled={isPending || status !== "reviewing" || withdrawComment.trim().length < 1}
onClick={() => onWithdraw(withdrawComment.trim())}
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 PaymentChannel)
}
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={isPending || !["approved", "paid"].includes(status)}
onClick={() =>
onPay({
paymentVoucherNo: paymentVoucherNo.trim(),
paymentDate,
paymentChannel,
})
}
type="button"
>
执行支付
</Button>
</div>
) : null}
</Card>
);
}
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import type { ExpenseType } from "../../api/expenseTypes";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
export type ItemEditorMode = "create" | "edit";
export type ItemEditorInitialValues = {
expenseTypeId: string;
expenseDate: string;
amount: string;
description: string;
};
export type ItemEditorSaveData = {
expenseTypeId: string;
expenseDate: string;
amount: string;
description: string;
};
export interface ReimbursementItemEditorProps {
mode: ItemEditorMode;
expenseTypes: ExpenseType[];
expenseTypesLoading: boolean;
expenseTypesError: boolean;
initialValues: ItemEditorInitialValues;
onSave: (data: ItemEditorSaveData) => void;
onCancel: () => void;
isPending: boolean;
}
export function ReimbursementItemEditor({
mode,
expenseTypes,
expenseTypesLoading,
expenseTypesError,
initialValues,
onSave,
onCancel,
isPending,
}: ReimbursementItemEditorProps) {
const [expenseTypeId, setExpenseTypeId] = useState(initialValues.expenseTypeId);
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
const [amount, setAmount] = useState(initialValues.amount);
const [description, setDescription] = useState(initialValues.description);
// Reset internal state when initialValues change (e.g. switching between create/edit)
useEffect(() => {
setExpenseTypeId(initialValues.expenseTypeId);
setExpenseDate(initialValues.expenseDate);
setAmount(initialValues.amount);
setDescription(initialValues.description);
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
const handleSave = () => {
onSave({ expenseTypeId, expenseDate, amount, description });
};
return (
<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">
{mode === "create" ? "新增明细" : "编辑明细"}
</p>
{expenseTypesLoading ? (
<p className="text-sm text-slate-500">加载费用类型中...</p>
) : null}
{expenseTypesError ? (
<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 || isPending}
id="expenseTypeId"
onChange={(event) => setExpenseTypeId(event.target.value)}
value={expenseTypeId}
>
{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={isPending}
id="expenseDate"
onChange={(event) => setExpenseDate(event.target.value)}
type="date"
value={expenseDate}
/>
</div>
<div className="space-y-1">
<label className="text-xs text-slate-600" htmlFor="amount">
金额
</label>
<Input
data-testid="reimbursement-item-amount"
disabled={isPending}
id="amount"
onChange={(event) => setAmount(event.target.value)}
placeholder="例如 123.45"
value={amount}
/>
</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={isPending}
id="itemDescription"
onChange={(event) => setDescription(event.target.value)}
rows={2}
value={description}
/>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
data-testid="reimbursement-item-save"
disabled={
isPending ||
!expenseTypeId ||
!expenseDate ||
!amount.trim()
}
onClick={handleSave}
type="button"
>
{isPending ? "保存中..." : "保存明细"}
</Button>
<Button
data-testid="reimbursement-item-cancel"
variant="secondary"
disabled={isPending}
onClick={onCancel}
type="button"
>
取消
</Button>
</div>
</div>
);
}