- Create translation files (locales/id.ts, locales/en.ts) - Add LanguageContext with useLanguage hook - Add LanguageToggle component in sidebar - Default language: Indonesian (ID) - Translate WalletDialog and TransactionDialog - Language preference persisted in localStorage - Type-safe translations with autocomplete Next: Translate remaining pages (Overview, Wallets, Transactions, Profile)
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { useState, useEffect } from "react"
|
|
import { toast } from "sonner"
|
|
import { useLanguage } from "@/contexts/LanguageContext"
|
|
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
|
|
walletId?: string | null
|
|
onSuccess: () => void
|
|
}
|
|
|
|
const API = "/api"
|
|
|
|
export function TransactionDialog({ open, onOpenChange, transaction, walletId: initialWalletId, onSuccess }: TransactionDialogProps) {
|
|
const { t } = useLanguage()
|
|
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 || initialWalletId || "")
|
|
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: { name: string }) => ({
|
|
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) {
|
|
// Ignore if category already exists (409 conflict)
|
|
const err = error as { response?: { status?: number } }
|
|
if (err.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)
|
|
toast.success('Transaksi berhasil diupdate')
|
|
} else {
|
|
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
|
|
toast.success('Transaksi berhasil ditambahkan')
|
|
}
|
|
|
|
onSuccess()
|
|
onOpenChange(false)
|
|
} catch (error) {
|
|
console.error("Failed to save transaction:", error)
|
|
toast.error('Gagal menyimpan transaksi')
|
|
} 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 ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}</DialogTitle>
|
|
<DialogDescription>
|
|
{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="wallet">{t.transactionDialog.wallet}</Label>
|
|
<Select value={walletId} onValueChange={setWalletId}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t.transactionDialog.selectWallet} />
|
|
</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">{t.transactionDialog.amount}</Label>
|
|
<Input
|
|
id="amount"
|
|
type="number"
|
|
step="0.01"
|
|
value={amount}
|
|
onChange={(e) => setAmount(e.target.value)}
|
|
placeholder={t.transactionDialog.amountPlaceholder}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="direction">{t.transactionDialog.direction}</Label>
|
|
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="in">{t.transactionDialog.income}</SelectItem>
|
|
<SelectItem value="out">{t.transactionDialog.expense}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="date">{t.transactionDialog.date}</Label>
|
|
<DatePicker
|
|
date={selectedDate}
|
|
onDateChange={(date) => date && setSelectedDate(date)}
|
|
placeholder={t.transactionDialog.selectDate}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="category">{t.transactionDialog.category}</Label>
|
|
<MultipleSelector
|
|
options={categoryOptions}
|
|
selected={categories}
|
|
onChange={setCategories}
|
|
placeholder={t.transactionDialog.categoryPlaceholder}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<Label htmlFor="memo">{t.transactionDialog.memo}</Label>
|
|
<Input
|
|
id="memo"
|
|
value={memo}
|
|
onChange={(e) => setMemo(e.target.value)}
|
|
placeholder={t.transactionDialog.memoPlaceholder}
|
|
/>
|
|
</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)}>
|
|
{t.common.cancel}
|
|
</Button>
|
|
<Button type="submit" disabled={loading}>
|
|
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|