Files
tabungin/apps/web/src/components/pages/Overview.tsx
dwindown 249f3a9d7d feat: remove OTP gate from transactions, fix categories auth, add implementation plan
- Remove OtpGateGuard from transactions controller (OTP verified at login)
- Fix categories controller to use authenticated user instead of TEMP_USER_ID
- Add comprehensive implementation plan document
- Update .env.example with WEB_APP_URL
- Prepare for admin dashboard development
2025-10-11 14:00:11 +07:00

1098 lines
43 KiB
TypeScript

import { useState, useEffect, useMemo } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Plus, Wallet, Receipt, 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"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { DatePicker } from "@/components/ui/date-picker"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Legend } from "recharts"
import axios from "axios"
import { formatCurrency } from "@/constants/currencies"
import { formatLargeNumber } from "@/utils/numberFormat"
import { fetchExchangeRates, convertToIDR } from "@/utils/exchangeRate"
import { WalletDialog } from "@/components/dialogs/WalletDialog"
import { TransactionDialog } from "@/components/dialogs/TransactionDialog"
interface Wallet {
id: string
name: string
kind: "money" | "asset"
currency?: string | null
unit?: string | null
initialAmount?: number | null
pricePerUnit?: number | null
deletedAt?: string | null
}
interface Transaction {
id: string
walletId: string
date: string
amount: number
direction: "in" | "out"
category?: string | null
memo?: string | null
}
const API = "/api"
type DateRange = 'this_month' | 'last_month' | 'this_year' | 'last_year' | 'all_time' | 'custom'
type TrendPeriod = 'daily' | 'weekly' | 'monthly' | 'yearly'
// Helper function to filter transactions by date range
function getFilteredTransactions(transactions: Transaction[], dateRange: DateRange, customStartDate?: Date, customEndDate?: Date): Transaction[] {
if (dateRange === 'all_time') return transactions
if (dateRange === 'custom' && customStartDate && customEndDate) {
return transactions.filter(tx => {
const txDate = new Date(tx.date)
return txDate >= customStartDate && txDate <= customEndDate
})
}
const now = new Date()
let startDate: Date
let endDate: Date = new Date(now.getFullYear(), now.getMonth() + 1, 0) // End of current month
switch (dateRange) {
case 'this_month':
startDate = new Date(now.getFullYear(), now.getMonth(), 1)
endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0)
break
case 'last_month':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1)
endDate = new Date(now.getFullYear(), now.getMonth(), 0)
break
case 'this_year':
startDate = new Date(now.getFullYear(), 0, 1)
endDate = new Date(now.getFullYear(), 11, 31)
break
case 'last_year':
startDate = new Date(now.getFullYear() - 1, 0, 1)
endDate = new Date(now.getFullYear() - 1, 11, 31)
break
default:
return transactions
}
return transactions.filter(tx => {
const txDate = new Date(tx.date)
return txDate >= startDate && txDate <= endDate
})
}
// Helper function to get date range label
function getDateRangeLabel(dateRange: DateRange, customStartDate?: Date, customEndDate?: Date): string {
switch (dateRange) {
case 'this_month': return 'This Month'
case 'last_month': return 'Last Month'
case 'this_year': return 'This Year'
case 'last_year': return 'Last Year'
case 'all_time': return 'All Time'
case 'custom':
if (customStartDate && customEndDate) {
return `${customStartDate.toLocaleDateString()} - ${customEndDate.toLocaleDateString()}`
}
return 'Custom Range'
default: return 'All Time'
}
}
// Helper function to format Y-axis values with k/m suffix
function formatYAxisValue(value: number): string {
const absValue = Math.abs(value)
const sign = value < 0 ? '-' : ''
if (absValue >= 1000000) {
return `${sign}${(absValue / 1000000).toFixed(1)}m`
} else if (absValue >= 1000) {
return `${sign}${(absValue / 1000).toFixed(1)}k`
}
return value.toLocaleString()
}
export function Overview() {
const [wallets, setWallets] = useState<Wallet[]>([])
const [transactions, setTransactions] = useState<Transaction[]>([])
const [exchangeRates, setExchangeRates] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [walletDialogOpen, setWalletDialogOpen] = useState(false)
const [transactionDialogOpen, setTransactionDialogOpen] = useState(false)
const [dateRange, setDateRange] = useState<DateRange>('this_month')
const [customStartDate, setCustomStartDate] = useState<Date>()
const [customEndDate, setCustomEndDate] = useState<Date>()
const [incomeChartWallet, setIncomeChartWallet] = useState<string>('all')
const [expenseChartWallet, setExpenseChartWallet] = useState<string>('all')
const [trendPeriod, setTrendPeriod] = useState<TrendPeriod>('monthly')
useEffect(() => {
loadData()
loadExchangeRates()
}, [])
const loadData = async () => {
try {
setLoading(true)
const [walletsRes, transactionsRes] = await Promise.all([
axios.get(`${API}/wallets`),
axios.get(`${API}/wallets/transactions`)
])
const activeWallets = walletsRes.data.filter((w: Wallet) => !w.deletedAt)
setWallets(activeWallets)
setTransactions(transactionsRes.data)
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const loadExchangeRates = async () => {
try {
const rates = await fetchExchangeRates()
setExchangeRates(rates)
} catch (error) {
console.error('Failed to load exchange rates:', error)
}
}
// Calculate totals across all wallets in IDR
const totals = useMemo(() => {
let totalBalance = 0
let totalIncome = 0
let totalExpense = 0
// Filter transactions by date range
const filteredTransactions = getFilteredTransactions(transactions, dateRange, customStartDate, customEndDate)
// Calculate income and expense from filtered transactions
filteredTransactions.forEach(tx => {
const wallet = wallets.find(w => w.id === tx.walletId)
if (!wallet) return
let idrAmount = 0
if (wallet.kind === 'money') {
const currency = wallet.currency || 'IDR'
idrAmount = convertToIDR(Number(tx.amount), currency, exchangeRates)
} else {
// For assets, multiply by price per unit and convert to IDR if needed
const pricePerUnit = Number(wallet.pricePerUnit) || 1
const assetCurrency = wallet.currency || 'IDR'
const assetValueInCurrency = Number(tx.amount) * pricePerUnit
idrAmount = convertToIDR(assetValueInCurrency, assetCurrency, exchangeRates)
}
if (tx.direction === 'in') {
totalIncome += idrAmount
} else {
totalExpense += idrAmount
}
})
// Calculate balance for each wallet (filtered transactions + initial amount)
wallets.forEach(wallet => {
const walletTransactions = filteredTransactions.filter(tx => tx.walletId === wallet.id)
const transactionBalance = walletTransactions.reduce((sum, tx) => {
return sum + (tx.direction === 'in' ? Number(tx.amount) : -Number(tx.amount))
}, 0)
// Add initial amount if exists
const initialAmount = Number(wallet.initialAmount) || 0
const walletBalance = transactionBalance + initialAmount
// Convert to IDR based on wallet type
let idrBalance = 0
if (wallet.kind === 'money') {
const currency = wallet.currency || 'IDR'
idrBalance = convertToIDR(walletBalance, currency, exchangeRates)
} else {
// For assets, multiply by price per unit and convert to IDR if needed
const pricePerUnit = Number(wallet.pricePerUnit) || 1
const assetCurrency = wallet.currency || 'IDR'
const assetValueInCurrency = walletBalance * pricePerUnit
idrBalance = convertToIDR(assetValueInCurrency, assetCurrency, exchangeRates)
}
totalBalance += idrBalance
})
return { totalBalance, totalIncome, totalExpense }
}, [wallets, transactions, exchangeRates, dateRange, customStartDate, customEndDate])
// Wallet breakdown data
const walletBreakdown = useMemo(() => {
const filteredTransactions = getFilteredTransactions(transactions, dateRange, customStartDate, customEndDate)
return wallets.map(wallet => {
const walletTransactions = filteredTransactions.filter(tx => tx.walletId === wallet.id)
const transactionBalance = walletTransactions.reduce((sum, tx) => {
const amount = Number(tx.amount);
const direction = tx.direction;
return sum + (direction === 'in' ? amount : -amount);
}, 0)
// Add initial amount
const initialAmount = Number(wallet.initialAmount) || 0
const balance = transactionBalance + initialAmount
// Convert balance to IDR for display
let idrBalance = 0
if (wallet.kind === 'money') {
const currency = wallet.currency || 'IDR'
idrBalance = convertToIDR(balance, currency, exchangeRates)
} else {
// For assets, multiply by price per unit and convert to IDR if needed
const pricePerUnit = Number(wallet.pricePerUnit) || 1
const assetCurrency = wallet.currency || 'IDR'
const assetValueInCurrency = balance * pricePerUnit
idrBalance = convertToIDR(assetValueInCurrency, assetCurrency, exchangeRates)
}
return {
id: wallet.id,
name: wallet.name,
balance: balance, // Show original currency balance
idrBalance: idrBalance, // IDR equivalent for reference
currency: wallet.currency || wallet.unit || 'IDR', // Show original currency
transactionCount: walletTransactions.length,
kind: wallet.kind
}
})
}, [wallets, transactions, exchangeRates, dateRange, customStartDate, customEndDate])
// Flexible trend data based on selected period and date range
const trendData = useMemo(() => {
const filteredTransactions = getFilteredTransactions(transactions, dateRange, customStartDate, customEndDate)
// Generate periods based on selected trend period and date range
const generatePeriods = () => {
const periods = []
const now = new Date()
// Determine smart defaults based on date range
let periodsCount = 6
let currentPeriod = trendPeriod
if (dateRange === 'this_month' || dateRange === 'last_month') {
currentPeriod = trendPeriod === 'monthly' ? 'daily' : trendPeriod
periodsCount = trendPeriod === 'daily' ? 30 : 4
} else if (dateRange === 'this_year' || dateRange === 'last_year') {
periodsCount = currentPeriod === 'yearly' ? 1 : currentPeriod === 'monthly' ? 12 : 52
}
switch (currentPeriod) {
case 'daily':
for (let i = periodsCount - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - i)
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' })
})
}
break
case 'weekly':
for (let i = periodsCount - 1; i >= 0; i--) {
const date = new Date(now)
date.setDate(date.getDate() - (i * 7))
const weekStart = new Date(date)
weekStart.setDate(date.getDate() - date.getDay())
const weekEnd = new Date(weekStart)
weekEnd.setDate(weekStart.getDate() + 6)
weekEnd.setHours(23, 59, 59)
periods.push({
start: weekStart,
end: weekEnd,
label: `${weekStart.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
})
}
break
case 'monthly':
for (let i = periodsCount - 1; i >= 0; i--) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1)
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1)
const monthEnd = new Date(date.getFullYear(), date.getMonth() + 1, 0, 23, 59, 59)
periods.push({
start: monthStart,
end: monthEnd,
label: date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' })
})
}
break
case 'yearly':
for (let i = periodsCount - 1; i >= 0; i--) {
const year = now.getFullYear() - i
const yearStart = new Date(year, 0, 1)
const yearEnd = new Date(year, 11, 31, 23, 59, 59)
periods.push({
start: yearStart,
end: yearEnd,
label: year.toString()
})
}
break
}
return periods
}
const periods = generatePeriods()
return periods.map(period => {
const periodTransactions = filteredTransactions.filter(tx => {
const txDate = new Date(tx.date)
return txDate >= period.start && txDate <= period.end
})
let income = 0
let expense = 0
periodTransactions.forEach(tx => {
const wallet = wallets.find(w => w.id === tx.walletId)
if (!wallet) return
let idrAmount = 0
if (wallet.kind === 'money') {
const currency = wallet.currency || 'IDR'
idrAmount = convertToIDR(Number(tx.amount), currency, exchangeRates)
} else {
const pricePerUnit = Number(wallet.pricePerUnit) || 1
const assetCurrency = wallet.currency || 'IDR'
const assetValueInCurrency = Number(tx.amount) * pricePerUnit
idrAmount = convertToIDR(assetValueInCurrency, assetCurrency, exchangeRates)
}
if (tx.direction === 'in') {
income += idrAmount
} else {
expense += idrAmount
}
})
return {
period: period.label,
income,
expense,
net: income - expense
}
})
}, [transactions, wallets, exchangeRates, trendPeriod, dateRange, customStartDate, customEndDate])
// Pie chart data for income by category
const incomeChartData = useMemo(() => {
const filteredTransactions = getFilteredTransactions(transactions, dateRange, customStartDate, customEndDate)
const incomeTransactions = filteredTransactions.filter(tx => {
if (tx.direction !== 'in') return false
if (incomeChartWallet === 'all') return true
return tx.walletId === incomeChartWallet
})
const categoryTotals: Record<string, number> = {}
incomeTransactions.forEach(tx => {
const wallet = wallets.find(w => w.id === tx.walletId)
if (!wallet) return
const category = tx.category || 'Uncategorized'
let idrAmount = 0
if (wallet.kind === 'money') {
const currency = wallet.currency || 'IDR'
idrAmount = convertToIDR(Number(tx.amount), currency, exchangeRates)
} else {
const pricePerUnit = Number(wallet.pricePerUnit) || 1
const assetCurrency = wallet.currency || 'IDR'
const assetValueInCurrency = Number(tx.amount) * pricePerUnit
idrAmount = convertToIDR(assetValueInCurrency, assetCurrency, exchangeRates)
}
categoryTotals[category] = (categoryTotals[category] || 0) + idrAmount
})
return Object.entries(categoryTotals).map(([category, amount]) => ({
name: category,
value: amount,
percentage: 0 // Will be calculated in the component
}))
}, [transactions, dateRange, incomeChartWallet, wallets, exchangeRates, customStartDate, customEndDate])
// Pie chart data for expense by category
const expenseChartData = useMemo(() => {
const filteredTransactions = getFilteredTransactions(transactions, dateRange, customStartDate, customEndDate)
const expenseTransactions = filteredTransactions.filter(tx => {
if (tx.direction !== 'out') return false
if (expenseChartWallet === 'all') return true
return tx.walletId === expenseChartWallet
})
const categoryTotals: Record<string, number> = {}
expenseTransactions.forEach(tx => {
const wallet = wallets.find(w => w.id === tx.walletId)
if (!wallet) return
const category = tx.category || 'Uncategorized'
let idrAmount = 0
if (wallet.kind === 'money') {
const currency = wallet.currency || 'IDR'
idrAmount = convertToIDR(Number(tx.amount), currency, exchangeRates)
} else {
const pricePerUnit = Number(wallet.pricePerUnit) || 1
const assetCurrency = wallet.currency || 'IDR'
const assetValueInCurrency = Number(tx.amount) * pricePerUnit
idrAmount = convertToIDR(assetValueInCurrency, assetCurrency, exchangeRates)
}
categoryTotals[category] = (categoryTotals[category] || 0) + idrAmount
})
return Object.entries(categoryTotals).map(([category, amount]) => ({
name: category,
value: amount,
percentage: 0 // Will be calculated in the component
}))
}, [transactions, dateRange, expenseChartWallet, wallets, exchangeRates, customStartDate, customEndDate])
// Colors for pie charts
// const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884D8', '#82CA9D', '#FFC658', '#FF7C7C']
const COLORS = [
"var(--chart-1)", // Teal
"var(--chart-2)", // Gold
"var(--chart-3)", // Dark Gray
"var(--chart-4)", // Info Blue
"var(--chart-5)", // Danger Red
];
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) => (
<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" />
<div className="h-4 w-4 bg-gray-200 rounded animate-pulse" />
</CardHeader>
<CardContent>
<div className="h-8 w-32 bg-gray-200 rounded animate-pulse" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="space-y-4">
<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">Overview</h1>
<p className="text-muted-foreground">
Your financial dashboard and quick actions
</p>
</div>
</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="flex flex-col sm:flex-row gap-3">
<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">Overview Period</span>
</label>
<Select value={dateRange} onValueChange={(value: DateRange) => setDateRange(value)}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Select period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="this_month">This Month</SelectItem>
<SelectItem value="last_month">Last Month</SelectItem>
<SelectItem value="this_year">This Year</SelectItem>
<SelectItem value="last_year">Last Year</SelectItem>
<SelectItem value="all_time">All Time</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Fields */}
{dateRange === 'custom' && (
<div className="flex gap-3">
<DatePicker
date={customStartDate}
onDateChange={setCustomStartDate}
placeholder="Pick start date"
className="w-50 sm:w-[200px]"
/>
<DatePicker
date={customEndDate}
onDateChange={setCustomEndDate}
placeholder="Pick end date"
className="w-50 sm:w-[200px]"
/>
</div>
)}
</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" />
Add Wallet
</Button>
<Button variant="outline" onClick={() => setTransactionDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Transaction
</Button>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Balance</CardTitle>
<Wallet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{formatLargeNumber(totals.totalBalance, 'IDR')}
</div>
<p className="text-xs text-muted-foreground">
Across {wallets.length} wallets
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Income</CardTitle>
<TrendingUp className="h-4 w-4 text-[var(--color-primary)]" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-[var(--color-primary)]">
{formatLargeNumber(totals.totalIncome, 'IDR')}
</div>
<p className="text-xs text-muted-foreground">
{getDateRangeLabel(dateRange, customStartDate, customEndDate)} income
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Expense</CardTitle>
<TrendingDown className="h-4 w-4 text-[var(--color-destructive)]" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-[var(--color-destructive)]">
{formatLargeNumber(totals.totalExpense, 'IDR')}
</div>
<p className="text-xs text-muted-foreground">
{getDateRangeLabel(dateRange, customStartDate, customEndDate)} expense
</p>
</CardContent>
</Card>
</div>
{/* Second Row: Wallet Breakdown (Full Width) */}
<div className="gap-4">
{/* Wallet Breakdown */}
<Card>
<CardHeader>
<CardTitle>Wallet Breakdown</CardTitle>
<CardDescription>Balance distribution across wallets</CardDescription>
</CardHeader>
<CardContent className="px-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="text-center">Currency/Unit</TableHead>
<TableHead className="text-center">Transactions</TableHead>
<TableHead className="text-right">Total Balance</TableHead>
<TableHead className="text-right">Domination</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{walletBreakdown.map((wallet) => {
const dominationPercentage = totals.totalBalance > 0
? (wallet.idrBalance / totals.totalBalance * 100).toFixed(1)
: '0.0'
return (
<TableRow key={wallet.name}>
<TableCell>
<div className="flex items-center gap-2">
<button
className="font-medium text-nowrap hover:text-blue-600 hover:underline text-left cursor-pointer"
onClick={() => {
// Navigate to transactions page with wallet filter
window.location.href = `/transactions?wallet=${encodeURIComponent(wallet.name)}`
}}
>
{wallet.name}
</button>
</div>
</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className={`text-xs ${wallet.kind === 'money' ? 'bg-[var(--chart-1)]/10 text-[var(--chart-1)] ring-1 ring-[var(--chart-1)]' : 'bg-[var(--chart-2)]/10 text-[var(--chart-2)] ring-1 ring-[var(--chart-2)]'}`}
>
{wallet.currency}
</Badge>
</TableCell>
<TableCell className="text-center">
<button
className="hover:text-blue-600 hover:underline cursor-pointer"
onClick={() => {
// Navigate to transactions page with wallet filter
window.location.href = `/transactions?wallet=${encodeURIComponent(wallet.name)}`
}}
>
{wallet.transactionCount}
</button>
</TableCell>
<TableCell className="text-right text-nowrap">
<div className="font-medium">
{formatCurrency(wallet.balance, wallet.currency)}
</div>
{wallet.currency !== 'IDR' && (
<div className="text-xs text-muted-foreground">
{formatCurrency(wallet.idrBalance, 'IDR')}
</div>
)}
</TableCell>
<TableCell className="text-right font-medium">
{dominationPercentage}%
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
{/* Third Row: Pie Charts (1/2 each) */}
<div className="gap-4 md:grid md:grid-cols-2">
{/* Income by Category */}
<Card className="mb-5 md:mb-0">
<CardHeader>
<CardTitle>Income by Category</CardTitle>
<CardDescription>
<div className="flex flex-col gap-2">
<span>Category breakdown for {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={incomeChartWallet} onValueChange={setIncomeChartWallet}>
<SelectTrigger className="w-full max-w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Wallets</SelectItem>
{wallets.map(wallet => (
<SelectItem key={wallet.id} value={wallet.id}>
{wallet.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardDescription>
</CardHeader>
<CardContent className="relative p-2 sm:p-6">
{incomeChartData.length > 0 ? (
<div className="w-full">
{/* Mobile Layout */}
<div className="block sm:hidden">
<ChartContainer
config={{}}
className="h-[280px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart margin={{ top: 10, right: 10, bottom: 40, left: 10 }}>
<Pie
data={incomeChartData}
cx="50%"
cy="45%"
outerRadius={80}
dataKey="value"
>
{incomeChartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<div className="bg-background p-2 border rounded shadow">
<p className="font-medium text-background dark:text-foreground">{data.name}</p>
<p className="text-[var(--primary)]">{formatCurrency(data.value, 'IDR')}</p>
</div>
)
}
return null
}}
/>
<Legend
verticalAlign="bottom"
align="center"
wrapperStyle={{
paddingTop: '5px',
fontSize: '11px'
}}
iconType="circle"
formatter={(value) => <span className="text-xs">{value}</span>}
/>
</RechartsPieChart>
</ResponsiveContainer>
</ChartContainer>
</div>
{/* Desktop Layout */}
<div className="hidden sm:block">
<ChartContainer
config={{}}
className="h-[350px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart margin={{ top: 20, right: 100, bottom: 20, left: 20 }}>
<Pie
data={incomeChartData}
cx="40%"
cy="50%"
outerRadius={100}
dataKey="value"
>
{incomeChartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<div className="bg-background p-2 border rounded shadow">
<p className="font-medium text-background dark:text-foreground">{data.name}</p>
<p className="text-[var(--primary)]">{formatCurrency(data.value, 'IDR')}</p>
</div>
)
}
return null
}}
/>
<Legend
verticalAlign="middle"
align="right"
layout="vertical"
wrapperStyle={{
paddingLeft: '20px',
fontSize: '12px'
}}
iconType="circle"
formatter={(value) => <span className="text-sm">{value}</span>}
/>
</RechartsPieChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</div>
) : (
<div className="h-[280px] sm:h-[350px] flex items-center justify-center text-muted-foreground">
No income data for selected period
</div>
)}
</CardContent>
</Card>
{/* Expense by Category */}
<Card>
<CardHeader>
<CardTitle>Expense by Category</CardTitle>
<CardDescription>
<div className="flex flex-col gap-2">
<span>Category breakdown for {getDateRangeLabel(dateRange, customStartDate, customEndDate).toLowerCase()}</span>
<Select value={expenseChartWallet} onValueChange={setExpenseChartWallet}>
<SelectTrigger className="w-full max-w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Wallets</SelectItem>
{wallets.map(wallet => (
<SelectItem key={wallet.id} value={wallet.id}>
{wallet.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardDescription>
</CardHeader>
<CardContent className="p-2 sm:p-6">
{expenseChartData.length > 0 ? (
<div className="w-full">
{/* Mobile Layout */}
<div className="block sm:hidden">
<ChartContainer
config={{}}
className="h-[280px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart margin={{ top: 10, right: 10, bottom: 40, left: 10 }}>
<Pie
data={expenseChartData}
cx="50%"
cy="45%"
outerRadius={80}
dataKey="value"
>
{expenseChartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<div className="bg-background p-2 border rounded shadow">
<p className="font-medium text-background dark:text-foreground">{data.name}</p>
<p className="text-[var(--destructive)]">{formatCurrency(data.value, 'IDR')}</p>
</div>
)
}
return null
}}
/>
<Legend
verticalAlign="bottom"
align="center"
wrapperStyle={{
paddingTop: '5px',
fontSize: '11px'
}}
iconType="circle"
formatter={(value) => <span className="text-xs">{value}</span>}
/>
</RechartsPieChart>
</ResponsiveContainer>
</ChartContainer>
</div>
{/* Desktop Layout */}
<div className="hidden sm:block">
<ChartContainer
config={{}}
className="h-[350px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<RechartsPieChart margin={{ top: 20, right: 100, bottom: 20, left: 20 }}>
<Pie
data={expenseChartData}
cx="40%"
cy="50%"
outerRadius={100}
dataKey="value"
>
{expenseChartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<ChartTooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<div className="bg-background p-2 border rounded shadow">
<p className="font-medium text-background dark:text-foreground">{data.name}</p>
<p className="text-[var(--destructive)]">{formatCurrency(data.value, 'IDR')}</p>
</div>
)
}
return null
}}
/>
<Legend
verticalAlign="middle"
align="right"
layout="vertical"
wrapperStyle={{
paddingLeft: '20px',
fontSize: '12px'
}}
iconType="circle"
formatter={(value) => <span className="text-sm">{value}</span>}
/>
</RechartsPieChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</div>
) : (
<div className="h-[280px] sm:h-[350px] flex items-center justify-center text-muted-foreground">
No expense data for selected period
</div>
)}
</CardContent>
</Card>
</div>
{/* Fourth Row: Trend Chart (Full Width) */}
<div className="gap-4">
<Card>
<CardHeader>
<CardTitle>Financial Trend</CardTitle>
<CardDescription>
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span>Income vs Expense over time</span>
<Select value={trendPeriod} onValueChange={(value: TrendPeriod) => setTrendPeriod(value)}>
<SelectTrigger className="w-full max-w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
<SelectItem value="yearly">Yearly</SelectItem>
</SelectContent>
</Select>
</div>
</CardDescription>
</CardHeader>
<CardContent className="p-2 sm:p-6">
<div className="w-full">
<ChartContainer
config={{
income: { label: "Income", color: "#16a34a" },
expense: { label: "Expense", color: "#dc2626" },
net: { label: "Net", color: "#2563eb" }
}}
className="h-[300px] sm:h-[400px] w-full"
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={trendData}
margin={{
top: 20,
right: 5,
left: 5,
bottom: 20
}}
>
<XAxis
dataKey="period"
fontSize={12}
tickMargin={8}
/>
<YAxis
tickFormatter={formatYAxisValue}
fontSize={12}
width={60}
/>
<ChartTooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-background p-3 border rounded shadow">
<p className="font-medium mb-2 text-background dark:text-foreground">{label}</p>
{payload.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: entry.color }}
/>
<span className="text-sm">
{entry.name}: {formatCurrency(entry.value as number, 'IDR')}
</span>
</div>
))}
</div>
)
}
return null
}}
/>
<Bar
dataKey="income"
fill="var(--chart-1)"
name="Income"
radius={[2, 2, 0, 0]}
/>
<Bar
dataKey="expense"
fill="var(--chart-5)"
name="Expense"
radius={[2, 2, 0, 0]}
/>
<Line
type="monotone"
dataKey="net"
stroke="var(--chart-2)"
strokeWidth={2}
dot={{ fill: "var(--chart-2)", strokeWidth: 2, r: 4 }}
name="Net Trend"
/>
</ComposedChart>
</ResponsiveContainer>
</ChartContainer>
</div>
</CardContent>
</Card>
</div>
{/* Dialogs */}
<WalletDialog
open={walletDialogOpen}
onOpenChange={setWalletDialogOpen}
onSuccess={loadData}
/>
<TransactionDialog
open={transactionDialogOpen}
onOpenChange={setTransactionDialogOpen}
onSuccess={loadData}
/>
</div>
)
}