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
if (val === '' || /^\d*\.?\d*$/.test(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.
// Let's rely on type="number" behavior of input if possible or assume user types clean numbers.
// Or better: update state properly.
// The original broken code used `value={formData.amount || ''}` and `inputMode="decimal"`.
// Let's do simple float parsing.
setFormData((prev) => ({ ...prev, amount: parseFloat(val) || 0 }));
if (errors.amount) setErrors((prev) => ({ ...prev, amount: '' }));
}
};
// Update display value immediately const handleCategoryChange = (categoryId: number | undefined) => {
setAmountStr(val); setFormData((prev) => ({ ...prev, categoryId: categoryId || 0 }));
if (errors.categoryId) setErrors((prev) => ({ ...prev, categoryId: '' }));
};
// Parse and update form data if valid number const handleAccountChange = (accountId: number) => {
// Allow empty string to just clear form data setFormData((prev) => ({ ...prev, accountId }));
if (val === '') { if (errors.accountId) setErrors((prev) => ({ ...prev, accountId: '' }));
setFormData((prev) => ({ ...prev, amount: 0 })); };
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; return;
} }
if (/^\d*\.?\d*$/.test(val)) { // Smart Skip
const parsed = parseFloat(val); if (smartSkipConfirmedSteps && formData.categoryId && formData.accountId) {
if (!isNaN(parsed)) { setCurrentStep(3);
setFormData((prev) => ({ ...prev, amount: parsed })); } else {
if (errors.amount) setErrors((prev) => ({ ...prev, amount: '' })); 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,7 +112,8 @@ 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. */
transactionTime?: string;
// Reimbursement related fields // Reimbursement related fields
reimbursementStatus: ReimbursementStatus; reimbursementStatus: ReimbursementStatus;
reimbursementAmount?: number; reimbursementAmount?: number;