实现报销费用政策与票据明细门禁(08-29-reimbursement-expense-policy-invoice-guard)

这个提交包含在:
2026-08-29 23:47:22 +08:00
父节点 9e2aea5f70
当前提交 6e999d5bf9
共修改 34 个文件,包含 1458 行新增和 92 行删除
@@ -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"