refactor(ui): [UI7-1] 深度翻新报销、发票与用户管理等核心视窗
这个提交包含在:
@@ -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>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户