feat: 新增交易项、交易表单和交易详情模态框组件,并为交易项添加滑动操作。
This commit is contained in:
@@ -69,6 +69,16 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
// Current step (1, 2, or 3)
|
||||
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
|
||||
const [formData, setFormData] = useState<TransactionFormInput>({
|
||||
amount: 0,
|
||||
@@ -77,18 +87,18 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
categoryId: 0,
|
||||
accountId: 0,
|
||||
// Initialize with current local time for datetime-local input
|
||||
// If initialData is provided (RFC3339), convert to local time string
|
||||
transactionDate: initialData?.transactionDate
|
||||
? (initialData.transactionDate.includes('T') ? initialData.transactionDate.substring(0, 16) : initialData.transactionDate)
|
||||
: (() => {
|
||||
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())}`;
|
||||
})(),
|
||||
? toLocalISOString(new Date(initialData.transactionDate))
|
||||
: toLocalISOString(new Date()),
|
||||
note: '',
|
||||
tagIds: [],
|
||||
...initialData,
|
||||
});
|
||||
|
||||
// Local state for Amount input to allow typing decimals (e.g. "12.")
|
||||
const [amountStr, setAmountStr] = useState<string>(initialData?.amount?.toString() || '');
|
||||
|
||||
// Data State
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
@@ -127,124 +137,26 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
};
|
||||
|
||||
const handleAmountChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// Basic number input handling
|
||||
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) => {
|
||||
setFormData((prev) => ({ ...prev, categoryId: categoryId || 0 }));
|
||||
if (errors.categoryId) setErrors((prev) => ({ ...prev, categoryId: '' }));
|
||||
};
|
||||
// Update display value immediately
|
||||
setAmountStr(val);
|
||||
|
||||
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: '请输入有效金额' }));
|
||||
// Parse and update form data if valid number
|
||||
// Allow empty string to just clear form data
|
||||
if (val === '') {
|
||||
setFormData((prev) => ({ ...prev, amount: 0 }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Smart Skip
|
||||
if (smartSkipConfirmedSteps && formData.categoryId && formData.accountId) {
|
||||
setCurrentStep(3);
|
||||
} else {
|
||||
setCurrentStep(2);
|
||||
if (/^\d*\.?\d*$/.test(val)) {
|
||||
const parsed = parseFloat(val);
|
||||
if (!isNaN(parsed)) {
|
||||
setFormData((prev) => ({ ...prev, amount: parsed }));
|
||||
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 = () => (
|
||||
<div className="transaction-form__steps">
|
||||
{[1, 2, 3].map((step) => (
|
||||
@@ -298,7 +210,7 @@ export const TransactionForm: React.FC<TransactionFormProps> = ({
|
||||
type="number" // Changed to number for simpler handling
|
||||
inputMode="decimal"
|
||||
className={`transaction-form__amount-input ${errors.amount ? 'transaction-form__amount-input--error' : ''}`}
|
||||
value={formData.amount || ''}
|
||||
value={amountStr}
|
||||
onChange={handleAmountChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="0.00"
|
||||
|
||||
@@ -68,11 +68,8 @@ const TransactionItem = React.memo<TransactionItemProps>(({
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string, timeString?: string): string => {
|
||||
if (timeString) {
|
||||
return timeString.slice(0, 5);
|
||||
}
|
||||
// 格式化时间 - Now always derive from transactionDate (DATETIME)
|
||||
const formatTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
@@ -148,7 +145,7 @@ const TransactionItem = React.memo<TransactionItemProps>(({
|
||||
{showDate && (
|
||||
<div className="transaction-item-meta">
|
||||
<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>
|
||||
@@ -232,11 +229,9 @@ const TransactionItem = React.memo<TransactionItemProps>(({
|
||||
<span className="transaction-item-date">
|
||||
{formatDate(transaction.transactionDate)}
|
||||
</span>
|
||||
{transaction.transactionTime && (
|
||||
<span className="transaction-item-time">
|
||||
{formatTime(transaction.transactionDate, transaction.transactionTime)}
|
||||
{formatTime(transaction.transactionDate)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,9 +37,7 @@ export const TransactionReceiptModal: React.FC<TransactionReceiptModalProps> = (
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
});
|
||||
const formattedTime = transaction.transactionTime
|
||||
? transaction.transactionTime.slice(0, 5)
|
||||
: dateObj.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
const formattedTime = dateObj.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<div className="receipt-modal-overlay" onClick={onClose}>
|
||||
|
||||
@@ -112,21 +112,21 @@ export interface Transaction {
|
||||
// New fields for accounting-feature-upgrade
|
||||
ledgerId?: number;
|
||||
ledger?: Ledger;
|
||||
transactionTime?: string;
|
||||
// Reimbursement related fields
|
||||
reimbursementStatus: ReimbursementStatus;
|
||||
reimbursementAmount?: number;
|
||||
reimbursementIncomeId?: number;
|
||||
// Refund related fields
|
||||
refundStatus: RefundStatus;
|
||||
refundAmount?: number;
|
||||
refundIncomeId?: number;
|
||||
// Association to original transaction (for refund/reimbursement income records)
|
||||
originalTransactionId?: number;
|
||||
originalTransaction?: Transaction;
|
||||
incomeType?: IncomeType;
|
||||
// Images
|
||||
images?: TransactionImage[];
|
||||
/** @deprecated Time is now stored in transactionDate (DATETIME). Kept for backward compatibility. */\r\n transactionTime ?: string;
|
||||
// Reimbursement related fields
|
||||
reimbursementStatus: ReimbursementStatus;
|
||||
reimbursementAmount ?: number;
|
||||
reimbursementIncomeId ?: number;
|
||||
// Refund related fields
|
||||
refundStatus: RefundStatus;
|
||||
refundAmount ?: number;
|
||||
refundIncomeId ?: number;
|
||||
// Association to original transaction (for refund/reimbursement income records)
|
||||
originalTransactionId ?: number;
|
||||
originalTransaction ?: Transaction;
|
||||
incomeType ?: IncomeType;
|
||||
// Images
|
||||
images ?: TransactionImage[];
|
||||
}
|
||||
|
||||
// Account Interface
|
||||
|
||||
Reference in New Issue
Block a user