feat: 新增交易项、交易表单和交易详情模态框组件,并为交易项添加滑动操作。

This commit is contained in:
2026-01-28 23:31:20 +08:00
parent 7369458a25
commit b0e9c403e9
4 changed files with 53 additions and 148 deletions

View File

@@ -69,6 +69,16 @@ 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);
// Helpers
const toLocalISOString = (date: Date) => {
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())}`;
};
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,
@@ -77,18 +87,18 @@ 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
transactionDate: initialData?.transactionDate transactionDate: initialData?.transactionDate
? (initialData.transactionDate.includes('T') ? initialData.transactionDate.substring(0, 16) : initialData.transactionDate) ? toLocalISOString(new Date(initialData.transactionDate))
: (() => { : toLocalISOString(new Date()),
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
})(),
note: '', note: '',
tagIds: [], tagIds: [],
...initialData, ...initialData,
}); });
// 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[]>([]);
@@ -127,124 +137,26 @@ 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: '' }));
}
};
const handleCategoryChange = (categoryId: number | undefined) => { // Update display value immediately
setFormData((prev) => ({ ...prev, categoryId: categoryId || 0 })); setAmountStr(val);
if (errors.categoryId) setErrors((prev) => ({ ...prev, categoryId: '' }));
};
const handleAccountChange = (accountId: number) => { // Parse and update form data if valid number
setFormData((prev) => ({ ...prev, accountId })); // Allow empty string to just clear form data
if (errors.accountId) setErrors((prev) => ({ ...prev, accountId: '' })); if (val === '') {
}; 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;
} }
// Smart Skip if (/^\d*\.?\d*$/.test(val)) {
if (smartSkipConfirmedSteps && formData.categoryId && formData.accountId) { const parsed = parseFloat(val);
setCurrentStep(3); if (!isNaN(parsed)) {
} else { setFormData((prev) => ({ ...prev, amount: parsed }));
setCurrentStep(2); if (errors.amount) setErrors((prev) => ({ ...prev, amount: '' }));
} }
} 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 proper ISO string for backend
// datetime-local value format: YYYY-MM-DDTHH:mm
// new Date(val).toISOString() will give UTC time which backend accepts
let submitDate = formData.transactionDate;
try {
const dateObj = new Date(formData.transactionDate);
if (!isNaN(dateObj.getTime())) {
submitDate = dateObj.toISOString();
} else {
// Fallback for date-only strings if any legacy data
submitDate = new Date(formData.transactionDate + 'T00:00:00').toISOString();
}
} catch (e) {
console.warn('Date conversion error', e);
}
const payload = {
...formData,
transactionDate: submitDate
};
onSubmit(payload);
}, [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) => (
@@ -298,7 +210,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={formData.amount || ''} value={amountStr}
onChange={handleAmountChange} onChange={handleAmountChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="0.00" placeholder="0.00"

View File

@@ -68,11 +68,8 @@ const TransactionItem = React.memo<TransactionItemProps>(({
} }
}; };
// 格式化时间 // 格式化时间 - Now always derive from transactionDate (DATETIME)
const formatTime = (dateString: string, timeString?: string): string => { const formatTime = (dateString: string): string => {
if (timeString) {
return timeString.slice(0, 5);
}
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}; };
@@ -148,7 +145,7 @@ const TransactionItem = React.memo<TransactionItemProps>(({
{showDate && ( {showDate && (
<div className="transaction-item-meta"> <div className="transaction-item-meta">
<span className="transaction-item-date">{formatDate(transaction.transactionDate)}</span> <span className="transaction-item-date">{formatDate(transaction.transactionDate)}</span>
{transaction.transactionTime && <span className="transaction-item-time">{formatTime(transaction.transactionDate, transaction.transactionTime)}</span>} <span className="transaction-item-time">{formatTime(transaction.transactionDate)}</span>
</div> </div>
)} )}
</div> </div>
@@ -232,11 +229,9 @@ const TransactionItem = React.memo<TransactionItemProps>(({
<span className="transaction-item-date"> <span className="transaction-item-date">
{formatDate(transaction.transactionDate)} {formatDate(transaction.transactionDate)}
</span> </span>
{transaction.transactionTime && (
<span className="transaction-item-time"> <span className="transaction-item-time">
{formatTime(transaction.transactionDate, transaction.transactionTime)} {formatTime(transaction.transactionDate)}
</span> </span>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -37,9 +37,7 @@ export const TransactionReceiptModal: React.FC<TransactionReceiptModalProps> = (
day: 'numeric', day: 'numeric',
weekday: 'long', weekday: 'long',
}); });
const formattedTime = transaction.transactionTime const formattedTime = dateObj.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
? transaction.transactionTime.slice(0, 5)
: dateObj.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
return ( return (
<div className="receipt-modal-overlay" onClick={onClose}> <div className="receipt-modal-overlay" onClick={onClose}>

View File

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