实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)
这个提交包含在:
@@ -6,6 +6,15 @@ import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
|
||||
export const OFFICE_ELECTRONICS_HINT_THRESHOLD = 2000;
|
||||
|
||||
export function shouldShowOfficeElectronicsHint(expenseTypeCode: string | null | undefined, amount: string | number) {
|
||||
const numericAmount = Number(amount);
|
||||
return expenseTypeCode === "office_supplies"
|
||||
&& Number.isFinite(numericAmount)
|
||||
&& numericAmount >= OFFICE_ELECTRONICS_HINT_THRESHOLD;
|
||||
}
|
||||
|
||||
export type ItemEditorMode = "create" | "edit";
|
||||
|
||||
export type ItemEditorInitialValues = {
|
||||
@@ -13,6 +22,7 @@ export type ItemEditorInitialValues = {
|
||||
expenseDate: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
assetPriorApprovalBasis: string;
|
||||
};
|
||||
|
||||
export type ItemEditorSaveData = {
|
||||
@@ -20,6 +30,7 @@ export type ItemEditorSaveData = {
|
||||
expenseDate: string;
|
||||
amount: string;
|
||||
description: string;
|
||||
assetPriorApprovalBasis: string;
|
||||
};
|
||||
|
||||
export interface ReimbursementItemEditorProps {
|
||||
@@ -49,6 +60,7 @@ export function ReimbursementItemEditor({
|
||||
const [expenseDate, setExpenseDate] = useState(initialValues.expenseDate);
|
||||
const [amount, setAmount] = useState(initialValues.amount);
|
||||
const [description, setDescription] = useState(initialValues.description);
|
||||
const [assetPriorApprovalBasis, setAssetPriorApprovalBasis] = useState(initialValues.assetPriorApprovalBasis);
|
||||
|
||||
// Reset internal state when initialValues change (e.g. switching between create/edit)
|
||||
useEffect(() => {
|
||||
@@ -56,7 +68,14 @@ export function ReimbursementItemEditor({
|
||||
setExpenseDate(initialValues.expenseDate);
|
||||
setAmount(initialValues.amount);
|
||||
setDescription(initialValues.description);
|
||||
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description]);
|
||||
setAssetPriorApprovalBasis(initialValues.assetPriorApprovalBasis);
|
||||
}, [initialValues.expenseTypeId, initialValues.expenseDate, initialValues.amount, initialValues.description, initialValues.assetPriorApprovalBasis]);
|
||||
|
||||
const selectedExpenseType = expenseTypes.find((item) => item.id === expenseTypeId) ?? null;
|
||||
const isAssetExpense = selectedExpenseType?.category === "assets";
|
||||
const numericAmount = Number(amount || 0);
|
||||
const shouldWarnOfficeEquipment =
|
||||
shouldShowOfficeElectronicsHint(selectedExpenseType?.code, numericAmount);
|
||||
|
||||
const standardsQuery = useQuery({
|
||||
enabled: Boolean(expenseTypeId),
|
||||
@@ -77,14 +96,13 @@ export function ReimbursementItemEditor({
|
||||
);
|
||||
}, [departmentId, standardsQuery.data?.items]);
|
||||
|
||||
const numericAmount = Number(amount || 0);
|
||||
const numericMaxAmount = Number(activeStandard?.maxAmount ?? 0);
|
||||
const isExceeded = Boolean(activeStandard) && Number.isFinite(numericAmount) && numericAmount > numericMaxAmount;
|
||||
const exceededAmount = isExceeded ? (numericAmount - numericMaxAmount).toFixed(2) : "0.00";
|
||||
const hasPositiveAmount = Number.isFinite(numericAmount) && numericAmount > 0;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ expenseTypeId, expenseDate, amount, description });
|
||||
onSave({ expenseTypeId, expenseDate, amount, description, assetPriorApprovalBasis });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -121,6 +139,9 @@ export function ReimbursementItemEditor({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedExpenseType?.description ? (
|
||||
<p className="text-xs text-zinc-500">{selectedExpenseType.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -162,8 +183,30 @@ export function ReimbursementItemEditor({
|
||||
超出费用标准 ¥{activeStandard?.maxAmount},超出 ¥{exceededAmount}
|
||||
</p>
|
||||
) : null}
|
||||
{shouldWarnOfficeEquipment ? (
|
||||
<p className="text-xs text-amber-600">
|
||||
该金额通常应选择办公电子设备并填写事前批准依据。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isAssetExpense ? (
|
||||
<div className="space-y-1 md:col-span-4">
|
||||
<label className="text-xs text-zinc-600" htmlFor="assetPriorApprovalBasis">
|
||||
事前批准依据
|
||||
</label>
|
||||
<Textarea
|
||||
data-testid="reimbursement-item-asset-approval-basis"
|
||||
disabled={isPending}
|
||||
id="assetPriorApprovalBasis"
|
||||
onChange={(event) => setAssetPriorApprovalBasis(event.target.value)}
|
||||
placeholder="填写批准人、批准时间和简短依据"
|
||||
rows={2}
|
||||
value={assetPriorApprovalBasis}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1 md:col-span-4">
|
||||
<label className="text-xs text-zinc-600" htmlFor="itemDescription">
|
||||
说明
|
||||
@@ -187,7 +230,8 @@ export function ReimbursementItemEditor({
|
||||
!expenseTypeId ||
|
||||
!expenseDate ||
|
||||
!amount.trim() ||
|
||||
!hasPositiveAmount
|
||||
!hasPositiveAmount ||
|
||||
(isAssetExpense && !assetPriorApprovalBasis.trim())
|
||||
}
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
|
||||
在新工单中引用
屏蔽一个用户