This commit is contained in:
2026-01-25 20:12:33 +08:00
parent 3c3868e2a7
commit fd7cb4485c
364 changed files with 66196 additions and 0 deletions

View 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;