mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -08:00
feat: Refactor transaction management with nanostores and convert components to React
- Added @nanostores/react for state management. - Created AccountSummary component to display account balance. - Replaced AddTransactionForm.astro with AddTransactionForm.tsx for better state handling. - Introduced TransactionTable.tsx for displaying transactions with edit/delete functionality. - Updated Sidebar.astro and MainContent.astro to use React components. - Implemented transactionStore.ts for managing current account ID and transaction editing state. - Removed obsolete AddTransactionForm.astro and related scripts. - Enhanced error handling and loading states in transaction forms. This fixes issues #7, #8, #9, #10, #11
This commit is contained in:
260
src/components/AddTransactionForm.tsx
Normal file
260
src/components/AddTransactionForm.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useStore } from "@nanostores/react";
|
||||
import type { Transaction } from "../types";
|
||||
// Import store atoms and actions
|
||||
import {
|
||||
currentAccountId as currentAccountIdStore,
|
||||
transactionToEdit as transactionToEditStore,
|
||||
cancelEditingTransaction,
|
||||
transactionSaved,
|
||||
} from "../stores/transactionStore";
|
||||
|
||||
// Remove props that now come from the store
|
||||
interface AddTransactionFormProps {}
|
||||
|
||||
export default function AddTransactionForm({}: AddTransactionFormProps) {
|
||||
// --- Read state from store ---
|
||||
const currentAccountId = useStore(currentAccountIdStore);
|
||||
const transactionToEdit = useStore(transactionToEditStore);
|
||||
|
||||
// --- State Variables ---
|
||||
const [date, setDate] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEditMode = !!editingId;
|
||||
|
||||
// --- Effects ---
|
||||
// Effect to set default date on mount
|
||||
useEffect(() => {
|
||||
// Only set default date if not editing
|
||||
if (!transactionToEdit) {
|
||||
setDate(new Date().toISOString().split("T")[0]);
|
||||
}
|
||||
}, [transactionToEdit]); // Rerun if edit mode changes
|
||||
|
||||
// Effect to populate form when editing
|
||||
useEffect(() => {
|
||||
if (transactionToEdit) {
|
||||
setEditingId(transactionToEdit.id);
|
||||
// Format date correctly for input type="date"
|
||||
try {
|
||||
const dateObj = new Date(transactionToEdit.date);
|
||||
// Check if date is valid before formatting
|
||||
if (!isNaN(dateObj.getTime())) {
|
||||
// Adjust for timezone offset to prevent date shifting
|
||||
const timezoneOffset = dateObj.getTimezoneOffset() * 60000; //offset in milliseconds
|
||||
const adjustedDate = new Date(dateObj.getTime() - timezoneOffset);
|
||||
setDate(adjustedDate.toISOString().split("T")[0]);
|
||||
} else {
|
||||
console.warn(
|
||||
"Invalid date received for editing:",
|
||||
transactionToEdit.date
|
||||
);
|
||||
setDate(""); // Set to empty if invalid
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing date for editing:", e);
|
||||
setDate(""); // Set to empty on error
|
||||
}
|
||||
setDescription(transactionToEdit.description);
|
||||
setAmount(transactionToEdit.amount.toString());
|
||||
setError(null); // Clear errors when starting edit
|
||||
} else {
|
||||
// Reset form if transactionToEdit becomes null (e.g., after saving or cancelling)
|
||||
// but only if not already resetting via handleCancel or handleSubmit
|
||||
if (!isLoading) {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}, [transactionToEdit, isLoading]); // Add isLoading dependency
|
||||
|
||||
// --- Helper Functions ---
|
||||
const resetForm = () => {
|
||||
setEditingId(null);
|
||||
setDate(new Date().toISOString().split("T")[0]);
|
||||
setDescription("");
|
||||
setAmount("");
|
||||
setError(null);
|
||||
// Don't reset isLoading here, it's handled in submit/cancel
|
||||
};
|
||||
|
||||
const validateForm = (): string[] => {
|
||||
const errors: string[] = [];
|
||||
if (!description || description.trim().length < 2) {
|
||||
errors.push("Description must be at least 2 characters long");
|
||||
}
|
||||
if (!amount) {
|
||||
errors.push("Amount is required");
|
||||
} else {
|
||||
const amountNum = parseFloat(amount);
|
||||
if (isNaN(amountNum)) {
|
||||
errors.push("Amount must be a valid number");
|
||||
} else if (amountNum === 0) {
|
||||
errors.push("Amount cannot be zero");
|
||||
}
|
||||
}
|
||||
if (!date) {
|
||||
errors.push("Date is required");
|
||||
} else {
|
||||
try {
|
||||
const dateObj = new Date(date + "T00:00:00"); // Treat input as local date
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
errors.push("Invalid date format");
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push("Invalid date format");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
// --- Event Handlers ---
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (isLoading || !currentAccountId) {
|
||||
if (!currentAccountId) setError("No account selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const validationErrors = validateForm();
|
||||
if (validationErrors.length > 0) {
|
||||
setError(validationErrors.join(". "));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Ensure date is sent in a consistent format (e.g., YYYY-MM-DD)
|
||||
// The API should handle parsing this.
|
||||
const transactionData = {
|
||||
accountId: currentAccountId,
|
||||
date: date, // Send as YYYY-MM-DD string
|
||||
description: description.trim(),
|
||||
amount: parseFloat(amount),
|
||||
};
|
||||
|
||||
const method = editingId ? "PUT" : "POST";
|
||||
const url = editingId
|
||||
? `/api/transactions/${editingId}`
|
||||
: "/api/transactions";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(transactionData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Failed to ${
|
||||
isEditMode ? "update" : "create"
|
||||
} transaction`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMsg = errorData.error || errorMsg;
|
||||
} catch (jsonError) {
|
||||
// Ignore if response is not JSON
|
||||
errorMsg = `${response.status}: ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const savedTransaction: Transaction = await response.json();
|
||||
transactionSaved(savedTransaction); // Call store action instead of prop callback
|
||||
resetForm(); // Reset form on success
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "An unexpected error occurred"
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
resetForm();
|
||||
cancelEditingTransaction(); // Call store action instead of prop callback
|
||||
};
|
||||
|
||||
// --- JSX ---
|
||||
return (
|
||||
<form id="add-transaction-form-react" onSubmit={handleSubmit} noValidate>
|
||||
<h4>{isEditMode ? "Edit Transaction" : "New Transaction"}</h4>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="txn-date-react">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
id="txn-date-react"
|
||||
name="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="txn-description-react">Description</label>
|
||||
<input
|
||||
type="text"
|
||||
id="txn-description-react"
|
||||
name="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={100}
|
||||
placeholder="e.g. Groceries"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="txn-amount-react">Amount</label>
|
||||
<input
|
||||
type="number"
|
||||
id="txn-amount-react"
|
||||
name="amount"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
step="0.01"
|
||||
required
|
||||
placeholder="e.g. -25.50 or 1200.00"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<small className="help-text">
|
||||
Use negative numbers for expenses (e.g., -50.00)
|
||||
</small>
|
||||
</div>
|
||||
<div className="button-group">
|
||||
<button
|
||||
type="submit"
|
||||
className={`form-submit-btn ${isLoading ? "loading" : ""}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading
|
||||
? "Saving..."
|
||||
: isEditMode
|
||||
? "Update Transaction"
|
||||
: "Save Transaction"}
|
||||
</button>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="cancel-btn"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user