Files
tabungin/apps/web/src/components/dialogs/TransactionDialog.tsx
dwindown 371b5e0a66 feat: Implement multi-language system (ID/EN) for member dashboard
- 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)
2025-10-12 08:51:48 +07:00

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