feat: 创建三步式交易表单组件,并定义了交易相关的核心类型。
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user