init
This commit is contained in:
504
src/components/budget/AllocationRuleForm/AllocationRuleForm.tsx
Normal file
504
src/components/budget/AllocationRuleForm/AllocationRuleForm.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user