first commit
This commit is contained in:
322
apps/web/src/components/dialogs/TransactionDialog.tsx
Normal file
322
apps/web/src/components/dialogs/TransactionDialog.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { MultipleSelector, type Option } from "@/components/ui/multiselector"
|
||||
import { DatePicker } from "@/components/ui/date-picker"
|
||||
import axios from "axios"
|
||||
|
||||
interface Wallet {
|
||||
id: string
|
||||
name: string
|
||||
kind: "money" | "asset"
|
||||
currency?: string | null
|
||||
unit?: string | null
|
||||
deletedAt?: string | null
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string
|
||||
walletId: string
|
||||
date: string
|
||||
amount: number
|
||||
direction: "in" | "out"
|
||||
category?: string | null
|
||||
memo?: string | null
|
||||
}
|
||||
|
||||
interface TransactionDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
transaction?: Transaction | null
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function TransactionDialog({ open, onOpenChange, transaction, onSuccess }: TransactionDialogProps) {
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [categoryOptions, setCategoryOptions] = useState<Option[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [amount, setAmount] = useState(transaction?.amount?.toString() || "")
|
||||
const [walletId, setWalletId] = useState(transaction?.walletId || "")
|
||||
const [direction, setDirection] = useState<"in" | "out">(transaction?.direction || "out")
|
||||
const [categories, setCategories] = useState<string[]>(
|
||||
transaction?.category ? transaction.category.split(',').map(c => c.trim()) : []
|
||||
)
|
||||
const [memo, setMemo] = useState(transaction?.memo || "")
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(
|
||||
transaction?.date ? new Date(transaction.date) : new Date()
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const isEditing = !!transaction
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadWallets()
|
||||
loadCategories()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const loadWallets = async () => {
|
||||
try {
|
||||
const response = await axios.get(`${API}/wallets`)
|
||||
setWallets(response.data.filter((w: Wallet) => !w.deletedAt))
|
||||
} catch (error) {
|
||||
console.error('Failed to load wallets:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
// Get categories from the dedicated Category table
|
||||
const response = await axios.get(`${API}/categories`)
|
||||
const categories = response.data
|
||||
|
||||
const options: Option[] = categories.map((cat: any) => ({
|
||||
label: cat.name,
|
||||
value: cat.name
|
||||
}))
|
||||
|
||||
setCategoryOptions(options)
|
||||
} catch (error) {
|
||||
console.error('Failed to load categories:', error)
|
||||
// Fallback: extract from existing transactions if categories endpoint fails
|
||||
try {
|
||||
const txResponse = await axios.get(`${API}/wallets/transactions`)
|
||||
const allTransactions = txResponse.data
|
||||
|
||||
const uniqueCategories = new Set<string>()
|
||||
allTransactions.forEach((tx: Transaction) => {
|
||||
if (tx.category) {
|
||||
tx.category.split(',').forEach(cat => {
|
||||
const trimmed = cat.trim()
|
||||
if (trimmed) uniqueCategories.add(trimmed)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const fallbackOptions: Option[] = Array.from(uniqueCategories).map(cat => ({
|
||||
label: cat,
|
||||
value: cat
|
||||
}))
|
||||
|
||||
setCategoryOptions(fallbackOptions)
|
||||
} catch (fallbackError) {
|
||||
console.error('Failed to load categories from transactions:', fallbackError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const amountNum = parseFloat(amount)
|
||||
if (!amountNum || amountNum <= 0) {
|
||||
setError("Amount must be a positive number")
|
||||
return
|
||||
}
|
||||
|
||||
if (!walletId) {
|
||||
setError("Please select a wallet")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// First, create any new categories
|
||||
if (categories.length > 0) {
|
||||
for (const category of categories) {
|
||||
try {
|
||||
await axios.post(`${API}/categories`, { name: category })
|
||||
} catch (error: any) {
|
||||
// Ignore if category already exists (409 conflict)
|
||||
if (error.response?.status !== 409) {
|
||||
console.error('Failed to create category:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
amount: amountNum,
|
||||
direction,
|
||||
category: categories.join(', ').trim() || undefined,
|
||||
memo: memo.trim() || undefined,
|
||||
date: selectedDate.toISOString()
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
await axios.put(`${API}/wallets/${walletId}/transactions/${transaction.id}`, data)
|
||||
} else {
|
||||
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
onOpenChange(false)
|
||||
|
||||
// Reset form
|
||||
resetForm()
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save transaction'
|
||||
setError(message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setAmount("")
|
||||
setWalletId("")
|
||||
setDirection("out")
|
||||
setCategories([])
|
||||
setMemo("")
|
||||
setSelectedDate(new Date())
|
||||
setError(null)
|
||||
}
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
onOpenChange(newOpen)
|
||||
if (!newOpen) {
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Update form when transaction prop changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (transaction) {
|
||||
setAmount(transaction.amount.toString())
|
||||
setWalletId(transaction.walletId)
|
||||
setDirection(transaction.direction)
|
||||
setCategories(transaction.category ? transaction.category.split(',').map(c => c.trim()) : [])
|
||||
setMemo(transaction.memo || "")
|
||||
setSelectedDate(new Date(transaction.date))
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
}, [open, transaction])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Transaction" : "Add New Transaction"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditing ? "Update your transaction details." : "Record a new transaction."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wallet">Wallet</Label>
|
||||
<Select value={walletId} onValueChange={setWalletId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a wallet" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wallets.map(wallet => (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
{wallet.name} ({wallet.currency || wallet.unit})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="amount">Amount</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="direction">Type</Label>
|
||||
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="in">Income</SelectItem>
|
||||
<SelectItem value="out">Expense</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date">Date</Label>
|
||||
<DatePicker
|
||||
date={selectedDate}
|
||||
onDateChange={(date) => date && setSelectedDate(date)}
|
||||
placeholder="Select date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category">Categories</Label>
|
||||
<MultipleSelector
|
||||
options={categoryOptions}
|
||||
selected={categories}
|
||||
onChange={setCategories}
|
||||
placeholder="Select or create categories..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="memo">Memo</Label>
|
||||
<Input
|
||||
id="memo"
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 bg-red-50 p-2 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : isEditing ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user