505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
/**
|
||
* 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;
|