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
This commit is contained in:
dwindown
2025-10-12 23:30:54 +07:00
parent 46488a09e2
commit 49d60676d0
33 changed files with 1340 additions and 444 deletions

View File

@@ -1,3 +1,4 @@
import { useState, useCallback } from "react"
import { Routes, Route, useLocation, useNavigate } from "react-router-dom"
import { DashboardLayout } from "./layout/DashboardLayout"
import { Overview } from "./pages/Overview"
@@ -8,9 +9,28 @@ import { Profile } from "./pages/Profile"
export function Dashboard() {
const location = useLocation()
const navigate = useNavigate()
const [fabWalletDialogOpen, setFabWalletDialogOpen] = useState(false)
const [fabTransactionDialogOpen, setFabTransactionDialogOpen] = useState(false)
const handleOpenWalletDialog = useCallback(() => {
setFabWalletDialogOpen(true)
}, [])
const handleOpenTransactionDialog = useCallback(() => {
setFabTransactionDialogOpen(true)
}, [])
return (
<DashboardLayout currentPage={location.pathname} onNavigate={navigate}>
<DashboardLayout
currentPage={location.pathname}
onNavigate={navigate}
onOpenWalletDialog={handleOpenWalletDialog}
onOpenTransactionDialog={handleOpenTransactionDialog}
fabWalletDialogOpen={fabWalletDialogOpen}
setFabWalletDialogOpen={setFabWalletDialogOpen}
fabTransactionDialogOpen={fabTransactionDialogOpen}
setFabTransactionDialogOpen={setFabTransactionDialogOpen}
>
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/wallets" element={<Wallets />} />

View File

@@ -0,0 +1,239 @@
"use client"
import { useState, useEffect } from "react"
import { useLanguage } from "@/contexts/LanguageContext"
import axios from "axios"
import { toast } from "sonner"
import {
ResponsiveDialog,
ResponsiveDialogContent,
ResponsiveDialogHeader,
ResponsiveDialogTitle,
ResponsiveDialogDescription,
ResponsiveDialogFooter,
} from "@/components/ui/responsive-dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { RefreshCw, TrendingUp, AlertTriangle } from "lucide-react"
import { formatLargeNumber } from "@/utils/numberFormat"
const API = "/api"
interface Wallet {
id: string
name: string
kind: "money" | "asset"
unit?: string
pricePerUnit?: number
updatedAt: string
}
interface AssetPriceUpdate {
walletId: string
pricePerUnit: number
}
interface AssetPriceUpdateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AssetPriceUpdateDialog({ open, onOpenChange }: AssetPriceUpdateDialogProps) {
const { t } = useLanguage()
const [loading, setLoading] = useState(false)
const [fetchLoading, setFetchLoading] = useState(true)
const [error, setError] = useState("")
const [assetWallets, setAssetWallets] = useState<Wallet[]>([])
const [priceUpdates, setPriceUpdates] = useState<Record<string, string>>({})
// Fetch asset wallets
useEffect(() => {
if (open) {
fetchAssetWallets()
}
}, [open])
const fetchAssetWallets = async () => {
try {
setFetchLoading(true)
const response = await axios.get(`${API}/wallets`)
const assets = response.data.filter((wallet: Wallet) => wallet.kind === "asset")
setAssetWallets(assets)
// Initialize price updates with current prices
const initialPrices: Record<string, string> = {}
assets.forEach((wallet: Wallet) => {
initialPrices[wallet.id] = wallet.pricePerUnit?.toString() || "0"
})
setPriceUpdates(initialPrices)
} catch (error) {
console.error("Failed to fetch asset wallets:", error)
toast.error(t.common.error)
} finally {
setFetchLoading(false)
}
}
const handlePriceChange = (walletId: string, value: string) => {
// Allow only numbers and decimal point
if (value === "" || /^\d*\.?\d*$/.test(value)) {
setPriceUpdates(prev => ({ ...prev, [walletId]: value }))
}
}
const handleUpdatePrices = async () => {
try {
setLoading(true)
setError("")
// Prepare updates array (only include changed prices)
const updates: AssetPriceUpdate[] = []
assetWallets.forEach(wallet => {
const newPrice = parseFloat(priceUpdates[wallet.id])
const currentPrice = wallet.pricePerUnit || 0
if (!isNaN(newPrice) && newPrice !== currentPrice && newPrice > 0) {
updates.push({
walletId: wallet.id,
pricePerUnit: newPrice
})
}
})
if (updates.length === 0) {
setError(t.assetPriceUpdate.noChanges)
return
}
// Update prices
await axios.patch(`${API}/wallets/bulk-update-prices`, { updates })
toast.success(t.assetPriceUpdate.updateSuccess.replace("{count}", updates.length.toString()))
onOpenChange(false)
// Trigger page reload to refresh data
window.location.reload()
} catch (error) {
const err = error as { response?: { data?: { message?: string } } }
const errorMessage = err.response?.data?.message || t.assetPriceUpdate.updateError
setError(errorMessage)
toast.error(errorMessage)
} finally {
setLoading(false)
}
}
const formatLastUpdate = (date: string) => {
const now = new Date()
const updated = new Date(date)
const diffMs = now.getTime() - updated.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return t.assetPriceUpdate.justNow
if (diffMins < 60) return t.assetPriceUpdate.minutesAgo.replace("{minutes}", diffMins.toString())
if (diffHours < 24) return t.assetPriceUpdate.hoursAgo.replace("{hours}", diffHours.toString())
return t.assetPriceUpdate.daysAgo.replace("{days}", diffDays.toString())
}
return (
<ResponsiveDialog open={open} onOpenChange={onOpenChange}>
<ResponsiveDialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<ResponsiveDialogHeader>
<ResponsiveDialogTitle>
<TrendingUp className="h-5 w-5" />
{t.assetPriceUpdate.title}
</ResponsiveDialogTitle>
<ResponsiveDialogDescription>
{t.assetPriceUpdate.description}
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<div className="flex-1 overflow-y-auto px-4 md:px-0 py-4 md:py-0">
{error && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{fetchLoading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : assetWallets.length === 0 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{t.assetPriceUpdate.noAssets}</AlertDescription>
</Alert>
) : (
<div className="space-y-3">
{assetWallets.map((wallet) => (
<div key={wallet.id} className="bg-secondary/30 rounded-lg p-3">
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex-1 min-w-0">
<h4 className="font-semibold truncate">{wallet.name}</h4>
<p className="text-xs text-muted-foreground">
{t.assetPriceUpdate.lastUpdated}: {formatLastUpdate(wallet.updatedAt)}
</p>
{wallet.pricePerUnit && parseFloat(priceUpdates[wallet.id]) !== wallet.pricePerUnit && (
<span className="text-muted-foreground">
<span className="font-normal text-sm">{t.assetPriceUpdate.currentPrice}</span>: <span className="font-medium">{formatLargeNumber(wallet.pricePerUnit, 'IDR')}</span>
</span>
)}
</div>
<div className="flex-shrink-0 w-32">
<Label htmlFor={`price-${wallet.id}`} className="text-muted-foreground justify-end mb-3">
{t.assetPriceUpdate.pricePerUnit.replace('{unit}', wallet.unit || '')}
</Label>
<Input
id={`price-${wallet.id}`}
type="text"
inputMode="decimal"
value={priceUpdates[wallet.id] || ""}
onChange={(e) => handlePriceChange(wallet.id, e.target.value)}
placeholder="0"
disabled={loading}
className="h-11 md:h-9 text-sm font-mono text-right"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
<ResponsiveDialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
className="h-11 md:h-9 text-base md:text-sm"
>
{t.common.cancel}
</Button>
<Button
type="button"
onClick={handleUpdatePrices}
disabled={loading || fetchLoading || assetWallets.length === 0}
className="h-11 md:h-9 text-base md:text-sm"
>
{loading ? (
<>
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
{t.common.loading}
</>
) : (
t.assetPriceUpdate.updateAll
)}
</Button>
</ResponsiveDialogFooter>
</ResponsiveDialogContent>
</ResponsiveDialog>
)
}

View File

@@ -231,11 +231,11 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-4 p-4 md:py-4 md:px-0">
<div className="grid gap-2">
<Label htmlFor="wallet">{t.transactionDialog.wallet}</Label>
<Label htmlFor="wallet" className="text-base md:text-sm">{t.transactionDialog.wallet}</Label>
<Select value={walletId} onValueChange={setWalletId}>
<SelectTrigger>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactionDialog.selectWallet} />
</SelectTrigger>
<SelectContent>
@@ -250,22 +250,22 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="amount">{t.transactionDialog.amount}</Label>
<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={t.transactionDialog.amountPlaceholder}
required
placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="direction">{t.transactionDialog.direction}</Label>
<Label htmlFor="direction" className="text-base md:text-sm">{t.transactionDialog.direction}</Label>
<Select value={direction} onValueChange={(value: "in" | "out") => setDirection(value)}>
<SelectTrigger>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -277,31 +277,34 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
</div>
<div className="grid gap-2">
<Label htmlFor="date">{t.transactionDialog.date}</Label>
<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">{t.transactionDialog.category}</Label>
<Label htmlFor="category" className="text-base md:text-sm">{t.transactionDialog.category}</Label>
<MultipleSelector
options={categoryOptions}
selected={categories}
onChange={setCategories}
placeholder={t.transactionDialog.categoryPlaceholder}
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">{t.transactionDialog.memo}</Label>
<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.memoPlaceholder}
placeholder={t.transactionDialog.addMemo}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
@@ -312,10 +315,10 @@ export function TransactionDialog({ open, onOpenChange, transaction, walletId: i
)}
</div>
<ResponsiveDialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
<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}>
<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>

View File

@@ -149,22 +149,23 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
</ResponsiveDialogDescription>
</ResponsiveDialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-4 p-4 md:py-4 md:px-0">
<div className="grid gap-2">
<Label htmlFor="name">{t.walletDialog.name}</Label>
<Label htmlFor="name" className="text-base md:text-sm">{t.walletDialog.name}</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t.walletDialog.namePlaceholder}
className="h-11 md:h-9 text-base md:text-sm"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="kind">{t.walletDialog.type}</Label>
<Label htmlFor="kind" className="text-base md:text-sm">{t.walletDialog.type}</Label>
<Select value={kind} onValueChange={(value: "money" | "asset") => setKind(value)}>
<SelectTrigger>
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -176,14 +177,14 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
{kind === "money" ? (
<div className="grid gap-2">
<Label htmlFor="currency">{t.walletDialog.currency}</Label>
<Label htmlFor="currency" className="text-base md:text-sm">{t.walletDialog.currency}</Label>
<Popover open={currencyOpen} onOpenChange={setCurrencyOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={currencyOpen}
className="w-full justify-between"
className="w-full justify-between h-11 md:h-9 text-base md:text-sm"
>
{currency
? CURRENCIES.find((curr) => curr.code === currency)?.code + " - " + CURRENCIES.find((curr) => curr.code === currency)?.name
@@ -205,6 +206,7 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
setCurrency(curr.code)
setCurrencyOpen(false)
}}
className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
<Check
className={cn(
@@ -224,37 +226,40 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
) : (
<>
<div className="grid gap-2">
<Label htmlFor="unit">{t.walletDialog.unit}</Label>
<Label htmlFor="unit" className="text-base md:text-sm">{t.walletDialog.unit}</Label>
<Input
id="unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t.walletDialog.unitPlaceholder}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="pricePerUnit">{t.walletDialog.pricePerUnit}</Label>
<Label htmlFor="pricePerUnit" className="text-base md:text-sm">{t.walletDialog.pricePerUnit}</Label>
<Input
id="pricePerUnit"
type="number"
step="0.01"
value={pricePerUnit}
onChange={(e) => setPricePerUnit(e.target.value)}
placeholder={t.walletDialog.pricePerUnitPlaceholder}
placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="initialAmount">{t.walletDialog.initialAmount}</Label>
<Label htmlFor="initialAmount" className="text-base md:text-sm">{t.walletDialog.initialAmount}</Label>
<Input
id="initialAmount"
type="number"
step="0.01"
value={initialAmount}
onChange={(e) => setInitialAmount(e.target.value)}
placeholder={t.walletDialog.initialAmountPlaceholder}
placeholder="0"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
@@ -265,10 +270,10 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
)}
</div>
<ResponsiveDialogFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
<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}>
<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>

View File

@@ -1,6 +1,5 @@
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
import { Logo } from "../Logo"
import { LanguageToggle } from "../LanguageToggle"
import {
Sidebar,
SidebarContent,
@@ -112,16 +111,13 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
</div>
</div>
</div>
<div className="flex gap-2 mt-3">
<LanguageToggle />
<button
onClick={logout}
className="flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium text-destructive bg-destructive/10 rounded-lg hover:bg-destructive/20 transition-colors cursor-pointer"
>
<LogOut className="h-4 w-4 mr-2" />
{t.nav.logout}
</button>
</div>
<button
onClick={logout}
className="mt-3 w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-destructive bg-destructive/10 rounded-lg hover:bg-destructive/20 transition-colors cursor-pointer"
>
<LogOut className="h-4 w-4 mr-2" />
{t.nav.logout}
</button>
</SidebarFooter>
</Sidebar>
)

View File

@@ -1,15 +1,67 @@
import { useState } from "react"
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { AppSidebar } from "./AppSidebar"
import { ThemeToggle } from "@/components/ThemeToggle"
import { LanguageToggle } from "@/components/LanguageToggle"
import { Breadcrumb } from "@/components/Breadcrumb"
import { FloatingActionButton, FABTrendingUpIcon, FABWalletIcon, FABReceiptIcon } from "@/components/ui/floating-action-button"
import { AssetPriceUpdateDialog } from "@/components/dialogs/AssetPriceUpdateDialog"
import { WalletDialog } from "@/components/dialogs/WalletDialog"
import { TransactionDialog } from "@/components/dialogs/TransactionDialog"
import { useLanguage } from "@/contexts/LanguageContext"
interface DashboardLayoutProps {
children: React.ReactNode
currentPage: string
onNavigate: (page: string) => void
onOpenWalletDialog?: () => void
onOpenTransactionDialog?: () => void
fabWalletDialogOpen: boolean
setFabWalletDialogOpen: (open: boolean) => void
fabTransactionDialogOpen: boolean
setFabTransactionDialogOpen: (open: boolean) => void
}
export function DashboardLayout({ children, currentPage, onNavigate }: DashboardLayoutProps) {
export function DashboardLayout({
children,
currentPage,
onNavigate,
onOpenWalletDialog,
onOpenTransactionDialog,
fabWalletDialogOpen,
setFabWalletDialogOpen,
fabTransactionDialogOpen,
setFabTransactionDialogOpen
}: DashboardLayoutProps) {
const { t } = useLanguage()
const [assetPriceDialogOpen, setAssetPriceDialogOpen] = useState(false)
const handleDialogSuccess = () => {
// Reload page to refresh data
window.location.reload()
}
const fabActions = [
{
icon: <FABTrendingUpIcon className="h-5 w-5" />,
label: t.fab.updateAssetPrices,
onClick: () => setAssetPriceDialogOpen(true),
variant: "default" as const,
},
{
icon: <FABReceiptIcon className="h-5 w-5" />,
label: t.fab.quickTransaction,
onClick: () => onOpenTransactionDialog?.(),
variant: "secondary" as const,
},
{
icon: <FABWalletIcon className="h-5 w-5" />,
label: t.fab.quickWallet,
onClick: () => onOpenWalletDialog?.(),
variant: "secondary" as const,
},
]
return (
<SidebarProvider>
<AppSidebar currentPage={currentPage} onNavigate={onNavigate} />
@@ -27,7 +79,10 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard
year: 'numeric',
})}
</span>
<ThemeToggle />
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
</header>
<div className="flex-1 overflow-auto">
<div className="container mx-auto max-w-7xl p-4">
@@ -35,6 +90,25 @@ export function DashboardLayout({ children, currentPage, onNavigate }: Dashboard
</div>
</div>
</div>
{/* Floating Action Button */}
<FloatingActionButton actions={fabActions} />
{/* FAB Dialogs */}
<AssetPriceUpdateDialog
open={assetPriceDialogOpen}
onOpenChange={setAssetPriceDialogOpen}
/>
<WalletDialog
open={fabWalletDialogOpen}
onOpenChange={setFabWalletDialogOpen}
onSuccess={handleDialogSuccess}
/>
<TransactionDialog
open={fabTransactionDialogOpen}
onOpenChange={setFabTransactionDialogOpen}
onSuccess={handleDialogSuccess}
/>
</main>
</SidebarProvider>
)

View File

@@ -3,6 +3,7 @@ import { useLanguage } from "@/contexts/LanguageContext"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import { Plus, Wallet, TrendingUp, TrendingDown, Calendar } from "lucide-react"
import { ChartContainer, ChartTooltip } from "@/components/ui/chart"
import { Bar, XAxis, YAxis, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, Line, ComposedChart } from "recharts"
@@ -14,6 +15,11 @@ import {
SelectValue,
} from "@/components/ui/select"
import { DatePicker } from "@/components/ui/date-picker"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Table,
TableBody,
@@ -117,20 +123,30 @@ function getDateRangeLabel(dateRange: DateRange, customStartDate?: Date, customE
}
// Helper function to format Y-axis values with k/m suffix
function formatYAxisValue(value: number): string {
function formatYAxisValue(value: number, language: string = 'en'): string {
const absValue = Math.abs(value)
const sign = value < 0 ? '-' : ''
if (absValue >= 1000000) {
return `${sign}${(absValue / 1000000).toFixed(1)}m`
// Get suffix based on language
const getSuffix = (type: 'thousand' | 'million' | 'billion') => {
if (language === 'id') {
return { thousand: 'rb', million: 'jt', billion: 'm' }[type]
}
return { thousand: 'k', million: 'm', billion: 'b' }[type]
}
if (absValue >= 1000000000) {
return `${sign}${(absValue / 1000000000).toFixed(1)}${getSuffix('billion')}`
} else if (absValue >= 1000000) {
return `${sign}${(absValue / 1000000).toFixed(1)}${getSuffix('million')}`
} else if (absValue >= 1000) {
return `${sign}${(absValue / 1000).toFixed(1)}k`
return `${sign}${(absValue / 1000).toFixed(1)}${getSuffix('thousand')}`
}
return value.toLocaleString()
}
export function Overview() {
const { t } = useLanguage()
const { t, language } = useLanguage()
const [wallets, setWallets] = useState<Wallet[]>([])
const [transactions, setTransactions] = useState<Transaction[]>([])
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({})
@@ -309,10 +325,14 @@ export function Overview() {
for (let i = periodsCount - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
const locale = language === 'id' ? 'id-ID' : 'en-US'
const label = language === 'id'
? `${date.getDate()} ${date.toLocaleDateString(locale, { month: 'short' })}`
: date.toLocaleDateString(locale, { month: 'short', day: 'numeric' })
periods.push({
start: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59),
label: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
label
})
}
break
@@ -327,10 +347,15 @@ export function Overview() {
weekEnd.setDate(weekStart.getDate() + 6)
weekEnd.setHours(23, 59, 59)
const locale = language === 'id' ? 'id-ID' : 'en-US'
const label = language === 'id'
? `${weekStart.getDate()} ${weekStart.toLocaleDateString(locale, { month: 'short' })}`
: weekStart.toLocaleDateString(locale, { month: 'short', day: 'numeric' })
periods.push({
start: weekStart,
end: weekEnd,
label: `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
label
})
}
break
@@ -341,10 +366,11 @@ export function Overview() {
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1)
const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59)
const locale = language === 'id' ? 'id-ID' : 'en-US'
periods.push({
start: monthStart,
end: monthEnd,
label: date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' })
label: date.toLocaleDateString(locale, { month: 'short', year: '2-digit' })
})
}
break
@@ -498,8 +524,8 @@ export function Overview() {
if (loading) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<div className="grid gap-4 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse" />
@@ -518,7 +544,7 @@ export function Overview() {
return (
<div className="space-y-6">
{/* Header */}
<div className="space-y-4">
<div className="flex flex-col md:flex-row md:justify-between md:items-end gap-3">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t.overview.title}</h1>
@@ -529,15 +555,15 @@ export function Overview() {
</div>
{/* Date Range Filter */}
<div className="space-y-3 flex flex-col md:flex-row md:flex-wrap md:justify-between gap-3 md:w-full">
<div className="space-y-3 flex flex-col items-end">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex flex-col sm:flex-row gap-3 w-full">
<label className="text-xs font-medium text-muted-foreground flex flex-row flex-nowrap items-center gap-1">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{t.overview.overviewPeriod}</span>
</label>
<Select value={dateRange} onValueChange={(value: DateRange) => setDateRange(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectTrigger className="w-full sm:w-[180px] h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.overview.overviewPeriodPlaceholder} />
</SelectTrigger>
<SelectContent>
@@ -550,38 +576,43 @@ export function Overview() {
</SelectContent>
</Select>
{/* Custom Date Fields */}
{/* Custom Date Range Popover */}
{dateRange === 'custom' && (
<div className="flex gap-3">
<DatePicker
date={customStartDate}
onDateChange={setCustomStartDate}
placeholder={t.overview.customStartDatePlaceholder}
className="w-50 sm:w-[200px]"
/>
<DatePicker
date={customEndDate}
onDateChange={setCustomEndDate}
placeholder={t.overview.customEndDatePlaceholder}
className="w-50 sm:w-[200px]"
/>
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-11 md:h-9 text-base md:text-sm">
<Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate
? `${customStartDate.toLocaleDateString()} - ${customEndDate.toLocaleDateString()}`
: t.overview.selectDateRange}
</Button>
</PopoverTrigger>
<PopoverContent className="w-full md:w-auto p-4" align="end">
<div className="space-y-3 md:grid md:grid-cols-2 md:gap-3">
<div>
<Label className="text-sm font-medium mb-2 block">{t.dateRange.from}</Label>
<DatePicker
date={customStartDate}
onDateChange={setCustomStartDate}
placeholder={t.overview.customStartDatePlaceholder}
className="w-full h-11 md:h-9 text-base md:text-sm"
/>
</div>
<div>
<Label className="text-sm font-medium mb-2 block">{t.dateRange.to}</Label>
<DatePicker
date={customEndDate}
onDateChange={setCustomEndDate}
placeholder={t.overview.customEndDatePlaceholder}
className="w-full h-11 md:h-9 text-base md:text-sm"
/>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
<hr className="my-2 block sm:hidden" />
<div className="w-full md:w-fit grid grid-cols-2 gap-3">
<Button onClick={() => setWalletDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t.overview.addWallet}
</Button>
<Button variant="outline" onClick={() => setTransactionDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t.overview.addTransaction}
</Button>
</div>
</div>
</div>
@@ -647,10 +678,10 @@ export function Overview() {
<TableHeader>
<TableRow>
<TableHead>{t.overview.walletTheadName}</TableHead>
<TableHead className="text-center">{t.overview.walletTheadCurrencyUnit}</TableHead>
<TableHead className="text-center">{t.overview.walletTheadTransactions}</TableHead>
<TableHead className="text-right">{t.overview.walletTheadTotalBalance}</TableHead>
<TableHead className="text-right">{t.overview.walletTheadDomination}</TableHead>
<TableHead className="text-center text-nowrap">{t.overview.walletTheadCurrencyUnit}</TableHead>
<TableHead className="text-center text-nowrap">{t.overview.walletTheadTransactions}</TableHead>
<TableHead className="text-right text-nowrap">{t.overview.walletTheadTotalBalance}</TableHead>
<TableHead className="text-right text-nowrap">{t.overview.walletTheadDomination}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -726,7 +757,7 @@ export function Overview() {
<div className="flex flex-col gap-2">
<span>{t.overview.incomeCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={incomeChartWallet} onValueChange={setIncomeChartWallet}>
<SelectTrigger className="w-full max-w-[180px]">
<SelectTrigger className="w-full max-w-[180px] h-11 md:h-9 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -857,7 +888,7 @@ export function Overview() {
<div className="flex flex-col gap-2">
<span>{t.overview.expenseCategoryFor} {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={expenseChartWallet} onValueChange={setExpenseChartWallet}>
<SelectTrigger className="w-full max-w-[180px]">
<SelectTrigger className="w-full max-w-[180px] h-11 md:h-9 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -990,7 +1021,7 @@ export function Overview() {
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span>{t.overview.financialTrendDescription}</span>
<Select value={trendPeriod} onValueChange={(value: TrendPeriod) => setTrendPeriod(value)}>
<SelectTrigger className="w-full max-w-[140px]">
<SelectTrigger className="w-full max-w-[140px] h-11 md:h-9 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -1029,9 +1060,10 @@ export function Overview() {
tickMargin={8}
/>
<YAxis
tickFormatter={formatYAxisValue}
tickFormatter={(value) => formatYAxisValue(value, language)}
fontSize={12}
width={60}
domain={['auto', 'auto']}
/>
<ChartTooltip
content={({ active, payload, label }) => {

View File

@@ -20,8 +20,6 @@ import {
Copy,
Check,
User,
UserCircle,
Lock,
Upload,
Trash2
} from "lucide-react"
@@ -58,7 +56,6 @@ export function Profile() {
const [editedName, setEditedName] = useState("")
const [nameLoading, setNameLoading] = useState(false)
const [nameError, setNameError] = useState("")
const [nameSuccess, setNameSuccess] = useState("")
// Avatar upload
const [avatarUploading, setAvatarUploading] = useState(false)
@@ -74,7 +71,6 @@ export function Profile() {
const [phone, setPhone] = useState("")
const [phoneLoading, setPhoneLoading] = useState(false)
const [phoneError, setPhoneError] = useState("")
const [phoneSuccess, setPhoneSuccess] = useState("")
// Email OTP states
const [emailOtpCode, setEmailOtpCode] = useState("")
@@ -98,7 +94,6 @@ export function Profile() {
const [confirmPassword, setConfirmPassword] = useState("")
const [passwordLoading, setPasswordLoading] = useState(false)
const [passwordError, setPasswordError] = useState("")
const [passwordSuccess, setPasswordSuccess] = useState("")
useEffect(() => {
loadOtpStatus()
@@ -165,23 +160,22 @@ export function Profile() {
try {
setNameLoading(true)
setNameError("")
setNameSuccess("")
if (!editedName || editedName.trim().length === 0) {
setNameError("Name cannot be empty")
setNameError(t.profile.nameError)
return
}
await axios.put(`${API}/users/profile`, { name: editedName })
toast.success('Nama berhasil diupdate')
setNameSuccess("Name updated successfully!")
toast.success(t.profile.nameSuccess)
setIsEditingName(false)
// Reload user data
window.location.reload()
} catch (error) {
const err = error as { response?: { data?: { message?: string } } }
setNameError(err.response?.data?.message || "Failed to update name")
setNameError(err.response?.data?.message || t.profile.nameLoadingError)
toast.error(err.response?.data?.message || t.profile.nameLoadingError)
} finally {
setNameLoading(false)
}
@@ -216,7 +210,7 @@ export function Profile() {
}
})
toast.success('Avatar berhasil diupdate')
toast.success(t.profile.avatarSuccess)
// Reload user data to get new avatar URL
window.location.reload()
} catch (error) {
@@ -233,7 +227,7 @@ export function Profile() {
setDeleteError("")
if (!deletePassword) {
setDeleteError("Please enter your password")
setDeleteError(t.profile.enterPassword)
return
}
@@ -241,13 +235,13 @@ export function Profile() {
data: { password: deletePassword }
})
toast.success('Akun berhasil dihapus')
toast.success(t.profile.deleteSuccess)
// Logout and redirect to login
localStorage.removeItem('token')
window.location.href = '/auth/login'
} catch (error) {
const err = error as { response?: { data?: { message?: string } } }
setDeleteError(err.response?.data?.message || "Failed to delete account")
toast.error(err.response?.data?.message || t.profile.deleteError)
} finally {
setDeleteLoading(false)
}
@@ -257,35 +251,33 @@ export function Profile() {
try {
setPhoneLoading(true)
setPhoneError("")
setPhoneSuccess("")
if (!phone || phone.length < 10) {
setPhoneError(t.profile.phoneNumber + " tidak valid")
setPhoneError(t.profile.phoneInvalid)
return
}
// Check if number is registered on WhatsApp using webhook
const checkResponse = await axios.post(`${API}/otp/send`, {
method: 'whatsapp',
mode: 'check_number',
to: phone
// Check if number is registered on WhatsApp
const checkResponse = await axios.post(`${API}/otp/whatsapp/check`, {
phone: phone
})
if (checkResponse.data.code === 'SUCCESS' && checkResponse.data.results?.is_on_whatsapp === false) {
setPhoneError("Nomor ini tidak terdaftar di WhatsApp. Silakan coba nomor lain.")
// If check failed or number not registered, show error
if (!checkResponse.data.success || !checkResponse.data.isRegistered) {
setPhoneError(checkResponse.data.message || t.profile.phoneNotRegistered)
return
}
// Update phone
await axios.put(`${API}/users/profile`, { phone })
toast.success(t.profile.phoneNumber + ' berhasil diupdate')
setPhoneSuccess(t.profile.phoneNumber + " updated successfully!")
toast.success(t.profile.phoneSuccess)
// Reload OTP status
await loadOtpStatus()
} catch (error) {
const err = error as { response?: { data?: { message?: string } } }
setPhoneError(err.response?.data?.message || "Failed to update phone number")
setPhoneError(err.response?.data?.message || t.profile.phoneError)
toast.error(err.response?.data?.message || t.profile.phoneError)
} finally {
setPhoneLoading(false)
}
@@ -295,7 +287,7 @@ export function Profile() {
try {
setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/send`)
toast.success('Kode OTP telah dikirim ke email')
toast.success(t.profile.emailOtpSent)
setEmailOtpSent(true)
} catch (error) {
console.error('Failed to send email OTP:', error)
@@ -308,7 +300,7 @@ export function Profile() {
try {
setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/verify`, { code: emailOtpCode })
toast.success('Email OTP berhasil diaktifkan')
toast.success(t.profile.emailOtpEnabled)
await loadOtpStatus()
setEmailOtpCode("")
setEmailOtpSent(false)
@@ -323,7 +315,7 @@ export function Profile() {
try {
setEmailOtpLoading(true)
await axios.post(`${API}/otp/email/disable`)
toast.success('Email OTP berhasil dinonaktifkan')
toast.success(t.profile.emailOtpDisabled)
await loadOtpStatus()
} catch (error) {
console.error('Failed to disable email OTP:', error)
@@ -336,7 +328,7 @@ export function Profile() {
try {
setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/send`, { mode: 'test' })
toast.success('Kode OTP telah dikirim ke WhatsApp')
toast.success(t.profile.whatsappOtpSent)
setWhatsappOtpSent(true)
} catch (error) {
console.error('Failed to send WhatsApp OTP:', error)
@@ -349,7 +341,7 @@ export function Profile() {
try {
setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/verify`, { code: whatsappOtpCode })
toast.success('WhatsApp OTP berhasil diaktifkan')
toast.success(t.profile.whatsappOtpEnabled)
await loadOtpStatus()
setWhatsappOtpCode("")
setWhatsappOtpSent(false)
@@ -364,7 +356,7 @@ export function Profile() {
try {
setWhatsappOtpLoading(true)
await axios.post(`${API}/otp/whatsapp/disable`)
toast.success('WhatsApp OTP berhasil dinonaktifkan')
toast.success(t.profile.whatsappOtpDisabled)
await loadOtpStatus()
} catch (error) {
console.error('Failed to disable WhatsApp OTP:', error)
@@ -394,7 +386,7 @@ export function Profile() {
try {
setTotpLoading(true)
await axios.post(`${API}/otp/totp/verify`, { code: totpCode })
toast.success('Authenticator App berhasil diaktifkan')
toast.success(t.profile.totpEnabled)
await loadOtpStatus()
setTotpCode("")
setShowTotpSetup(false)
@@ -409,7 +401,7 @@ export function Profile() {
try {
setTotpLoading(true)
await axios.post(`${API}/otp/totp/disable`)
toast.success('Authenticator App berhasil dinonaktifkan')
toast.success(t.profile.totpDisabled)
await loadOtpStatus()
setShowTotpSetup(false)
// Clear QR code and secret when disabling
@@ -435,30 +427,29 @@ export function Profile() {
const handleChangePassword = async () => {
setPasswordError("")
setPasswordSuccess("")
// Validation
if (!hasPassword) {
// For users without password: only need new password and confirmation
if (!newPassword || !confirmPassword) {
setPasswordError("Please enter and confirm your new password")
setPasswordError(t.profile.enterPassword)
return
}
} else {
// For users with password: need current password too
if (!currentPassword || !newPassword || !confirmPassword) {
setPasswordError("All fields are required")
setPasswordError(t.profile.enterPassword)
return
}
}
if (newPassword !== confirmPassword) {
setPasswordError("New passwords do not match")
setPasswordError("Password tidak cocok")
return
}
if (newPassword.length < 6) {
setPasswordError("New password must be at least 6 characters")
setPasswordError("Password minimal 6 karakter")
return
}
@@ -472,9 +463,7 @@ export function Profile() {
newPassword,
isSettingPassword: true // Flag to tell backend this is initial password
})
setPasswordSuccess("Password set successfully! You can now login with email/password.")
toast.success('Password berhasil diatur')
setPasswordSuccess("Password set successfully! Redirecting...")
toast.success(t.profile.passwordSetSuccess)
setTimeout(() => window.location.reload(), 2000)
} else {
// Change password for user with existing password
@@ -482,18 +471,18 @@ export function Profile() {
currentPassword,
newPassword
})
toast.success('Password berhasil diubah')
setPasswordSuccess("Password changed successfully!")
toast.success(t.profile.passwordChangeSuccess)
}
setCurrentPassword("")
setNewPassword("")
setConfirmPassword("")
await loadOtpStatus()
setTimeout(() => setPasswordSuccess(""), 3000)
} catch (error) {
const err = error as { response?: { data?: { message?: string } } }
setPasswordError(err.response?.data?.message || (!hasPassword ? "Failed to set password" : "Failed to change password"))
const errorMsg = err.response?.data?.message || t.profile.passwordError
setPasswordError(errorMsg)
toast.error(errorMsg)
} finally {
setPasswordLoading(false)
}
@@ -509,210 +498,188 @@ export function Profile() {
return (
<div className="space-y-6">
<div>
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold">{t.profile.title}</h1>
<p className="text-muted-foreground">
{t.profile.description}
</p>
<p className="text-muted-foreground">{t.profile.description}</p>
</div>
<Tabs defaultValue="profile" className="space-y-6">
<TabsList className="grid w-full grid-cols-2 max-w-md">
<TabsTrigger value="profile" className="flex items-center gap-2">
<UserCircle className="h-4 w-4" />
<div className="max-w-4xl mx-auto">
<Tabs defaultValue="profile" className="w-full">
<TabsList className="grid w-[50%] grid-cols-2 h-auto p-1">
<TabsTrigger value="profile" className="h-11 md:h-9 text-base md:text-sm data-[state=active]:bg-background">
{t.profile.editProfile}
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Lock className="h-4 w-4" />
<TabsTrigger value="security" className="h-11 md:h-9 text-base md:text-sm data-[state=active]:bg-background">
{t.profile.security}
</TabsTrigger>
</TabsList>
{/* Edit Profile Tab */}
<TabsContent value="profile" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t.profile.personalInfo}</CardTitle>
<CardDescription>{t.profile.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar Section */}
<div className="flex items-center gap-6">
<div className="relative">
{getAvatarUrl(user?.avatarUrl) ? (
<img
src={getAvatarUrl(user?.avatarUrl)!}
alt={user?.name || user?.email || 'User'}
className="h-20 w-20 rounded-full object-cover"
/>
) : (
<div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-10 w-10" />
</div>
)}
{!hasGoogleAuth && (
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 h-7 w-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center cursor-pointer hover:bg-primary/90 transition-colors"
>
{avatarUploading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={avatarUploading}
/>
</label>
)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">{user?.name || t.profile.name}</h3>
<p className="text-sm text-muted-foreground mb-2">{user?.email}</p>
{hasGoogleAuth ? (
<p className="text-xs text-muted-foreground">
{t.profile.avatarSynced}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t.profile.clickUploadAvatar}
</p>
)}
{avatarError && (
<p className="text-xs text-destructive mt-1">{avatarError}</p>
)}
</div>
</div>
<Separator />
{/* Name Field */}
<div className="space-y-2">
<Label htmlFor="name">{t.profile.name}</Label>
{hasGoogleAuth ? (
<>
<Input
id="name"
type="text"
value={user?.name || ""}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
{t.profile.nameSynced}
</p>
</>
) : (
<>
<div className="flex gap-2">
<Input
id="name"
type="text"
value={isEditingName ? editedName : (user?.name || "")}
onChange={(e) => setEditedName(e.target.value)}
disabled={!isEditingName || nameLoading}
className={!isEditingName ? "bg-muted" : ""}
/>
{isEditingName ? (
<>
<Button
onClick={handleUpdateName}
disabled={nameLoading}
size="sm"
>
{nameLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.save}
</Button>
<Button
variant="outline"
onClick={() => {
setIsEditingName(false)
setEditedName(user?.name || "")
setNameError("")
}}
disabled={nameLoading}
size="sm"
>
{t.profile.cancel}
</Button>
</>
) : (
<Button
variant="outline"
onClick={() => setIsEditingName(true)}
size="sm"
>
{t.profile.edit}
</Button>
)}
</div>
{nameError && (
<p className="text-xs text-destructive">{nameError}</p>
)}
{nameSuccess && (
<p className="text-xs text-green-600">{nameSuccess}</p>
)}
</>
)}
</div>
{/* Email Field */}
<div className="space-y-2">
<Label htmlFor="email">{t.profile.email}</Label>
<Input
id="email"
type="email"
value={user?.email || ""}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground">
{t.profile.emailCannotBeChanged}
</p>
</div>
{/* Phone Field */}
<div className="space-y-2">
<Label htmlFor="phone">{t.profile.phoneNumber}</Label>
<div className="flex gap-2">
<Input
id="phone"
type="tel"
placeholder="+1234567890"
value={phone}
onChange={(e) => setPhone(e.target.value)}
disabled={phoneLoading}
{/* Edit Profile Tab */}
<TabsContent value="profile" className="w-full space-y-6">
<Card>
<CardHeader>
<CardTitle>{t.profile.personalInfo}</CardTitle>
<CardDescription>{t.profile.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Avatar Section */}
<div className="flex items-center gap-6">
<div className="relative">
{getAvatarUrl(user?.avatarUrl) ? (
<img
src={getAvatarUrl(user?.avatarUrl)!}
alt={user?.name || user?.email || 'User'}
className="h-20 w-20 rounded-full object-cover"
/>
<Button
onClick={handleUpdatePhone}
disabled={phoneLoading || !phone}
) : (
<div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-10 w-10" />
</div>
)}
{!hasGoogleAuth && (
<label
htmlFor="avatar-upload"
className="absolute bottom-0 right-0 h-7 w-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center cursor-pointer hover:bg-primary/90 transition-colors"
>
{phoneLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.update}
</Button>
</div>
{phoneError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{phoneError}</AlertDescription>
</Alert>
{avatarUploading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
<input
id="avatar-upload"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={avatarUploading}
/>
</label>
)}
{phoneSuccess && (
<Alert>
<Check className="h-4 w-4" />
<AlertDescription>{phoneSuccess}</AlertDescription>
</Alert>
)}
<p className="text-xs text-muted-foreground">
{t.profile.phoneNumberDescription}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<div className="flex-1">
<h3 className="text-lg font-semibold">{user?.name || t.profile.name}</h3>
<p className="text-sm text-muted-foreground mb-2">{user?.email}</p>
{hasGoogleAuth ? (
<p className="text-xs text-muted-foreground">
{t.profile.avatarSynced}
</p>
) : (
<p className="text-xs text-muted-foreground">
{t.profile.clickUploadAvatar}
</p>
)}
{avatarError && (
<p className="text-xs text-destructive mt-1">{avatarError}</p>
)}
</div>
</div>
<Separator />
{/* Name Field */}
<div className="space-y-3 md:space-y-2">
<Label htmlFor="name" className="text-base md:text-sm">{t.profile.name}</Label>
{!isEditingName ? (
<>
<Input
id="name"
type="text"
value={user?.name || ''}
disabled
className="bg-muted h-11 md:h-9 text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">
{t.profile.nameSynced}
</p>
</>
) : (
<>
<div className="flex gap-2">
<Input
id="name-edit"
type="text"
value={editedName}
onChange={(e) => setEditedName(e.target.value)}
disabled={nameLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
<Button
type="button"
onClick={handleUpdateName}
disabled={nameLoading}
className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm min-w-[100px]"
>
{nameLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.save}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setIsEditingName(false)
setEditedName(user?.name || "")
setNameError("")
}}
disabled={nameLoading}
className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm min-w-[100px]"
>
{t.profile.cancel}
</Button>
</div>
{nameError && (
<p className="text-xs text-destructive">{nameError}</p>
)}
</>
)}
</div>
{/* Email Field */}
<div className="space-y-3 md:space-y-2">
<Label htmlFor="email" className="text-base md:text-sm">{t.profile.email}</Label>
<Input
id="email"
type="email"
value={user?.email || ''}
disabled
className="bg-muted h-11 md:h-9 text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">
{t.profile.emailCannotBeChanged}
</p>
</div>
{/* Phone Number Field */}
<div className="space-y-3 md:space-y-2">
<Label htmlFor="phone" className="text-base md:text-sm">{t.profile.phoneNumber}</Label>
<div className="flex gap-2">
<Input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder={t.profile.phoneNumberPlaceholder}
disabled={phoneLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
<Button
type="button"
onClick={handleUpdatePhone}
disabled={phoneLoading}
className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm min-w-[120px]"
>
{phoneLoading ? <RefreshCw className="h-4 w-4 animate-spin" /> : t.profile.update}
</Button>
</div>
{phoneError && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{phoneError}</AlertDescription>
</Alert>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Security Tab */}
<TabsContent value="security" className="space-y-6">
@@ -747,15 +714,9 @@ export function Profile() {
<AlertDescription>{passwordError}</AlertDescription>
</Alert>
)}
{passwordSuccess && (
<Alert className="bg-green-50 text-green-900 border-green-200">
<Check className="h-4 w-4" />
<AlertDescription>{passwordSuccess}</AlertDescription>
</Alert>
)}
{hasPassword && (
<div>
<Label htmlFor="current-password">{t.profile.currentPassword}</Label>
<Label htmlFor="current-password" className="text-base md:text-sm">{t.profile.currentPassword}</Label>
<Input
id="current-password"
type="password"
@@ -763,11 +724,12 @@ export function Profile() {
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={passwordLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
)}
<div>
<Label htmlFor="new-password">{t.profile.newPassword}</Label>
<Label htmlFor="new-password" className="text-base md:text-sm">{t.profile.newPassword}</Label>
<Input
id="new-password"
type="password"
@@ -775,10 +737,11 @@ export function Profile() {
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<div>
<Label htmlFor="confirm-password">{t.profile.confirmPassword}</Label>
<Label htmlFor="confirm-password" className="text-base md:text-sm">{t.profile.confirmPassword}</Label>
<Input
id="confirm-password"
type="password"
@@ -786,10 +749,11 @@ export function Profile() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<Button
className="w-full"
className="w-full h-11 md:h-9 text-base md:text-sm"
onClick={handleChangePassword}
disabled={passwordLoading}
>
@@ -856,7 +820,7 @@ export function Profile() {
<Button
onClick={handleWhatsappOtpRequest}
disabled={whatsappOtpLoading}
className="w-full"
className="w-full h-11 md:h-9 text-base md:text-sm"
>
{whatsappOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -872,7 +836,7 @@ export function Profile() {
{t.profile.checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode}
</AlertDescription>
</Alert>
<Label htmlFor="whatsapp-otp">{t.profile.enterVerificationCode}</Label>
<Label htmlFor="whatsapp-otp" className="text-base md:text-sm">{t.profile.enterVerificationCode}</Label>
<div className="flex gap-2">
<Input
id="whatsapp-otp"
@@ -881,10 +845,12 @@ export function Profile() {
value={whatsappOtpCode}
onChange={(e) => setWhatsappOtpCode(e.target.value)}
maxLength={6}
className="h-11 md:h-9 text-base md:text-sm"
/>
<Button
onClick={handleWhatsappOtpVerify}
disabled={whatsappOtpLoading || whatsappOtpCode.length !== 6}
className="h-11 md:h-9 text-base md:text-sm min-w-[100px]"
>
{whatsappOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
@@ -903,7 +869,7 @@ export function Profile() {
variant="destructive"
onClick={handleWhatsappOtpDisable}
disabled={whatsappOtpLoading}
className="w-full"
className="w-full h-11 md:h-9 text-base md:text-sm"
>
{whatsappOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -940,7 +906,7 @@ export function Profile() {
<Button
onClick={handleEmailOtpRequest}
disabled={emailOtpLoading}
className="w-full"
className="w-full h-11 md:h-9 text-base md:text-sm"
>
{emailOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -960,10 +926,12 @@ export function Profile() {
value={emailOtpCode}
onChange={(e) => setEmailOtpCode(e.target.value)}
maxLength={6}
className="h-11 md:h-9 text-base md:text-sm"
/>
<Button
onClick={handleEmailOtpVerify}
disabled={emailOtpLoading || emailOtpCode.length !== 6}
className="h-11 md:h-9 text-base md:text-sm min-w-[100px]"
>
{emailOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
@@ -980,6 +948,7 @@ export function Profile() {
variant="destructive"
onClick={handleEmailOtpDisable}
disabled={emailOtpLoading}
className="w-full h-11 md:h-9 text-base md:text-sm"
>
{emailOtpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -1016,7 +985,7 @@ export function Profile() {
<Button
onClick={handleTotpSetup}
disabled={totpLoading}
className="w-full"
className="w-full h-11 md:h-9 text-base md:text-sm"
>
{totpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -1055,12 +1024,13 @@ export function Profile() {
<Input
value={otpStatus.totpSecret}
readOnly
className="font-mono text-xs"
className="font-mono text-xs h-11 md:h-9"
/>
<Button
size="sm"
variant="outline"
onClick={copySecret}
className="h-11 md:h-9 min-w-[44px]"
>
{secretCopied ? (
<Check className="h-4 w-4" />
@@ -1079,10 +1049,12 @@ export function Profile() {
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
maxLength={6}
className="h-11 md:h-9 text-base md:text-sm"
/>
<Button
onClick={handleTotpVerify}
disabled={totpLoading || totpCode.length !== 6}
className="h-11 md:h-9 text-base md:text-sm min-w-[100px]"
>
{totpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin" />
@@ -1099,6 +1071,7 @@ export function Profile() {
variant="destructive"
onClick={handleTotpDisable}
disabled={totpLoading}
className="w-full h-11 md:h-9 text-base md:text-sm"
>
{totpLoading ? (
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
@@ -1112,7 +1085,10 @@ export function Profile() {
</CardContent>
</Card>
</div>
{/* Danger Zone */}
<Separator className="my-8" />
{/* Danger Zone - Inside Security Tab */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
@@ -1146,14 +1122,15 @@ export function Profile() {
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="delete-password">{t.profile.enterPasswordToDelete}</Label>
<Label htmlFor="delete-password" className="text-base md:text-sm">{t.profile.enterPasswordToDelete}</Label>
<Input
id="delete-password"
type="password"
placeholder="Enter your password"
placeholder={t.profile.enterPasswordToDeletePlaceholder}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
disabled={deleteLoading}
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<div className="flex gap-2">
@@ -1161,6 +1138,7 @@ export function Profile() {
variant="destructive"
onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword}
className="h-11 md:h-9 text-base md:text-sm"
>
{deleteLoading ? (
<>
@@ -1182,6 +1160,7 @@ export function Profile() {
setDeleteError("")
}}
disabled={deleteLoading}
className="h-11 md:h-9 text-base md:text-sm"
>
Cancel
</Button>
@@ -1191,6 +1170,7 @@ export function Profile() {
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
className="h-11 md:h-9 text-base md:text-sm"
>
<Trash2 className="h-4 w-4 mr-2" />
{t.profile.deleteAccount}
@@ -1202,5 +1182,6 @@ export function Profile() {
</TabsContent>
</Tabs>
</div>
</div>
)
}

View File

@@ -272,11 +272,11 @@ export function Transactions() {
</p>
</div>
<div className="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
<Button variant="outline" onClick={() => setShowFilters(!showFilters)} className="h-11 md:h-9 text-base md:text-sm">
<Filter className="mr-2 h-4 w-4" />
{showFilters ? t.common.hideFilters : t.common.showFilters}
</Button>
<Button onClick={() => setTransactionDialogOpen(true)}>
<Button onClick={() => setTransactionDialogOpen(true)} className="h-11 md:h-9 text-base md:text-sm">
<Plus className="mr-2 h-4 w-4" />
{t.transactions.addTransaction}
</Button>
@@ -333,7 +333,7 @@ export function Transactions() {
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-8 text-xs"
className="h-9 md:h-7 px-3 md:px-2 text-sm"
>
<X className="h-3 w-3 mr-1" />
{t.common.clearAll}
@@ -345,23 +345,23 @@ export function Transactions() {
<div className="grid gap-3 md:grid-cols-3">
{/* Search */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.searchMemo}</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.searchMemo}</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-3 md:top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t.transactions.filter.searchMemoPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
className="pl-9 h-11 md:h-9 text-base md:text-sm"
/>
</div>
</div>
{/* Wallet Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.wallet}</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.wallet}</Label>
<Select value={walletFilter} onValueChange={setWalletFilter}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactions.filter.walletPlaceholder} />
</SelectTrigger>
<SelectContent>
@@ -377,9 +377,9 @@ export function Transactions() {
{/* Direction Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">Direction</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">Direction</Label>
<Select value={directionFilter} onValueChange={setDirectionFilter}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.transactions.filter.directionPlaceholder} />
</SelectTrigger>
<SelectContent>
@@ -394,24 +394,24 @@ export function Transactions() {
{/* Row 2: Amount Range */}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.minAmount}</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.minAmount}</Label>
<Input
type="number"
placeholder={t.transactions.filter.minAmountPlaceholder}
value={amountMin}
onChange={(e) => setAmountMin(e.target.value)}
className="h-9"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.transactions.filter.maxAmount}</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">{t.transactions.filter.maxAmount}</Label>
<Input
type="number"
placeholder={t.transactions.filter.maxAmountPlaceholder}
value={amountMax}
onChange={(e) => setAmountMax(e.target.value)}
className="h-9"
className="h-11 md:h-9 text-base md:text-sm"
/>
</div>
</div>
@@ -506,11 +506,11 @@ export function Transactions() {
<TableRow>
<TableHead>{t.transactions.tableTheadDate}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadWallet}</TableHead>
<TableHead className="text-center">{t.transactions.tableTheadDirection}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadAmount}</TableHead>
<TableHead>{t.transactions.tableTheadCategory}</TableHead>
<TableHead>{t.transactions.tableTheadMemo}</TableHead>
<TableHead className="text-right">{t.transactions.tableTheadActions}</TableHead>
<TableHead className="text-center text-nowrap">{t.transactions.tableTheadDirection}</TableHead>
<TableHead className="text-right text-nowrap">{t.transactions.tableTheadAmount}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadCategory}</TableHead>
<TableHead className="text-nowrap">{t.transactions.tableTheadMemo}</TableHead>
<TableHead className="text-right text-nowrap">{t.transactions.tableTheadActions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -541,7 +541,7 @@ export function Transactions() {
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<TableCell className="text-center text-nowrap">
<Badge
variant={`outline`}
className={transaction.direction === 'in' ? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)] stroke-[var(--color-primary)] ring-1 ring-[var(--color-primary)]/75' : 'bg-[var(--color-destructive)]/10 text-[var(--color-destructive)] stroke-[var(--color-destructive)] ring-1 ring-[var(--color-destructive)]/75'}
@@ -552,7 +552,7 @@ export function Transactions() {
<TableCell className="font-mono text-right text-nowrap">
{formatCurrency(transaction.amount, wallet?.currency || wallet?.unit || 'IDR')}
</TableCell>
<TableCell>
<TableCell className="text-nowrap">
{transaction.category && (
<Badge variant="outline">{transaction.category}</Badge>
)}
@@ -560,7 +560,7 @@ export function Transactions() {
<TableCell className="max-w-[200px] truncate">
{transaction.memo}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right text-nowrap">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditTransaction(transaction)}>
<Edit className="h-4 w-4" />

View File

@@ -161,11 +161,11 @@ export function Wallets() {
</p>
</div>
<div className="flex gap-2 sm:flex-shrink-0">
<Button variant="outline" onClick={() => setShowFilters(!showFilters)}>
<Button variant="outline" onClick={() => setShowFilters(!showFilters)} className="h-11 md:h-9 text-base md:text-sm">
<Filter className="mr-2 h-4 w-4" />
{showFilters ? t.common.hideFilters : t.common.showFilters}
</Button>
<Button onClick={() => setWalletDialogOpen(true)}>
<Button onClick={() => setWalletDialogOpen(true)} className="h-11 md:h-9 text-base md:text-sm">
<Plus className="mr-2 h-4 w-4" />
{t.wallets.addWallet}
</Button>
@@ -223,7 +223,7 @@ export function Wallets() {
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-7 px-2"
className="h-9 md:h-7 px-3 md:px-2 text-sm"
>
<X className="h-3 w-3 mr-1" />
{t.common.clearAll}
@@ -235,23 +235,23 @@ export function Wallets() {
<div className="grid gap-3 md:grid-cols-3">
{/* Search */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.common.search}</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">{t.common.search}</Label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-2.5 top-3 md:top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t.wallets.searchPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9"
className="pl-9 h-11 md:h-9 text-base md:text-sm"
/>
</div>
</div>
{/* Type Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.wallets.type}</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">{t.wallets.type}</Label>
<Select value={kindFilter} onValueChange={setKindFilter}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.common.all} />
</SelectTrigger>
<SelectContent>
@@ -264,9 +264,9 @@ export function Wallets() {
{/* Currency Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">{t.wallets.currency}/{t.wallets.unit}</Label>
<Label className="text-base md:text-xs font-medium text-muted-foreground">{t.wallets.currency}/{t.wallets.unit}</Label>
<Select value={currencyFilter} onValueChange={setCurrencyFilter}>
<SelectTrigger className="h-9">
<SelectTrigger className="h-11 md:h-9 text-base md:text-sm">
<SelectValue placeholder={t.common.all} />
</SelectTrigger>
<SelectContent>
@@ -330,11 +330,11 @@ export function Wallets() {
<Table>
<TableHeader>
<TableRow>
<TableHead>{t.wallets.name}</TableHead>
<TableHead>{t.wallets.currency}/{t.wallets.unit}</TableHead>
<TableHead>{t.wallets.type}</TableHead>
<TableHead>{t.common.date}</TableHead>
<TableHead className="text-right">{t.common.actions}</TableHead>
<TableHead className="text-nowrap">{t.wallets.name}</TableHead>
<TableHead className="text-center text-nowrap">{t.wallets.currency}/{t.wallets.unit}</TableHead>
<TableHead className="text-center text-nowrap">{t.wallets.type}</TableHead>
<TableHead className="text-center text-nowrap">{t.common.date}</TableHead>
<TableHead className="text-right text-nowrap">{t.common.actions}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -351,14 +351,14 @@ export function Wallets() {
filteredWallets.map((wallet) => (
<TableRow key={wallet.id}>
<TableCell className="font-medium text-nowrap">{wallet.name}</TableCell>
<TableCell>
<TableCell className="text-center text-nowrap">
{wallet.kind === 'money' ? (
<Badge variant="outline" className="text-nowrap">{wallet.currency}</Badge>
) : (
<Badge variant="outline" className="text-nowrap">{wallet.unit}</Badge>
)}
</TableCell>
<TableCell>
<TableCell className="text-center text-nowrap">
<Badge
variant="outline"
className={`text-nowrap ${wallet.kind === 'money' ? 'bg-[var(--chart-1)]/20 text-[var(--chart-1)] ring-1 ring-[var(--chart-1)]' : 'bg-[var(--chart-2)]/20 text-[var(--chart-2)] ring-1 ring-[var(--chart-2)]'}`}
@@ -366,10 +366,10 @@ export function Wallets() {
{wallet.kind === 'money' ? t.wallets.money : t.wallets.asset}
</Badge>
</TableCell>
<TableCell>
<TableCell className="text-center text-nowrap">
{new Date(wallet.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right text-nowrap">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => handleEditWallet(wallet)}>
<Edit className="h-4 w-4" />

View File

@@ -34,7 +34,7 @@ function Calendar({
head_row: "flex",
head_cell: "rdp-weekday text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "rdp-day h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
cell: "rdp-day h-11 w-11 md:h-9 md:w-9 text-center text-base md:text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: "rdp-day_button",
day_range_end: "rdp-range_end day-range-end",
day_selected: "rdp-selected",

View File

@@ -0,0 +1,82 @@
"use client"
import * as React from "react"
import { Plus, TrendingUp, Wallet, Receipt } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
interface FABAction {
icon: React.ReactNode
label: string
onClick: () => void
variant?: "default" | "outline" | "secondary"
}
interface FloatingActionButtonProps {
actions: FABAction[]
className?: string
}
export function FloatingActionButton({ actions, className }: FloatingActionButtonProps) {
const [isOpen, setIsOpen] = React.useState(false)
const toggleMenu = () => setIsOpen(!isOpen)
const handleActionClick = (action: FABAction) => {
action.onClick()
setIsOpen(false)
}
return (
<>
{/* Backdrop Overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/40 backdrop-blur-xs z-40 animate-in fade-in duration-200"
onClick={() => setIsOpen(false)}
/>
)}
{/* FAB Container */}
<div className={cn("fixed bottom-6 right-6 z-50 flex flex-col items-end gap-3", className)}>
{/* Main FAB Button - Always at bottom */}
<Button
size="lg"
onClick={toggleMenu}
className={cn(
"h-16 w-16 rounded-full shadow-2xl hover:scale-110 transition-all duration-200 order-last",
isOpen && "rotate-45"
)}
>
<Plus className="h-6 w-6" />
</Button>
{/* Action Menu - Above main button */}
{isOpen && (
<div className="flex flex-col gap-3 animate-in fade-in slide-in-from-bottom-2 duration-200">
{actions.map((action, index) => (
<div key={index} className="flex items-center gap-3 justify-end">
{/* Label */}
<span className="bg-background border shadow-lg rounded-lg px-3 py-2 text-sm font-medium whitespace-nowrap">
{action.label}
</span>
{/* Action Button */}
<Button
size="lg"
variant={action.variant || "secondary"}
onClick={() => handleActionClick(action)}
className="h-14 w-14 rounded-full shadow-lg hover:scale-110 transition-transform px-0"
>
{action.icon}
</Button>
</div>
))}
</div>
)}
</div>
</>
)
}
// Export icons for convenience
export { TrendingUp as FABTrendingUpIcon, Wallet as FABWalletIcon, Receipt as FABReceiptIcon }

View File

@@ -63,7 +63,7 @@ function MultiSelect({
onKeyDown={handleKeyDown}
className={cn("overflow-visible bg-transparent", className)}
>
<div className="group rounded-md border border-input px-3 py-2 text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<div className="group rounded-md border border-input px-3 py-2 min-h-[44px] md:min-h-[36px] text-base md:text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<div className="flex flex-wrap gap-1">
{selected.map((item) => {
const option = options.find((opt) => opt.value === item)
@@ -120,7 +120,7 @@ function MultiSelect({
handleSelect(inputValue)
setOpen(false)
}}
className="cursor-pointer"
className="cursor-pointer min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
Create "{inputValue}"
</CommandItem>
@@ -143,7 +143,7 @@ function MultiSelect({
handleSelect(option.value)
setOpen(false)
}}
className="cursor-pointer"
className="cursor-pointer min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
{option.label}
</CommandItem>

View File

@@ -118,6 +118,7 @@ export function MultipleSelector({
handleSetValue(inputValue)
setInputValue("")
}}
className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
<Check
className={cn(
@@ -140,6 +141,7 @@ export function MultipleSelector({
onSelect={() => {
handleSetValue(option.value)
}}
className="min-h-[44px] md:min-h-0 py-2.5 md:py-1.5 text-base md:text-sm"
>
<Check
className={cn(

View File

@@ -117,7 +117,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-2.5 md:py-1.5 pl-2 pr-8 text-base md:text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 min-h-[44px] md:min-h-0",
className
)}
{...props}

View File

@@ -58,7 +58,7 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted h-14 md:h-auto",
className
)}
{...props}
@@ -73,7 +73,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"h-12 md:h-10 px-3 md:px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-base md:text-sm",
className
)}
{...props}
@@ -88,7 +88,7 @@ const TableCell = React.forwardRef<
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"p-3 md:p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] text-base md:text-sm",
className
)}
{...props}

View File

@@ -41,8 +41,9 @@ export const getCurrencyByCode = (code: string) => {
};
export const formatCurrency = (amount: number, currencyCode: string) => {
const useLanguage = localStorage.getItem('language') || 'en';
const currency = getCurrencyByCode(currencyCode);
if (!currency) return `${amount} ${(amount === 1) ? currencyCode : currencyCode + 's'}`;
if (!currency) return `${amount} ${(amount === 1) ? currencyCode : currencyCode + (useLanguage == 'en' ? 's' : '')}`;
// For IDR, format without decimals
if (currencyCode === 'IDR') {

View File

@@ -419,3 +419,28 @@ body {
.rdp-vhidden {
display: none;
}
@media only screen and (max-width: 48rem) {
[data-radix-popper-content-wrapper] {
width: 90%;
}
[data-radix-popper-content-wrapper] .rdp-months,
[data-radix-popper-content-wrapper] .rdp-months .rdp-month,
[data-radix-popper-content-wrapper] table{
width: 100%;
max-width: unset;
}
[data-radix-popper-content-wrapper] .rdp-dropdown_root{
height: 2.75rem;
}
[data-radix-popper-content-wrapper] table tbody tr {
display: flex;
}
[data-radix-popper-content-wrapper] table *:is(th, td){
flex-grow: 1;
}
[data-radix-popper-content-wrapper] table .rdp-day_button {
height: 2.75rem;
width: 2.75rem;
}
}

View File

@@ -2,18 +2,17 @@ export const en = {
common: {
search: 'Search',
filter: 'Filter',
clearAll: 'Clear All',
add: 'Add',
edit: 'Edit',
delete: 'Delete',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
close: 'Close',
loading: 'Loading...',
noData: 'No data',
confirm: 'Confirm',
loading: 'Loading...',
noData: 'No data available',
error: 'An error occurred',
success: 'Success',
error: 'Error',
total: 'Total',
date: 'Date',
amount: 'Amount',
@@ -28,6 +27,12 @@ export const en = {
showFilters: 'Show Filters',
hideFilters: 'Hide Filters',
},
numberFormat: {
thousand: 'k',
million: 'm',
billion: 'b',
},
nav: {
overview: 'Overview',
@@ -44,6 +49,7 @@ export const en = {
overviewPeriodPlaceholder: 'Select period',
customStartDatePlaceholder: 'Pick start date',
customEndDatePlaceholder: 'Pick end date',
selectDateRange: 'Select date range',
totalBalance: 'Total Balance',
totalIncome: 'Total Income',
totalExpense: 'Total Expense',
@@ -200,9 +206,11 @@ export const en = {
expense: 'Expense',
category: 'Category',
categoryPlaceholder: 'Select or type new category',
selectCategory: 'Select or type new category',
addCategory: 'Add',
memo: 'Memo (Optional)',
memoPlaceholder: 'Add a note...',
addMemo: 'Add a note...',
date: 'Date',
selectDate: 'Select date',
addSuccess: 'Transaction added successfully',
@@ -227,16 +235,25 @@ export const en = {
save: 'Save',
update: 'Update',
cancel: 'Cancel',
nameSaved: 'Name saved successfully',
nameError: 'Name cannot be empty',
nameSuccess: 'Name updated successfully',
nameLoading: 'Updating name...',
nameLoadingError: 'Failed to update name',
email: 'Email',
emailVerified: 'Email Verified',
emailNotVerified: 'Email Not Verified',
emailCannotBeChanged: 'Email cannot be changed',
avatar: 'Avatar',
changeAvatar: 'Change Avatar',
uploadAvatar: 'Upload Avatar',
avatarSynced: 'Avatar is synced from your Google account',
clickUploadAvatar: 'Click the upload button to change your avatar',
uploading: 'Uploading...',
avatarSuccess: 'Avatar updated successfully',
avatarError: 'Failed to update avatar',
security: 'Security',
password: 'Password',
@@ -252,6 +269,10 @@ export const en = {
updating: 'Updating...',
setPassword: 'Set Password',
updatePassword: 'Update Password',
passwordSetSuccess: 'Password set successfully',
passwordChangeSuccess: 'Password changed successfully',
passwordError: 'Failed to set password',
enterPassword: 'Please enter your password',
twoFactor: 'Two-Factor Authentication',
twoFactorDesc: 'Add an extra layer of security to your account',
@@ -259,12 +280,20 @@ export const en = {
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Phone',
phoneNumberDescription: 'Required for WhatsApp OTP verification',
phoneInvalid: 'Invalid phone number',
phoneNotRegistered: 'This number is not registered on WhatsApp. Please try another number.',
phoneSuccess: 'Phone number updated successfully',
phoneError: 'Failed to update phone number',
emailOtp: 'Email OTP',
emailOtpDesc: 'Receive verification codes via email',
enableEmailOtp: 'Enable Email OTP',
disableEmailOtp: 'Disable Email OTP',
checkYourEmailForTheVerificationCode: 'Check your email for the verification code',
emailOtpSent: 'OTP code has been sent to your email',
emailOtpEnabled: 'Email OTP enabled successfully',
emailOtpDisabled: 'Email OTP disabled successfully',
emailOtpError: 'Failed to send OTP code',
enable: 'Enable',
disable: 'Disable',
enabled: 'Enabled',
@@ -280,6 +309,10 @@ export const en = {
pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Please add your phone number in the Edit Profile tab first',
checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Check your WhatsApp for the verification code',
enterVerificationCode: 'Enter 6 digit code',
whatsappOtpSent: 'OTP code has been sent to WhatsApp',
whatsappOtpEnabled: 'WhatsApp OTP enabled successfully',
whatsappOtpDisabled: 'WhatsApp OTP disabled successfully',
whatsappOtpError: 'Failed to send OTP code',
authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Use an authenticator app like Google Authenticator',
@@ -290,6 +323,9 @@ export const en = {
setupSecretKey: 'Secret Key (if you can\'t scan QR code):',
enableAuthenticatorApp: 'Enable Authenticator App',
disableAuthenticatorApp: 'Disable Authenticator App',
totpEnabled: 'Authenticator App enabled successfully',
totpDisabled: 'Authenticator App disabled successfully',
totpError: 'Failed to enable Authenticator App',
scanQr: 'Scan QR Code',
scanQrDesc: 'Scan this QR code with your authenticator app',
manualEntry: 'Or enter this code manually:',
@@ -302,8 +338,11 @@ export const en = {
deletePasswordRequired: 'You must set a password first before you can delete your account. Go to "Set Password" above.',
deleteAccountConfirm: 'Are you sure you want to delete your account? All data will be permanently lost.',
enterPasswordToDelete: 'Enter your password to confirm',
enterPasswordToDeletePlaceholder: 'Enter your password',
deleting: 'Deleting...',
yesDeleteMyAccount: 'Yes, Delete My Account',
deleteSuccess: 'Account deleted successfully',
deleteError: 'Failed to delete account',
},
dateRange: {
@@ -313,7 +352,30 @@ export const en = {
lastMonth: 'Last month',
thisYear: 'This year',
custom: 'Custom',
from: 'From',
to: 'To',
from: 'Start Date',
to: 'End Date',
},
fab: {
updateAssetPrices: 'Update Asset Prices',
quickTransaction: 'Quick Transaction',
quickWallet: 'Quick Wallet',
},
assetPriceUpdate: {
title: 'Update Asset Prices',
description: 'Update the price per unit for your asset wallets',
noAssets: 'You don\'t have any asset wallets yet. Create an asset wallet first.',
noChanges: 'No price changes detected',
pricePerUnit: 'Price per {unit}',
currentPrice: 'Current price',
lastUpdated: 'Last updated',
justNow: 'Just now',
minutesAgo: '{minutes} minutes ago',
hoursAgo: '{hours} hours ago',
daysAgo: '{days} days ago',
updateAll: 'Update All',
updateSuccess: '{count} asset price(s) updated successfully',
updateError: 'Failed to update asset prices',
},
}

View File

@@ -28,6 +28,12 @@ export const id = {
showFilters: 'Tampilkan Filter',
hideFilters: 'Sembunyikan Filter',
},
numberFormat: {
thousand: 'rb',
million: 'jt',
billion: 'm',
},
nav: {
overview: 'Ringkasan',
@@ -42,8 +48,9 @@ export const id = {
description: 'Ringkasan keuangan dan tindakan cepat',
overviewPeriod: 'Periode Ringkasan',
overviewPeriodPlaceholder: 'Pilih Periode',
customStartDatePlaceholder: 'Pilih Tanggal Mulai',
customEndDatePlaceholder: 'Pilih Tanggal Selesai',
customStartDatePlaceholder: 'Pilih tanggal mulai',
customEndDatePlaceholder: 'Pilih tanggal akhir',
selectDateRange: 'Pilih rentang tanggal',
totalBalance: 'Total Saldo',
totalIncome: 'Total Pemasukan',
totalExpense: 'Total Pengeluaran',
@@ -200,9 +207,11 @@ export const id = {
expense: 'Pengeluaran',
category: 'Kategori',
categoryPlaceholder: 'Pilih atau ketik kategori baru',
selectCategory: 'Pilih atau ketik kategori baru',
addCategory: 'Tambah',
memo: 'Catatan (Opsional)',
memoPlaceholder: 'Tambahkan catatan...',
addMemo: 'Tambahkan catatan...',
date: 'Tanggal',
selectDate: 'Pilih tanggal',
addSuccess: 'Transaksi berhasil ditambahkan',
@@ -227,16 +236,25 @@ export const id = {
save: 'Simpan',
update: 'Update',
cancel: 'Batal',
nameSaved: 'Nama berhasil disimpan',
nameError: 'Nama tidak boleh kosong',
nameSuccess: 'Nama berhasil diupdate',
nameLoading: 'Mengupdate nama...',
nameLoadingError: 'Gagal mengupdate nama',
email: 'Email',
emailVerified: 'Email Terverifikasi',
emailNotVerified: 'Email Belum Terverifikasi',
emailCannotBeChanged: 'Email tidak dapat diubah',
avatar: 'Avatar',
changeAvatar: 'Ubah Avatar',
uploadAvatar: 'Unggah Avatar',
avatarSynced: 'Avatar disinkronkan dari akun Google Anda',
clickUploadAvatar: 'Klik tombol unggah untuk mengubah avatar Anda',
uploading: 'Mengunggah...',
avatarSuccess: 'Avatar berhasil diupdate',
avatarError: 'Gagal mengupdate avatar',
security: 'Keamanan',
password: 'Password',
@@ -252,6 +270,10 @@ export const id = {
updating: 'Updating...',
setPassword: 'Buat Password',
updatePassword: 'Ubah Password',
passwordSetSuccess: 'Password berhasil diatur',
passwordChangeSuccess: 'Password berhasil diubah',
passwordError: 'Gagal mengatur password',
enterPassword: 'Silakan masukkan password',
twoFactor: 'Autentikasi Dua Faktor',
twoFactorDesc: 'Tambahkan lapisan keamanan ekstra ke akun Anda',
@@ -259,12 +281,20 @@ export const id = {
phoneNumberPlaceholder: '+62812345678',
updatePhone: 'Update Nomor',
phoneNumberDescription: 'Diperlukan untuk verifikasi WhatsApp OTP',
phoneInvalid: 'Nomor telepon tidak valid',
phoneNotRegistered: 'Nomor ini tidak terdaftar di WhatsApp. Silakan coba nomor lain.',
phoneSuccess: 'Nomor telepon berhasil diupdate',
phoneError: 'Gagal mengupdate nomor telepon',
emailOtp: 'Email OTP',
emailOtpDesc: 'Terima kode verifikasi via email',
enableEmailOtp: 'Aktifkan Email OTP',
disableEmailOtp: 'Nonaktifkan Email OTP',
checkYourEmailForTheVerificationCode: 'Cek email Anda untuk kode verifikasi',
emailOtpSent: 'Kode OTP telah dikirim ke email',
emailOtpEnabled: 'Email OTP berhasil diaktifkan',
emailOtpDisabled: 'Email OTP berhasil dinonaktifkan',
emailOtpError: 'Gagal mengirim kode OTP',
enable: 'Aktifkan',
disable: 'Nonaktifkan',
enabled: 'Aktif',
@@ -280,6 +310,10 @@ export const id = {
pleaseAddYourPhoneNumberInTheEditProfileTabFirst: 'Tambahkan nomor telepon Anda di tab Edit Profil terlebih dahulu',
checkYourWhatsAppForTheVerificationCodeOrCheckConsoleInTestMode: 'Cek WhatsApp Anda untuk kode verifikasi',
enterVerificationCode: 'Masukkan 6 digit kode',
whatsappOtpSent: 'Kode OTP telah dikirim ke WhatsApp',
whatsappOtpEnabled: 'WhatsApp OTP berhasil diaktifkan',
whatsappOtpDisabled: 'WhatsApp OTP berhasil dinonaktifkan',
whatsappOtpError: 'Gagal mengirim kode OTP',
authenticatorApp: 'Authenticator App',
authenticatorDesc: 'Gunakan aplikasi authenticator seperti Google Authenticator',
@@ -290,6 +324,9 @@ export const id = {
setupSecretKey: 'Secret Key (jika tidak bisa scan QR code):',
enableAuthenticatorApp: 'Aktifkan Authenticator App',
disableAuthenticatorApp: 'Nonaktifkan Authenticator App',
totpEnabled: 'Authenticator App berhasil diaktifkan',
totpDisabled: 'Authenticator App berhasil dinonaktifkan',
totpError: 'Gagal mengaktifkan Authenticator App',
scanQr: 'Scan QR Code',
scanQrDesc: 'Scan QR code ini dengan aplikasi authenticator Anda',
manualEntry: 'Atau masukkan kode ini secara manual:',
@@ -302,8 +339,11 @@ export const id = {
deletePasswordRequired: 'Anda harus membuat password terlebih dahulu sebelum Anda dapat menghapus akun Anda. Buka "Buat Password" di atas.',
deleteAccountConfirm: 'Apakah Anda yakin ingin menghapus akun Anda? Semua data akan hilang permanen.',
enterPasswordToDelete: 'Masukkan password Anda untuk konfirmasi',
enterPasswordToDeletePlaceholder: 'Masukkan password Anda',
deleting: 'Menghapus...',
yesDeleteMyAccount: 'Ya, Hapus Akun Saya',
deleteSuccess: 'Akun berhasil dihapus',
deleteError: 'Gagal menghapus akun',
},
dateRange: {
@@ -313,7 +353,30 @@ export const id = {
lastMonth: 'Bulan lalu',
thisYear: 'Tahun ini',
custom: 'Kustom',
from: 'Dari',
to: 'Sampai',
from: 'Tanggal Mulai',
to: 'Tanggal Akhir',
},
fab: {
updateAssetPrices: 'Perbarui Harga Aset',
quickTransaction: 'Transaksi Cepat',
quickWallet: 'Dompet Cepat',
},
assetPriceUpdate: {
title: 'Perbarui Harga Aset',
description: 'Perbarui harga per unit untuk dompet aset Anda',
noAssets: 'Anda belum memiliki dompet aset. Buat dompet aset terlebih dahulu.',
noChanges: 'Tidak ada perubahan harga yang terdeteksi',
pricePerUnit: 'Harga per {unit}',
currentPrice: 'Harga saat ini',
lastUpdated: 'Terakhir diperbarui',
justNow: 'Baru saja',
minutesAgo: '{minutes} menit yang lalu',
hoursAgo: '{hours} jam yang lalu',
daysAgo: '{days} hari yang lalu',
updateAll: 'Perbarui Semua',
updateSuccess: '{count} harga aset berhasil diperbarui',
updateError: 'Gagal memperbarui harga aset',
},
}