- 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
330 lines
11 KiB
TypeScript
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>
|
|
)
|
|
}
|