Files
tabungin/apps/web/src/components/dialogs/TransactionDialog.tsx
dwindown 49d60676d0 feat: Add FAB with asset price update, mobile optimizations, and localized financial trend
- Add Floating Action Button (FAB) with 3 quick actions
- Implement Asset Price Update dialog for bulk price updates
- Add bulk price update API endpoint with transaction support
- Optimize multiselect, calendar, and dropdown options for mobile (44px touch targets)
- Add custom date range popover to save space in Overview header
- Localize number format suffixes (k/m/b for EN, rb/jt/m for ID)
- Localize date format in Financial Trend (Oct 8 vs 8 Okt)
- Fix negative values in trend line chart (domain auto)
- Improve Asset Price Update dialog layout (compact horizontal)
- Add mobile-optimized calendar with responsive cells
- Fix FAB overlay and close button position
- Add translations for FAB and asset price updates
2025-10-12 23:30:54 +07:00

330 lines
11 KiB
TypeScript

import { useState, useEffect } from "react"
import { toast } from "sonner"
import { useLanguage } from "@/contexts/LanguageContext"
import { Button } from "@/components/ui/button"
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
} from "@/components/ui/responsive-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(t.transactionDialog.editSuccess)
} else {
await axios.post(`${API}/wallets/${walletId}/transactions`, data)
toast.success(t.transactionDialog.addSuccess)
}
onSuccess()
onOpenChange(false)
} catch (error) {
console.error("Failed to save transaction:", error)
toast.error(t.transactionDialog.saveError)
} 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 (
<ResponsiveDialog open={open} onOpenChange={handleOpenChange}>
<ResponsiveDialogContent className="sm:max-w-[425px]">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>{isEditing ? t.transactionDialog.editTitle : t.transactionDialog.addTitle}</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
{t.transactionDialog.description}
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 p-4 md:py-4 md:px-0">
<div className="grid gap-2">
<Label htmlFor="wallet" className="text-base md:text-sm">{t.transactionDialog.wallet}</Label>
<Select value={walletId} onValueChange={setWalletId}>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<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" className="text-base md:text-sm">{t.transactionDialog.amount}</Label>
<Input
id="amount"
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="direction" className="text-base md:text-sm">{t.transactionDialog.direction}</Label>
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<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" className="text-base md:text-sm">{t.transactionDialog.date}</Label>
<DatePicker
date={selectedDate}
onDateChange={(date) => date && setSelectedDate(date)}
placeholder={t.transactionDialog.selectDate}
className="h-11 md:h-9 text-base md:text-sm w-full"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category" className="text-base md:text-sm">{t.transactionDialog.category}</Label>
<MultipleSelector
options={categoryOptions}
selected={categories}
onChange={setCategories}
placeholder={t.transactionDialog.selectCategory}
className="min-h-[44px] md:min-h-[40px] text-base md:text-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="memo" className="text-base md:text-sm">{t.transactionDialog.memo}</Label>
<Input
id="memo"
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder={t.transactionDialog.addMemo}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
{error && (
<div className="text-sm text-red-600 bg-red-50 p-2 rounded">
{error}
</div>
)}
</div>
<ResponsiveDialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)} className="h-11 md:h-9 text-base md:text-sm">
{t.common.cancel}
</Button>
<Button type="submit" disabled={loading} className="h-11 md:h-9 text-base md:text-sm">
{loading ? t.common.loading : isEditing ? t.common.save : t.common.add}
</Button>
</ResponsiveDialogFooter>
</form>
</ResponsiveDialogContent>
</ResponsiveDialog>
)
}