feat: 创建三步式交易表单组件,并定义了交易相关的核心类型。

This commit is contained in:
2026-01-28 23:38:55 +08:00
parent b0e9c403e9
commit f116998226
2 changed files with 124 additions and 43 deletions

View File

@@ -69,16 +69,12 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
// Current step (1, 2, or 3) // Current step (1, 2, or 3)
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
// Helper to format Date to local datetime-local string (YYYY-MM-DDTHH:mm)
// Helpers
const toLocalISOString = (date: Date) => { const toLocalISOString = (date: Date) => {
const pad = (n: number) => n.toString().padStart(2, '0'); const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}; };
const selectedAccount = accounts.find((a) => a.id === formData.accountId);
const selectedCategory = categories.find((c) => c.id === formData.categoryId);
// Form State // Form State
const [formData, setFormData] = useState<TransactionFormInput>({ const [formData, setFormData] = useState<TransactionFormInput>({
amount: 0, amount: 0,
@@ -87,18 +83,19 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
categoryId: 0, categoryId: 0,
accountId: 0, accountId: 0,
// Initialize with current local time for datetime-local input // Initialize with current local time for datetime-local input
// If initialData is provided (RFC3339), convert to local time string // If editing, convert the ISO string to local datetime-local format
transactionDate: initialData?.transactionDate transactionDate: initialData?.transactionDate
? toLocalISOString(new Date(initialData.transactionDate)) ? toLocalISOString(new Date(initialData.transactionDate))
: toLocalISOString(new Date()), : toLocalISOString(new Date()),
note: '', note: '',
tagIds: [], tagIds: [],
...initialData, ...initialData,
// Override transactionDate again after spreading initialData
...(initialData?.transactionDate
? { transactionDate: toLocalISOString(new Date(initialData.transactionDate)) }
: {}),
}); });
// Local state for Amount input to allow typing decimals (e.g. "12.")
const [amountStr, setAmountStr] = useState<string>(initialData?.amount?.toString() || '');
// Data State // Data State
const [accounts, setAccounts] = useState<Account[]>([]); const [accounts, setAccounts] = useState<Account[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
@@ -137,26 +134,109 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
}; };
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Basic number input handling
const val = e.target.value; const val = e.target.value;
// Allow empty string or ending with decimal
// Update display value immediately if (val === '' || /^\d*\.?\d*$/.test(val)) {
setAmountStr(val); // We might store it as string locally if we want perfect input, but TransactionFormInput expects number.
// For simplicity, we parse float immediately but this prevents typing "1." comfortably.
// Parse and update form data if valid number // Let's rely on type="number" behavior of input if possible or assume user types clean numbers.
// Allow empty string to just clear form data // Or better: update state properly.
if (val === '') { // The original broken code used `value={formData.amount || ''}` and `inputMode="decimal"`.
setFormData((prev) => ({ ...prev, amount: 0 })); // Let's do simple float parsing.
return; setFormData((prev) => ({ ...prev, amount: parseFloat(val) || 0 }));
} if (errors.amount) setErrors((prev) => ({ ...prev, amount: '' }));
if (/^\d*\.?\d*$/.test(val)) {
const parsed = parseFloat(val);
if (!isNaN(parsed)) {
setFormData((prev) => ({ ...prev, amount: parsed }));
if (errors.amount) setErrors((prev) => ({ ...prev, amount: '' }));
}
} }
}; };
const handleCategoryChange = (categoryId: number | undefined) => {
setFormData((prev) => ({ ...prev, categoryId: categoryId || 0 }));
if (errors.categoryId) setErrors((prev) => ({ ...prev, categoryId: '' }));
};
const handleAccountChange = (accountId: number) => {
setFormData((prev) => ({ ...prev, accountId }));
if (errors.accountId) setErrors((prev) => ({ ...prev, accountId: '' }));
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData((prev) => ({ ...prev, transactionDate: e.target.value }));
};
const handleNoteChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setFormData((prev) => ({ ...prev, note: e.target.value }));
};
const handleTagsChange = (tagIds: number[]) => {
setFormData((prev) => ({ ...prev, tagIds }));
};
// Navigation
const handleNextStep = useCallback(() => {
if (currentStep === 1) {
// Validate Step 1
if (!formData.amount || formData.amount <= 0) {
setErrors(prev => ({ ...prev, amount: '请输入有效金额' }));
return;
}
// Smart Skip
if (smartSkipConfirmedSteps && formData.categoryId && formData.accountId) {
setCurrentStep(3);
} else {
setCurrentStep(2);
}
} else if (currentStep === 2) {
// Validate Step 2
let isValid = true;
const newErrors = { ...errors };
if (!formData.categoryId) { newErrors.categoryId = '请选择分类'; isValid = false; }
if (!formData.accountId) { newErrors.accountId = '请选择账户'; isValid = false; }
setErrors(newErrors);
if (isValid) setCurrentStep(3);
}
}, [currentStep, formData, smartSkipConfirmedSteps, errors]);
const handlePrevStep = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1);
}
};
const handleSubmit = useCallback(() => {
if (!formData.amount) return;
if (!formData.categoryId) return;
if (!formData.accountId) return;
// Convert local datetime-local string to UTC ISO 8601 for submission
const submitData = {
...formData,
transactionDate: new Date(formData.transactionDate).toISOString(),
};
onSubmit(submitData);
}, [formData, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (currentStep < 3) {
handleNextStep();
} else {
handleSubmit();
}
}
},
[currentStep, handleNextStep, handleSubmit]
);
// Helpers
const selectedAccount = accounts.find((a) => a.id === formData.accountId);
const selectedCategory = categories.find((c) => c.id === formData.categoryId);
// Render Functions
const renderStepIndicator = () => ( const renderStepIndicator = () => (
<div className="transaction-form__steps"> <div className="transaction-form__steps">
{[1, 2, 3].map((step) => ( {[1, 2, 3].map((step) => (
@@ -210,7 +290,7 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
type="number" // Changed to number for simpler handling type="number" // Changed to number for simpler handling
inputMode="decimal" inputMode="decimal"
className={`transaction-form__amount-input ${errors.amount ? 'transaction-form__amount-input--error' : ''}`} className={`transaction-form__amount-input ${errors.amount ? 'transaction-form__amount-input--error' : ''}`}
value={amountStr} value={formData.amount || ''}
onChange={handleAmountChange} onChange={handleAmountChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="0.00" placeholder="0.00"
@@ -312,7 +392,7 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
</div> </div>
<div className="transaction-form__field"> <div className="transaction-form__field">
<label className="transaction-form__label" htmlFor="transactionDate"> <label className="transaction-form__label" htmlFor="transactionDate">
</label> </label>
<input <input
type="datetime-local" type="datetime-local"

View File

@@ -112,21 +112,22 @@ export interface Transaction {
// New fields for accounting-feature-upgrade // New fields for accounting-feature-upgrade
ledgerId?: number; ledgerId?: number;
ledger?: Ledger; ledger?: Ledger;
/** @deprecated Time is now stored in transactionDate (DATETIME). Kept for backward compatibility. */\r\n transactionTime ?: string; /** @deprecated Time is now stored in transactionDate (DATETIME). Kept for backward compatibility. */
// Reimbursement related fields transactionTime?: string;
reimbursementStatus: ReimbursementStatus; // Reimbursement related fields
reimbursementAmount ?: number; reimbursementStatus: ReimbursementStatus;
reimbursementIncomeId ?: number; reimbursementAmount?: number;
// Refund related fields reimbursementIncomeId?: number;
refundStatus: RefundStatus; // Refund related fields
refundAmount ?: number; refundStatus: RefundStatus;
refundIncomeId ?: number; refundAmount?: number;
// Association to original transaction (for refund/reimbursement income records) refundIncomeId?: number;
originalTransactionId ?: number; // Association to original transaction (for refund/reimbursement income records)
originalTransaction ?: Transaction; originalTransactionId?: number;
incomeType ?: IncomeType; originalTransaction?: Transaction;
// Images incomeType?: IncomeType;
images ?: TransactionImage[]; // Images
images?: TransactionImage[];
} }
// Account Interface // Account Interface