- 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
1098 lines
43 KiB
TypeScript
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>
|
|
)
|
|
}
|