Files
Novault-Frontend-web/src/components/budget/AllocationRuleForm/AllocationRuleForm.tsx
2026-01-25 20:12:33 +08:00

505 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AllocationRuleForm Component
* Form for creating and editing income allocation rules
*
* Requirements: 5.3.2, 5.3.5
*/
import React, { useState, useEffect } from 'react';
import type { AllocationRule, Account, PiggyBank } from '../../../types';
import type {
AllocationRuleFormInput,
AllocationTargetInput,
} from '../../../services/allocationRuleService';
import { getTargetTypeLabel } from '../../../services/allocationRuleService';
import './AllocationRuleForm.css';
interface AllocationRuleFormProps {
allocationRule?: AllocationRule;
accounts: Account[];
piggyBanks: PiggyBank[];
onSubmit: (data: AllocationRuleFormInput) => void;
onCancel: () => void;
isLoading?: boolean;
}
export const AllocationRuleForm: React.FC<AllocationRuleFormProps> = ({
allocationRule,
accounts,
piggyBanks,
onSubmit,
onCancel,
isLoading = false,
}) => {
const [formData, setFormData] = useState<AllocationRuleFormInput>({
name: '',
triggerType: 'income',
sourceAccountId: undefined,
isActive: true,
targets: [],
});
const [errors, setErrors] = useState<Record<string, string>>({});
// Initialize form with allocation rule data if editing
useEffect(() => {
if (allocationRule) {
setFormData({
name: allocationRule.name,
triggerType: allocationRule.triggerType as 'income' | 'manual',
sourceAccountId: allocationRule.sourceAccountId,
isActive: allocationRule.isActive,
targets: allocationRule.targets.map((target) => ({
targetType: target.targetType,
targetId: target.targetId,
percentage: target.percentage,
fixedAmount: target.fixedAmount,
})),
});
}
}, [allocationRule]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
const checked = (e.target as HTMLInputElement).checked;
setFormData((prev) => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
// Clear error for this field
if (errors[name]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleAddTarget = () => {
setFormData((prev) => ({
...prev,
targets: [
...prev.targets,
{
targetType: 'account',
targetId: accounts.length > 0 ? accounts[0].id : 0,
percentage: 0,
},
],
}));
};
const handleRemoveTarget = (index: number) => {
setFormData((prev) => ({
...prev,
targets: prev.targets.filter((_, i) => i !== index),
}));
};
const handleTargetChange = (
index: number,
field: keyof AllocationTargetInput,
value: string | number
) => {
setFormData((prev) => {
const newTargets = [...prev.targets];
const target = { ...newTargets[index] };
if (field === 'targetType') {
target.targetType = value as 'account' | 'piggy_bank';
// Reset target ID when type changes
if (value === 'account') {
target.targetId = accounts.length > 0 ? accounts[0].id : 0;
} else {
target.targetId = piggyBanks.length > 0 ? piggyBanks[0].id : 0;
}
} else if (field === 'targetId') {
target.targetId = Number(value);
} else if (field === 'percentage') {
target.percentage = value === '' ? undefined : Number(value);
target.fixedAmount = undefined; // Clear fixed amount when percentage is set
} else if (field === 'fixedAmount') {
target.fixedAmount = value === '' ? undefined : Number(value);
target.percentage = undefined; // Clear percentage when fixed amount is set
}
newTargets[index] = target;
return { ...prev, targets: newTargets };
});
// Clear target errors
if (errors[`target_${index}`]) {
setErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[`target_${index}`];
return newErrors;
});
}
};
const handleAllocationTypeChange = (index: number, type: 'percentage' | 'fixed') => {
setFormData((prev) => {
const newTargets = [...prev.targets];
const target = { ...newTargets[index] };
if (type === 'percentage') {
target.percentage = 0;
target.fixedAmount = undefined;
} else {
target.fixedAmount = 0;
target.percentage = undefined;
}
newTargets[index] = target;
return { ...prev, targets: newTargets };
});
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = '请输入规则名称';
}
if (formData.targets.length === 0) {
newErrors.targets = '至少需要添加一个分配目标';
}
let totalPercentage = 0;
formData.targets.forEach((target, index) => {
if (!target.targetId) {
newErrors[`target_${index}`] = '请选择目标';
}
if (target.percentage === undefined && target.fixedAmount === undefined) {
newErrors[`target_${index}`] = '请设置分配比例或固定金额';
}
if (target.percentage !== undefined) {
if (target.percentage < 0 || target.percentage > 100) {
newErrors[`target_${index}`] = '分配比例必须在0-100之间';
}
totalPercentage += target.percentage;
}
if (target.fixedAmount !== undefined && target.fixedAmount <= 0) {
newErrors[`target_${index}`] = '固定金额必须大于0';
}
});
if (totalPercentage > 100) {
newErrors.targets = '总分配比例不能超过100%';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validate()) {
onSubmit(formData);
}
};
const getTotalPercentage = (): number => {
return formData.targets.reduce((sum, target) => {
return sum + (target.percentage || 0);
}, 0);
};
const totalPercentage = getTotalPercentage();
return (
<form className="allocation-rule-form" onSubmit={handleSubmit}>
<div className="allocation-rule-form__header">
<h2 className="allocation-rule-form__title">
{allocationRule ? '编辑分配规则' : '创建分配规则'}
</h2>
</div>
<div className="allocation-rule-form__body">
{/* Rule Name */}
<div className="allocation-rule-form__field">
<label htmlFor="name" className="allocation-rule-form__label">
<span className="allocation-rule-form__required">*</span>
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className={`allocation-rule-form__input ${errors.name ? 'allocation-rule-form__input--error' : ''}`}
placeholder="例如:工资分配、奖金分配"
disabled={isLoading}
/>
{errors.name && <span className="allocation-rule-form__error">{errors.name}</span>}
</div>
{/* Trigger Type */}
<div className="allocation-rule-form__field">
<label htmlFor="triggerType" className="allocation-rule-form__label">
<span className="allocation-rule-form__required">*</span>
</label>
<select
id="triggerType"
name="triggerType"
value={formData.triggerType}
onChange={handleChange}
className="allocation-rule-form__select"
disabled={isLoading}
>
<option value="income"></option>
<option value="manual"></option>
</select>
</div>
{/* Source Account - only show when trigger type is income */}
{formData.triggerType === 'income' && (
<div className="allocation-rule-form__field">
<label htmlFor="sourceAccountId" className="allocation-rule-form__label">
</label>
<select
id="sourceAccountId"
name="sourceAccountId"
value={formData.sourceAccountId || ''}
onChange={(e) => {
const value = e.target.value;
setFormData((prev) => ({
...prev,
sourceAccountId: value ? Number(value) : undefined,
}));
}}
className="allocation-rule-form__select"
disabled={isLoading}
>
<option value=""></option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.icon} {account.name}
</option>
))}
</select>
<span className="allocation-rule-form__hint">
</span>
</div>
)}
{/* Is Active */}
<div className="allocation-rule-form__field allocation-rule-form__field--checkbox">
<label className="allocation-rule-form__checkbox-label">
<input
type="checkbox"
name="isActive"
checked={formData.isActive}
onChange={handleChange}
className="allocation-rule-form__checkbox"
disabled={isLoading}
/>
<span></span>
</label>
</div>
{/* Allocation Targets */}
<div className="allocation-rule-form__section">
<div className="allocation-rule-form__section-header">
<h3 className="allocation-rule-form__section-title">
<span className="allocation-rule-form__required">*</span>
</h3>
<button
type="button"
onClick={handleAddTarget}
className="allocation-rule-form__add-btn"
disabled={isLoading}
>
+
</button>
</div>
{errors.targets && (
<span className="allocation-rule-form__error allocation-rule-form__error--section">
{errors.targets}
</span>
)}
{formData.targets.length === 0 ? (
<div className="allocation-rule-form__empty">
<p>"添加目标"</p>
</div>
) : (
<div className="allocation-rule-form__targets">
{formData.targets.map((target, index) => (
<div key={index} className="allocation-rule-form__target">
<div className="allocation-rule-form__target-header">
<span className="allocation-rule-form__target-number"> {index + 1}</span>
<button
type="button"
onClick={() => handleRemoveTarget(index)}
className="allocation-rule-form__remove-btn"
disabled={isLoading}
aria-label="删除目标"
>
</button>
</div>
{/* Target Type */}
<div className="allocation-rule-form__target-field">
<label className="allocation-rule-form__label"></label>
<select
value={target.targetType}
onChange={(e) => handleTargetChange(index, 'targetType', e.target.value)}
className="allocation-rule-form__select"
disabled={isLoading}
>
<option value="account"></option>
<option value="piggy_bank"></option>
</select>
</div>
{/* Target Selection */}
<div className="allocation-rule-form__target-field">
<label className="allocation-rule-form__label">
{getTargetTypeLabel(target.targetType)}
</label>
<select
value={target.targetId}
onChange={(e) => handleTargetChange(index, 'targetId', e.target.value)}
className="allocation-rule-form__select"
disabled={isLoading}
>
{target.targetType === 'account' ? (
accounts.length === 0 ? (
<option value=""></option>
) : (
accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.icon} {account.name}
</option>
))
)
) : piggyBanks.length === 0 ? (
<option value=""></option>
) : (
piggyBanks.map((piggyBank) => (
<option key={piggyBank.id} value={piggyBank.id}>
🐷 {piggyBank.name}
</option>
))
)}
</select>
</div>
{/* Allocation Type Toggle */}
<div className="allocation-rule-form__target-field">
<label className="allocation-rule-form__label"></label>
<div className="allocation-rule-form__allocation-type">
<button
type="button"
className={`allocation-rule-form__type-btn ${target.percentage !== undefined ? 'allocation-rule-form__type-btn--active' : ''}`}
onClick={() => handleAllocationTypeChange(index, 'percentage')}
disabled={isLoading}
>
</button>
<button
type="button"
className={`allocation-rule-form__type-btn ${target.fixedAmount !== undefined ? 'allocation-rule-form__type-btn--active' : ''}`}
onClick={() => handleAllocationTypeChange(index, 'fixed')}
disabled={isLoading}
>
</button>
</div>
</div>
{/* Percentage or Fixed Amount Input */}
{target.percentage !== undefined ? (
<div className="allocation-rule-form__target-field">
<label className="allocation-rule-form__label"> (%)</label>
<input
type="number"
value={target.percentage ?? ''}
onChange={(e) => handleTargetChange(index, 'percentage', e.target.value)}
className="allocation-rule-form__input"
placeholder="0"
min="0"
max="100"
step="0.01"
disabled={isLoading}
/>
</div>
) : (
<div className="allocation-rule-form__target-field">
<label className="allocation-rule-form__label"></label>
<input
type="number"
value={target.fixedAmount ?? ''}
onChange={(e) => handleTargetChange(index, 'fixedAmount', e.target.value)}
className="allocation-rule-form__input"
placeholder="0.00"
min="0"
step="0.01"
disabled={isLoading}
/>
</div>
)}
{errors[`target_${index}`] && (
<span className="allocation-rule-form__error">{errors[`target_${index}`]}</span>
)}
</div>
))}
</div>
)}
{/* Total Percentage Display */}
{totalPercentage > 0 && (
<div className="allocation-rule-form__total">
<span className="allocation-rule-form__total-label"></span>
<span
className={`allocation-rule-form__total-value ${totalPercentage > 100 ? 'allocation-rule-form__total-value--error' : ''}`}
>
{totalPercentage.toFixed(2)}%
</span>
{totalPercentage < 100 && (
<span className="allocation-rule-form__total-hint">
{(100 - totalPercentage).toFixed(2)}%
</span>
)}
</div>
)}
</div>
</div>
<div className="allocation-rule-form__footer">
<button
type="button"
onClick={onCancel}
className="allocation-rule-form__button allocation-rule-form__button--cancel"
disabled={isLoading}
>
</button>
<button
type="submit"
className="allocation-rule-form__button allocation-rule-form__button--submit"
disabled={isLoading}
>
{isLoading ? '保存中...' : allocationRule ? '更新规则' : '创建规则'}
</button>
</div>
</form>
);
};
export default AllocationRuleForm;