Files
finance/src/components/AddTransactionForm.tsx
GitHub Copilot 892ea684f4 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
2025-04-24 15:49:19 -04:00

261 lines
7.9 KiB
TypeScript

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>
);
}