checkpoint: goals feature, wallet balance, and goals/wallet detail UI
- Add goals feature (models, migrations, API, web pages) - Add reserved/centralized wallet balance service - Add wallet detail page and overview components - Add new UI components (progress, multi-select, FAB) - Remove stray empty -H/-d files from working tree
0
apps/web/.env.example
Normal file → Executable file
0
apps/web/.env.local.example
Normal file → Executable file
0
apps/web/.gitignore
vendored
Normal file → Executable file
0
apps/web/README.md
Normal file → Executable file
0
apps/web/components.json
Normal file → Executable file
0
apps/web/eslint.config.js
Normal file → Executable file
0
apps/web/index.html
Normal file → Executable file
25
apps/web/package-lock.json
generated
Normal file → Executable file
@@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
@@ -1757,6 +1758,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz",
|
||||
"integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
|
||||
|
||||
1
apps/web/package.json
Normal file → Executable file
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
|
||||
0
apps/web/postcss.config.cjs
Normal file → Executable file
0
apps/web/public/vite.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
0
apps/web/src/App.css
Normal file → Executable file
0
apps/web/src/App.tsx
Normal file → Executable file
0
apps/web/src/assets/images/logo-dark.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
0
apps/web/src/assets/images/logo-icon.png
Normal file → Executable file
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
0
apps/web/src/assets/images/logo-large-dark.png
Normal file → Executable file
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
0
apps/web/src/assets/images/logo-large.png
Normal file → Executable file
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
0
apps/web/src/assets/images/logo.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
0
apps/web/src/assets/images/logo.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
0
apps/web/src/assets/react.svg
Normal file → Executable file
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
72
apps/web/src/components/Breadcrumb.tsx
Normal file → Executable file
@@ -1,10 +1,46 @@
|
||||
import { ChevronRight, Home } from "lucide-react"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { useEffect, useState } from "react"
|
||||
import { goalsApi } from "@/lib/api/goals"
|
||||
import axios from "axios"
|
||||
|
||||
interface BreadcrumbProps {
|
||||
currentPage: string
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function Breadcrumb({ currentPage }: BreadcrumbProps) {
|
||||
const location = useLocation()
|
||||
const [goalName, setGoalName] = useState<string>("")
|
||||
const [walletName, setWalletName] = useState<string>("")
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're on a goal detail page
|
||||
const goalMatch = location.pathname.match(/\/goals\/([^/]+)/)
|
||||
if (goalMatch && goalMatch[1]) {
|
||||
const goalId = goalMatch[1]
|
||||
// Fetch goal name
|
||||
goalsApi.getOne(goalId)
|
||||
.then(goal => setGoalName(goal.name))
|
||||
.catch(() => setGoalName("Goal Details"))
|
||||
} else {
|
||||
setGoalName("")
|
||||
}
|
||||
|
||||
// Check if we're on a wallet detail page
|
||||
const walletMatch = location.pathname.match(/\/wallets\/([^/]+)/)
|
||||
if (walletMatch && walletMatch[1]) {
|
||||
const walletId = walletMatch[1]
|
||||
// Fetch wallet name
|
||||
axios.get(`${API}/wallets/${walletId}`)
|
||||
.then(res => setWalletName(res.data.name))
|
||||
.catch(() => setWalletName("Wallet Details"))
|
||||
} else {
|
||||
setWalletName("")
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
const getPageTitle = (page: string) => {
|
||||
switch (page) {
|
||||
case '/':
|
||||
@@ -15,20 +51,50 @@ export function Breadcrumb({ currentPage }: BreadcrumbProps) {
|
||||
return 'Transactions'
|
||||
case '/profile':
|
||||
return 'Profile'
|
||||
case '/goals':
|
||||
return 'Goals'
|
||||
default:
|
||||
return page.charAt(0).toUpperCase() + page.slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're on a goal detail page
|
||||
const isGoalDetail = location.pathname.startsWith('/goals/') && location.pathname !== '/goals'
|
||||
// Check if we're on a wallet detail page
|
||||
const isWalletDetail = location.pathname.startsWith('/wallets/') && location.pathname !== '/wallets'
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Home className="h-4 w-4" />
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">
|
||||
{getPageTitle(currentPage)}
|
||||
</span>
|
||||
|
||||
{isGoalDetail ? (
|
||||
<>
|
||||
<span className="hover:text-foreground cursor-pointer transition-colors">
|
||||
Goals
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">
|
||||
{goalName || "Loading..."}
|
||||
</span>
|
||||
</>
|
||||
) : isWalletDetail ? (
|
||||
<>
|
||||
<span className="hover:text-foreground cursor-pointer transition-colors">
|
||||
Wallets
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">
|
||||
{walletName || "Loading..."}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">
|
||||
{getPageTitle(currentPage)}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
4
apps/web/src/components/Dashboard.tsx
Normal file → Executable file
@@ -4,8 +4,10 @@ import { useAuth } from "@/contexts/AuthContext"
|
||||
import { DashboardLayout } from "./layout/DashboardLayout"
|
||||
import { Overview } from "./pages/Overview"
|
||||
import { Wallets } from "./pages/Wallets"
|
||||
import { WalletDetail } from "./pages/WalletDetail"
|
||||
import { Transactions } from "./pages/Transactions"
|
||||
import { Profile } from "./pages/Profile"
|
||||
import { Goals } from "./pages/Goals"
|
||||
|
||||
export function Dashboard() {
|
||||
const { user } = useAuth()
|
||||
@@ -41,7 +43,9 @@ export function Dashboard() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Overview />} />
|
||||
<Route path="/wallets" element={<Wallets />} />
|
||||
<Route path="/wallets/:id" element={<WalletDetail />} />
|
||||
<Route path="/transactions" element={<Transactions />} />
|
||||
<Route path="/goals/*" element={<Goals />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
</Routes>
|
||||
</DashboardLayout>
|
||||
|
||||
0
apps/web/src/components/LanguageToggle.tsx
Normal file → Executable file
0
apps/web/src/components/Logo.tsx
Normal file → Executable file
0
apps/web/src/components/ThemeProvider.tsx
Normal file → Executable file
0
apps/web/src/components/ThemeToggle.tsx
Normal file → Executable file
0
apps/web/src/components/admin/AdminBreadcrumb.tsx
Normal file → Executable file
0
apps/web/src/components/admin/AdminLayout.tsx
Normal file → Executable file
0
apps/web/src/components/admin/AdminSidebar.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/AdminDashboard.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/AdminPaymentMethods.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/AdminPayments.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/AdminPlans.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/AdminSettings.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/AdminSettingsNew.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/AdminUsers.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/settings/AdminSettingsGeneral.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/settings/AdminSettingsPaymentMethods.tsx
Normal file → Executable file
0
apps/web/src/components/admin/pages/settings/AdminSettingsSecurity.tsx
Normal file → Executable file
0
apps/web/src/components/dialogs/AssetPriceUpdateDialog.tsx
Normal file → Executable file
0
apps/web/src/components/dialogs/TransactionDialog.tsx
Normal file → Executable file
2
apps/web/src/components/dialogs/WalletDialog.tsx
Normal file → Executable file
@@ -88,7 +88,7 @@ export function WalletDialog({ open, onOpenChange, wallet, onSuccess }: WalletDi
|
||||
kind,
|
||||
...(kind === "money" ? { currency } : { unit: unit.trim() }),
|
||||
...(initialAmountNum && initialAmountNum > 0 ? { initialAmount: initialAmountNum } : {}),
|
||||
...(kind === "asset" && pricePerUnitNum && pricePerUnitNum > 0 ? { pricePerUnit: pricePerUnitNum } : {})
|
||||
...(pricePerUnitNum && pricePerUnitNum > 0 ? { pricePerUnit: pricePerUnitNum } : {})
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
|
||||
7
apps/web/src/components/layout/AppSidebar.tsx
Normal file → Executable file
@@ -1,4 +1,4 @@
|
||||
import { Home, Wallet, Receipt, User, LogOut } from "lucide-react"
|
||||
import { Home, Wallet, Receipt, User, LogOut, Target } from "lucide-react"
|
||||
import { Logo } from "../Logo"
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -42,6 +42,11 @@ export function AppSidebar({ currentPage, onNavigate }: AppSidebarProps) {
|
||||
url: "/transactions",
|
||||
icon: Receipt,
|
||||
},
|
||||
{
|
||||
title: t.nav.goals,
|
||||
url: "/goals",
|
||||
icon: Target,
|
||||
},
|
||||
{
|
||||
title: t.nav.profile,
|
||||
url: "/profile",
|
||||
|
||||
0
apps/web/src/components/layout/AuthLayout.tsx
Normal file → Executable file
2
apps/web/src/components/layout/DashboardLayout.tsx
Normal file → Executable file
@@ -85,7 +85,7 @@ export function DashboardLayout({
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="container mx-auto max-w-7xl p-4">
|
||||
<div className="container mx-auto max-w-7xl pb-24 md:pb-4 p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
0
apps/web/src/components/pages/AuthCallback.tsx
Normal file → Executable file
445
apps/web/src/components/pages/GoalDetail.tsx
Executable file
@@ -0,0 +1,445 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { goalsApi, type Goal } from '@/lib/api/goals';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit,
|
||||
Loader2,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
Wallet as WalletIcon,
|
||||
CheckCircle2,
|
||||
Circle
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { AddMoneyDialog } from './goals/AddMoneyDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
export function GoalDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [goal, setGoal] = useState<Goal | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addMoneyDialogOpen, setAddMoneyDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchGoal();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const fetchGoal = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await goalsApi.getOne(id);
|
||||
setGoal(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch goal:', error);
|
||||
toast.error('Failed to load goal');
|
||||
navigate('/goals');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMoney = () => {
|
||||
setAddMoneyDialogOpen(false);
|
||||
fetchGoal();
|
||||
toast.success('Money added successfully!');
|
||||
};
|
||||
|
||||
const handleDeleteGoal = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
await goalsApi.delete(id);
|
||||
toast.success('Goal deleted successfully');
|
||||
navigate('/goals');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete goal:', error);
|
||||
toast.error('Failed to delete goal');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAllocation = async (allocationId: string) => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
await goalsApi.removeAllocation(id, allocationId);
|
||||
toast.success('Allocation removed');
|
||||
fetchGoal();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove allocation:', error);
|
||||
toast.error('Failed to remove allocation');
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: currency || 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTargetDate = (dateString?: string) => {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = date.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
return {
|
||||
formatted: date.toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }),
|
||||
daysLeft: diffDays,
|
||||
isOverdue: diffDays < 0,
|
||||
};
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!goal) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const progress = goal.targetAmount > 0
|
||||
? (goal.currentAmount / goal.targetAmount) * 100
|
||||
: 0;
|
||||
|
||||
const remaining = goal.targetAmount - goal.currentAmount;
|
||||
const targetDateInfo = formatTargetDate(goal.targetDate);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/goals')}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{goal.name}</h1>
|
||||
{goal.description && (
|
||||
<p className="text-muted-foreground mt-1">{goal.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => toast.info('Edit feature coming soon!')}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Progress</CardTitle>
|
||||
<Badge variant={goal.status === 'completed' ? 'secondary' : 'default'}>
|
||||
{goal.status.charAt(0).toUpperCase() + goal.status.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Progress Circle */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-48 h-48">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="96"
|
||||
cy="96"
|
||||
r="88"
|
||||
stroke="currentColor"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
className="text-muted"
|
||||
/>
|
||||
<circle
|
||||
cx="96"
|
||||
cy="96"
|
||||
r="88"
|
||||
stroke="currentColor"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
strokeDasharray={`${2 * Math.PI * 88}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 88 * (1 - progress / 100)}`}
|
||||
className={progress >= 100 ? 'text-green-500' : progress >= 75 ? 'text-blue-500' : progress >= 50 ? 'text-yellow-500' : 'text-gray-500'}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-4xl font-bold">{Math.round(progress)}%</span>
|
||||
<span className="text-sm text-muted-foreground">Complete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">Current Amount</span>
|
||||
<span className="text-2xl font-bold">
|
||||
{formatCurrency(goal.currentAmount, goal.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-muted-foreground">Target Amount</span>
|
||||
<span className="text-lg font-semibold">
|
||||
{formatCurrency(goal.targetAmount, goal.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="h-3" />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Remaining</span>
|
||||
<span className="text-xl font-bold text-destructive">
|
||||
{formatCurrency(remaining > 0 ? remaining : 0, goal.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{targetDateInfo && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className={targetDateInfo.isOverdue ? 'text-destructive' : ''}>
|
||||
{targetDateInfo.formatted}
|
||||
{!targetDateInfo.isOverdue && targetDateInfo.daysLeft > 0 && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({targetDateInfo.daysLeft} days left)
|
||||
</span>
|
||||
)}
|
||||
{targetDateInfo.isOverdue && (
|
||||
<span className="text-destructive ml-2">(Overdue)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => setAddMoneyDialogOpen(true)}
|
||||
className="w-full gap-2"
|
||||
disabled={goal.status === 'completed'}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Money
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Milestones */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Milestones</CardTitle>
|
||||
<CardDescription>Track your progress milestones</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{goal.milestones.map((milestone) => (
|
||||
<div
|
||||
key={milestone.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-lg border ${
|
||||
milestone.achievedAt ? 'bg-primary/5 border-primary/20' : 'bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
{milestone.achievedAt ? (
|
||||
<CheckCircle2 className="h-6 w-6 text-primary flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="h-6 w-6 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">{milestone.percentage}% Milestone</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatCurrency(milestone.targetAmount, goal.currency)}
|
||||
</span>
|
||||
</div>
|
||||
{milestone.achievedAt && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Achieved on {formatDate(milestone.achievedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Allocations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Allocations</CardTitle>
|
||||
<CardDescription>Money added to this goal</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{goal.allocations.length} {goal.allocations.length === 1 ? 'allocation' : 'allocations'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{goal.allocations.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<DollarSign className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>No allocations yet</p>
|
||||
<p className="text-sm">Click "Add Money" to start saving</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{goal.allocations.map((allocation) => (
|
||||
<div
|
||||
key={allocation.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<WalletIcon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{allocation.wallet.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatDate(allocation.createdAt)}
|
||||
</p>
|
||||
{allocation.notes && (
|
||||
<p className="text-sm text-muted-foreground italic mt-1">
|
||||
"{allocation.notes}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="font-semibold">
|
||||
{formatCurrency(allocation.amount, allocation.currency)}
|
||||
</p>
|
||||
{allocation.currency !== goal.currency && allocation.exchangeRate && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
≈ {formatCurrency(allocation.amountInGoalCurrency, goal.currency)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (confirm('Remove this allocation?')) {
|
||||
handleRemoveAllocation(allocation.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add Money Dialog */}
|
||||
{id && (
|
||||
<AddMoneyDialog
|
||||
open={addMoneyDialogOpen}
|
||||
onOpenChange={setAddMoneyDialogOpen}
|
||||
goalId={id}
|
||||
goalCurrency={goal.currency}
|
||||
onSuccess={handleAddMoney}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Goal?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{goal.name}"? This action cannot be undone.
|
||||
All allocations will be removed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteGoal}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
329
apps/web/src/components/pages/Goals.tsx
Executable file
@@ -0,0 +1,329 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import { goalsApi, type Goal, type GoalStats } from '@/lib/api/goals';
|
||||
import { GoalDetail } from './GoalDetail';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Target, TrendingUp, CheckCircle2, Loader2, Calendar, DollarSign } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { CreateGoalDialog } from './goals/CreateGoalDialog';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
|
||||
function GoalsList() {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [goals, setGoals] = useState<Goal[]>([]);
|
||||
const [stats, setStats] = useState<GoalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGoals();
|
||||
fetchStats();
|
||||
}, [filter]);
|
||||
|
||||
const fetchGoals = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const status = filter === 'all' ? undefined : filter;
|
||||
const data = await goalsApi.getAll(status);
|
||||
setGoals(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch goals:', error);
|
||||
toast.error(t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const data = await goalsApi.getStats();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoalCreated = () => {
|
||||
setCreateDialogOpen(false);
|
||||
fetchGoals();
|
||||
fetchStats();
|
||||
toast.success(t.goals.goalCreated);
|
||||
};
|
||||
|
||||
const getProgressColor = (progress: number) => {
|
||||
if (progress >= 100) return 'bg-green-500';
|
||||
if (progress >= 75) return 'bg-blue-500';
|
||||
if (progress >= 50) return 'bg-yellow-500';
|
||||
return 'bg-gray-500';
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: currency || 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return t.goals.noDeadline;
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = date.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return t.goals.overdue;
|
||||
if (diffDays === 0) return t.goals.today;
|
||||
if (diffDays === 1) return t.goals.tomorrow;
|
||||
if (diffDays < 30) return t.goals.daysLeft.replace('{days}', diffDays.toString());
|
||||
|
||||
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline', label: string }> = {
|
||||
active: { variant: 'default', label: t.goals.status.active },
|
||||
completed: { variant: 'secondary', label: t.goals.status.completed },
|
||||
archived: { variant: 'outline', label: t.goals.status.archived },
|
||||
cancelled: { variant: 'destructive', label: t.goals.status.cancelled },
|
||||
};
|
||||
|
||||
const config = variants[status] || variants.active;
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
if (loading && goals.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{t.goals.title}</h1>
|
||||
<p className="text-muted-foreground">{t.goals.pageDescription}</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t.goals.newGoal}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t.goals.totalGoals}</CardTitle>
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalGoals}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.activeGoals} {t.goals.activeGoals.toLowerCase()}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t.goals.completedGoals}</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.completedGoals}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.totalGoals > 0 ? Math.round((stats.completedGoals / stats.totalGoals) * 100) : 0}% completion rate
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t.goals.totalTarget}</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{formatCurrency(stats.totalTargetAmount, 'IDR')}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t.goals.acrossAllActiveGoals}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t.goals.overallProgress}</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.overallProgress.toFixed(1)}%</div>
|
||||
<Progress value={stats.overallProgress} className="mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-2">
|
||||
{['all', 'active', 'completed', 'archived'].map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={filter === status ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter(status)}
|
||||
>
|
||||
{status === 'all' ? t.common.all : t.goals.status[status as keyof typeof t.goals.status]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Goals Grid */}
|
||||
{goals.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Target className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">{t.goals.noGoalsYet}</h3>
|
||||
<p className="text-muted-foreground text-center mb-4">
|
||||
{t.goals.noGoalsDescription}
|
||||
</p>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t.goals.createFirstGoal}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{goals.map((goal) => {
|
||||
const progress = goal.targetAmount > 0
|
||||
? (goal.currentAmount / goal.targetAmount) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={goal.id}
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => navigate(`/goals/${goal.id}`)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="line-clamp-1">{goal.name}</CardTitle>
|
||||
{goal.description && (
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{goal.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{getStatusBadge(goal.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Progress Circle */}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-muted"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${2 * Math.PI * 56}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 56 * (1 - progress / 100)}`}
|
||||
className={getProgressColor(progress)}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-2xl font-bold">{Math.round(progress)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Current</span>
|
||||
<span className="font-semibold">
|
||||
{formatCurrency(goal.currentAmount, goal.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Target</span>
|
||||
<span className="font-semibold">
|
||||
{formatCurrency(goal.targetAmount, goal.currency)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progress} className="mt-2" />
|
||||
</div>
|
||||
|
||||
{/* Target Date */}
|
||||
{goal.targetDate && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{formatDate(goal.targetDate)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Milestones */}
|
||||
<div className="flex gap-1">
|
||||
{goal.milestones.map((milestone) => (
|
||||
<div
|
||||
key={milestone.id}
|
||||
className={`flex-1 h-2 rounded-full ${
|
||||
milestone.achievedAt ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
title={`${milestone.percentage}% ${milestone.achievedAt ? 'achieved' : 'pending'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Goal Dialog */}
|
||||
<CreateGoalDialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSuccess={handleGoalCreated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Goals() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<GoalsList />} />
|
||||
<Route path=":id" element={<GoalDetail />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
0
apps/web/src/components/pages/Login.tsx
Normal file → Executable file
0
apps/web/src/components/pages/MaintenancePage.tsx
Normal file → Executable file
0
apps/web/src/components/pages/OtpVerification.tsx
Normal file → Executable file
47
apps/web/src/components/pages/Overview.tsx
Normal file → Executable file
@@ -4,7 +4,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
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 { 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"
|
||||
import {
|
||||
@@ -30,9 +30,10 @@ import {
|
||||
} 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 { formatCurrency } from '@/constants/currencies'
|
||||
import { formatLargeNumber } from '@/utils/numberFormat'
|
||||
import { fetchExchangeRates, convertToIDR } from '@/utils/exchangeRate'
|
||||
import { GoalsSummaryCard } from './overview/GoalsSummaryCard'
|
||||
import { WalletDialog } from "@/components/dialogs/WalletDialog"
|
||||
import { TransactionDialog } from "@/components/dialogs/TransactionDialog"
|
||||
|
||||
@@ -104,23 +105,7 @@ function getFilteredTransactions(transactions: Transaction[], dateRange: DateRan
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to get date range label
|
||||
function getDateRangeLabel(dateRange: DateRange, customStartDate?: Date, customEndDate?: Date): string {
|
||||
const { t } = useLanguage()
|
||||
switch (dateRange) {
|
||||
case 'this_month': return t.overview.thisMonth
|
||||
case 'last_month': return t.overview.lastMonth
|
||||
case 'this_year': return t.overview.thisYear
|
||||
case 'last_year': return t.overview.lastYear
|
||||
case 'all_time': return t.overview.allTime
|
||||
case 'custom':
|
||||
if (customStartDate && customEndDate) {
|
||||
return `${customStartDate.toLocaleDateString()} - ${customEndDate.toLocaleDateString()}`
|
||||
}
|
||||
return t.overview.custom
|
||||
default: return t.overview.allTime
|
||||
}
|
||||
}
|
||||
// Helper function to get date range label - moved inside component to use hooks
|
||||
|
||||
// Helper function to format Y-axis values with k/m suffix
|
||||
function formatYAxisValue(value: number, language: string = 'en'): string {
|
||||
@@ -160,6 +145,23 @@ export function Overview() {
|
||||
const [expenseChartWallet, setExpenseChartWallet] = useState<string>('all')
|
||||
const [trendPeriod, setTrendPeriod] = useState<TrendPeriod>('monthly')
|
||||
|
||||
// Helper function to get date range label
|
||||
const getDateRangeLabel = (dateRange: DateRange, customStartDate?: Date, customEndDate?: Date): string => {
|
||||
switch (dateRange) {
|
||||
case 'this_month': return t.overview.thisMonth
|
||||
case 'last_month': return t.overview.lastMonth
|
||||
case 'this_year': return t.overview.thisYear
|
||||
case 'last_year': return t.overview.lastYear
|
||||
case 'all_time': return t.overview.allTime
|
||||
case 'custom':
|
||||
if (customStartDate && customEndDate) {
|
||||
return `${customStartDate.toLocaleDateString()} - ${customEndDate.toLocaleDateString()}`
|
||||
}
|
||||
return t.overview.custom
|
||||
default: return t.overview.allTime
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
loadExchangeRates()
|
||||
@@ -664,6 +666,9 @@ export function Overview() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Goals Summary */}
|
||||
<GoalsSummaryCard />
|
||||
|
||||
{/* Second Row: Wallet Breakdown (Full Width) */}
|
||||
<div className="gap-4">
|
||||
{/* Wallet Breakdown */}
|
||||
|
||||
0
apps/web/src/components/pages/Profile.tsx
Normal file → Executable file
0
apps/web/src/components/pages/Register.tsx
Normal file → Executable file
0
apps/web/src/components/pages/Transactions.tsx
Normal file → Executable file
462
apps/web/src/components/pages/WalletDetail.tsx
Executable file
@@ -0,0 +1,462 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Wallet as WalletIcon,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Edit,
|
||||
Trash2,
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
Target,
|
||||
Calendar,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { formatCurrency } from '@/constants/currencies';
|
||||
import { fetchExchangeRates } from '@/utils/exchangeRate';
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { WalletDialog } from '@/components/dialogs/WalletDialog';
|
||||
import { TransactionDialog } from '@/components/dialogs/TransactionDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Wallet {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'money' | 'asset';
|
||||
currency?: string | null;
|
||||
unit?: string | null;
|
||||
initialAmount?: number | null;
|
||||
deletedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface WalletBalance {
|
||||
walletId: string;
|
||||
kind: 'money' | 'asset';
|
||||
currency?: string;
|
||||
unit?: string;
|
||||
totalBalance: number;
|
||||
reservedBalance: number;
|
||||
availableBalance: number;
|
||||
totalUnits?: number;
|
||||
pricePerUnit?: number;
|
||||
totalValue?: number;
|
||||
}
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
type: 'income' | 'expense';
|
||||
amount: number;
|
||||
description: string;
|
||||
date: string;
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const API = '/api';
|
||||
|
||||
export function WalletDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const [wallet, setWallet] = useState<Wallet | null>(null);
|
||||
const [balance, setBalance] = useState<WalletBalance | null>(null);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [exchangeRates, setExchangeRates] = useState<{[key: string]: number}>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [walletDialogOpen, setWalletDialogOpen] = useState(false);
|
||||
const [transactionDialogOpen, setTransactionDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadWalletData();
|
||||
loadExchangeRates();
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadExchangeRates = async () => {
|
||||
const rates = await fetchExchangeRates();
|
||||
setExchangeRates(rates);
|
||||
};
|
||||
|
||||
const loadWalletData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [walletRes, balancesRes, transactionsRes] = await Promise.all([
|
||||
axios.get(`${API}/wallets/${id}`),
|
||||
axios.get(`${API}/wallets/balances`),
|
||||
axios.get(`${API}/wallets/${id}/transactions`)
|
||||
]);
|
||||
|
||||
setWallet(walletRes.data);
|
||||
const walletBalance = balancesRes.data.find((b: WalletBalance) => b.walletId === id);
|
||||
setBalance(walletBalance || null);
|
||||
setTransactions(transactionsRes.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load wallet data:', error);
|
||||
toast.error(t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWallet = async () => {
|
||||
if (!wallet) return;
|
||||
try {
|
||||
await axios.delete(`${API}/wallets/${wallet.id}`);
|
||||
toast.success(t.walletDialog.deleteSuccess);
|
||||
navigate('/wallets');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete wallet:', error);
|
||||
toast.error(t.walletDialog.deleteError);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBalance = (amount: number) => {
|
||||
if (!wallet || !balance) return formatCurrency(amount, 'IDR');
|
||||
|
||||
if (wallet.kind === 'asset' && wallet.unit && balance.totalUnits !== undefined) {
|
||||
const units = balance.totalUnits > 0 ? amount / balance.totalBalance * balance.totalUnits : 0;
|
||||
return (
|
||||
<>
|
||||
<div>{formatCurrency(amount, wallet.currency || 'IDR')}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">≈ {formatCurrency(units, wallet.unit)}</div>
|
||||
</>
|
||||
);
|
||||
} else if (wallet.kind === 'money' && wallet.currency && wallet.currency !== 'IDR' && exchangeRates[wallet.currency]) {
|
||||
const rate = exchangeRates[wallet.currency];
|
||||
const idrValue = amount / rate; // Convert to IDR
|
||||
return (
|
||||
<>
|
||||
<div>{formatCurrency(amount, wallet.currency)}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">≈ {formatCurrency(idrValue, 'IDR')}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return formatCurrency(amount, wallet.currency || 'IDR');
|
||||
};
|
||||
|
||||
const reservedPercentage = balance && balance.totalBalance > 0
|
||||
? (balance.reservedBalance / balance.totalBalance) * 100
|
||||
: 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
|
||||
<div className="grid gap-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="h-4 w-20 bg-gray-200 rounded animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-8 w-16 bg-gray-200 rounded animate-pulse" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!wallet) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">{t.wallets.noWallets}</p>
|
||||
<Button onClick={() => navigate('/wallets')} className="mt-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
{t.common.back || 'Back to Wallets'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => navigate('/wallets')}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{wallet.name}</h1>
|
||||
<Badge variant="outline">
|
||||
{wallet.kind === 'money' ? wallet.currency : wallet.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{wallet.kind === 'money' ? t.wallets.money : t.wallets.asset}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setWalletDialogOpen(true)}
|
||||
className="h-11 md:h-9 text-base md:text-sm"
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{t.common.edit}
|
||||
</Button>
|
||||
<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>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-11 md:h-9 text-base md:text-sm"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t.common.delete}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t.walletDialog.deleteConfirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t.walletDialog.deleteConfirm}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t.walletDialog.deleteConfirmCancel}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={deleteWallet}>
|
||||
{t.walletDialog.deleteConfirmDelete}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t.goals.totalBalance}</CardTitle>
|
||||
<WalletIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold break-words">
|
||||
{balance ? formatBalance(balance.totalBalance) : '-'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t.goals.reservedForGoals}</CardTitle>
|
||||
<Target className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-primary break-words">
|
||||
{balance && balance.reservedBalance > 0 ? formatBalance(balance.reservedBalance) : '-'}
|
||||
</div>
|
||||
{balance && balance.reservedBalance > 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{reservedPercentage.toFixed(0)}% {t.overview.ofTotal || 'of total'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t.goals.availableToAllocate}</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600 break-words">
|
||||
{balance ? formatBalance(balance.availableBalance) : '-'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Balance Breakdown */}
|
||||
{balance && balance.reservedBalance > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.wallets.balance}</CardTitle>
|
||||
<CardDescription>{t.overview.balanceBreakdown || 'Balance allocation breakdown'}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t.goals.reservedForGoals}:</span>
|
||||
<span className="font-medium text-primary">
|
||||
{formatBalance(balance.reservedBalance)} ({reservedPercentage.toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={reservedPercentage} className="h-2" />
|
||||
<div className="flex items-center justify-between text-sm pt-2">
|
||||
<span className="text-muted-foreground">{t.goals.availableToAllocate}:</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatBalance(balance.availableBalance)} ({(100 - reservedPercentage).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exchange rate or price per unit */}
|
||||
{wallet.kind === 'money' && wallet.currency !== 'IDR' && balance.pricePerUnit && (
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span>
|
||||
{t.wallets.pricePerUnit || 'Exchange Rate'}: {formatCurrency(balance.pricePerUnit, 'IDR')} / {wallet.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wallet.kind === 'asset' && balance.pricePerUnit && (
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span>
|
||||
{t.wallets.pricePerUnit}: {formatCurrency(balance.pricePerUnit, wallet.currency || 'IDR')} / {wallet.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Transactions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t.transactions.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{transactions.length} {t.transactions.title.toLowerCase()}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Export CSV functionality
|
||||
window.location.href = `${API}/wallets/${id}/transactions/export.csv`;
|
||||
}}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t.transactions.export || 'Export'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{transactions.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t.transactions.noTransactions}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t.common.date}</TableHead>
|
||||
<TableHead>{t.transactions.description}</TableHead>
|
||||
<TableHead className="text-center">{t.common.type}</TableHead>
|
||||
<TableHead className="text-right">{t.common.amount}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.id}>
|
||||
<TableCell className="text-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
{new Date(transaction.date).toLocaleDateString()}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{transaction.description}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
transaction.type === 'income'
|
||||
? 'bg-green-500/20 text-green-600 ring-1 ring-green-500'
|
||||
: 'bg-red-500/20 text-red-600 ring-1 ring-red-500'
|
||||
}
|
||||
>
|
||||
{transaction.type === 'income' ? (
|
||||
<TrendingUp className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{transaction.type === 'income' ? t.transactions.income : t.transactions.expense}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
<span
|
||||
className={
|
||||
transaction.type === 'income' ? 'text-green-600' : 'text-red-600'
|
||||
}
|
||||
>
|
||||
{transaction.type === 'income' ? '+' : '-'}
|
||||
{formatCurrency(transaction.amount, wallet.kind === 'asset' ? wallet.unit || 'units' : wallet.currency || 'IDR')}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dialogs */}
|
||||
<WalletDialog
|
||||
open={walletDialogOpen}
|
||||
onOpenChange={setWalletDialogOpen}
|
||||
wallet={wallet}
|
||||
onSuccess={loadWalletData}
|
||||
/>
|
||||
|
||||
<TransactionDialog
|
||||
open={transactionDialogOpen}
|
||||
onOpenChange={setTransactionDialogOpen}
|
||||
walletId={wallet.id}
|
||||
onSuccess={loadWalletData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
apps/web/src/components/pages/Wallets.tsx
Normal file → Executable file
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { useLanguage } from "@/contexts/LanguageContext"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useLanguage } from '@/contexts/LanguageContext'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -11,19 +11,22 @@ import {
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Plus, Search, Edit, Trash2, Wallet, Filter, X } from "lucide-react"
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Search, Edit, Trash2, Wallet, Filter, X, LayoutGrid, Table as TableIcon } from "lucide-react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import axios from "axios"
|
||||
import { toast } from "sonner"
|
||||
import { WalletDialog } from "@/components/dialogs/WalletDialog"
|
||||
import { WalletCard } from "./wallets/WalletCard"
|
||||
import { formatCurrency } from "@/constants/currencies"
|
||||
import { fetchExchangeRates } from '@/utils/exchangeRate'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -47,30 +50,59 @@ interface Wallet {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface WalletBalance {
|
||||
walletId: string
|
||||
kind: 'money' | 'asset'
|
||||
currency?: string
|
||||
unit?: string
|
||||
totalBalance: number
|
||||
reservedBalance: number
|
||||
availableBalance: number
|
||||
totalUnits?: number
|
||||
pricePerUnit?: number
|
||||
totalValue?: number
|
||||
}
|
||||
|
||||
const API = "/api"
|
||||
|
||||
export function Wallets() {
|
||||
const { t } = useLanguage()
|
||||
const [wallets, setWallets] = useState<Wallet[]>([])
|
||||
const [balances, setBalances] = useState<WalletBalance[]>([])
|
||||
const [exchangeRates, setExchangeRates] = useState<{[key: string]: number}>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState("")
|
||||
const [kindFilter, setKindFilter] = useState<string>("all")
|
||||
const [currencyFilter, setCurrencyFilter] = useState<string>("all")
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'cards' | 'table'>('cards')
|
||||
const [walletDialogOpen, setWalletDialogOpen] = useState(false)
|
||||
const [editingWallet, setEditingWallet] = useState<Wallet | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadWallets()
|
||||
loadExchangeRates()
|
||||
}, [])
|
||||
|
||||
const loadExchangeRates = async () => {
|
||||
const rates = await fetchExchangeRates()
|
||||
setExchangeRates(rates)
|
||||
}
|
||||
|
||||
const loadWallets = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`${API}/wallets`)
|
||||
setWallets(response.data.filter((w: Wallet) => !w.deletedAt))
|
||||
const [walletsRes, balancesRes] = await Promise.all([
|
||||
axios.get(`${API}/wallets`),
|
||||
axios.get(`${API}/wallets/balances`)
|
||||
])
|
||||
setWallets(walletsRes.data.filter((w: Wallet) => !w.deletedAt))
|
||||
// Ensure balances is always an array
|
||||
setBalances(Array.isArray(balancesRes.data) ? balancesRes.data : [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load wallets:', error)
|
||||
toast.error(t.common.error)
|
||||
setBalances([]) // Set empty array on error
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -315,24 +347,121 @@ export function Wallets() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallets Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.wallets.title} ({filteredWallets.length})</CardTitle>
|
||||
<CardDescription>
|
||||
{filteredWallets.length !== wallets.length
|
||||
? t.wallets.filterDesc.replace("{count}", wallets.length.toString())
|
||||
: t.wallets.allWallets
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{/* Wallets Grid/Table */}
|
||||
{viewMode === 'cards' ? (
|
||||
/* Card View */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t.wallets.title} ({filteredWallets.length})</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredWallets.length !== wallets.length
|
||||
? t.wallets.filterDesc.replace("{count}", wallets.length.toString())
|
||||
: t.wallets.allWallets
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setViewMode('cards')}
|
||||
className="rounded-r-none h-11 md:h-9 px-3"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode('table')}
|
||||
className="rounded-l-none h-11 md:h-9 px-3"
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredWallets.map((wallet) => {
|
||||
const balance = Array.isArray(balances) ? balances.find(b => b.walletId === wallet.id) : null
|
||||
|
||||
// Create default balance if not found
|
||||
const walletBalance: WalletBalance = balance || {
|
||||
walletId: wallet.id,
|
||||
kind: wallet.kind,
|
||||
currency: wallet.currency || undefined,
|
||||
unit: wallet.unit || undefined,
|
||||
totalBalance: 0,
|
||||
reservedBalance: 0,
|
||||
availableBalance: 0,
|
||||
totalUnits: wallet.kind === 'asset' ? 0 : undefined,
|
||||
pricePerUnit: undefined,
|
||||
totalValue: undefined,
|
||||
}
|
||||
|
||||
// For non-IDR money wallets, add exchange rate from exchangeRates
|
||||
if (wallet.kind === 'money' && wallet.currency && wallet.currency !== 'IDR') {
|
||||
const rate = exchangeRates[wallet.currency]
|
||||
if (rate) {
|
||||
walletBalance.pricePerUnit = 1 / rate
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<WalletCard
|
||||
key={wallet.id}
|
||||
wallet={wallet}
|
||||
balance={walletBalance}
|
||||
onEdit={handleEditWallet}
|
||||
onDelete={(w) => deleteWallet(w.id)}
|
||||
onClick={(w) => window.location.href = `/wallets/${w.id}`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Table View */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t.wallets.title} ({filteredWallets.length})</CardTitle>
|
||||
<CardDescription>
|
||||
{filteredWallets.length !== wallets.length
|
||||
? t.wallets.filterDesc.replace("{count}", wallets.length.toString())
|
||||
: t.wallets.allWallets
|
||||
}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex border rounded-md">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode('cards')}
|
||||
className="rounded-r-none h-11 md:h-9 px-3"
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setViewMode('table')}
|
||||
className="rounded-l-none h-11 md:h-9 px-3"
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<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-right text-nowrap">{t.wallets.deposits}</TableHead>
|
||||
<TableHead className="text-right text-nowrap">{t.wallets.allocations}</TableHead>
|
||||
<TableHead className="text-center text-nowrap">{t.common.date}</TableHead>
|
||||
<TableHead className="text-right text-nowrap">{t.common.actions}</TableHead>
|
||||
</TableRow>
|
||||
@@ -340,7 +469,7 @@ export function Wallets() {
|
||||
<TableBody>
|
||||
{filteredWallets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-8">
|
||||
<TableCell colSpan={7} className="text-center py-8">
|
||||
{filteredWallets.length !== wallets.length
|
||||
? t.wallets.noWallets
|
||||
: t.wallets.createFirst
|
||||
@@ -348,27 +477,69 @@ export function Wallets() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredWallets.map((wallet) => (
|
||||
<TableRow key={wallet.id}>
|
||||
<TableCell className="font-medium text-nowrap">{wallet.name}</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 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)]'}`}
|
||||
>
|
||||
{wallet.kind === 'money' ? t.wallets.money : t.wallets.asset}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-nowrap">
|
||||
{new Date(wallet.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
filteredWallets.map((wallet) => {
|
||||
const balance = Array.isArray(balances) ? balances.find(b => b.walletId === wallet.id) : null
|
||||
|
||||
// Format balance with main amount and IDR conversion
|
||||
const formatBalanceDisplay = (amount: number) => {
|
||||
if (!balance) return null;
|
||||
|
||||
if (wallet.kind === 'asset' && wallet.unit) {
|
||||
// For assets: amount is already in units, convert to IDR for secondary
|
||||
const idrValue = amount * (balance.pricePerUnit || 0);
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-nowrap">{formatCurrency(amount, wallet.unit)}</div>
|
||||
<div className="text-xs text-muted-foreground text-nowrap">≈ {formatCurrency(idrValue, 'IDR')}</div>
|
||||
</div>
|
||||
);
|
||||
} else if (wallet.kind === 'money' && wallet.currency !== 'IDR' && balance.pricePerUnit) {
|
||||
// For non-IDR money: amount is in foreign currency, convert to IDR for secondary
|
||||
const idrValue = amount * balance.pricePerUnit;
|
||||
return (
|
||||
<div className="text-right">
|
||||
<div className="font-medium text-nowrap">{formatCurrency(amount, wallet.currency || 'IDR')}</div>
|
||||
<div className="text-xs text-muted-foreground text-nowrap">≈ {formatCurrency(idrValue, 'IDR')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// For IDR: just show the amount
|
||||
return <div className="font-medium">{formatCurrency(amount, wallet.currency || 'IDR')}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow key={wallet.id}>
|
||||
<TableCell className="font-medium text-nowrap">{wallet.name}</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 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)]'}`}
|
||||
>
|
||||
{wallet.kind === 'money' ? t.wallets.money : t.wallets.asset}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{balance ? formatBalanceDisplay(balance.totalBalance) : <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{balance && balance.reservedBalance > 0 ? (
|
||||
<div className="text-primary">
|
||||
{formatBalanceDisplay(balance.reservedBalance)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-nowrap">
|
||||
{new Date(wallet.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-nowrap">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditWallet(wallet)}>
|
||||
@@ -398,12 +569,14 @@ export function Wallets() {
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<WalletDialog
|
||||
|
||||
352
apps/web/src/components/pages/goals/AddMoneyDialog.tsx
Executable file
@@ -0,0 +1,352 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { goalsApi, type CreateAllocationDto } from '@/lib/api/goals';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, Wallet as WalletIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { formatCurrency } from '@/constants/currencies';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { fetchExchangeRates } from '@/utils/exchangeRate';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
interface Wallet {
|
||||
id: string;
|
||||
name: string;
|
||||
kind?: string;
|
||||
currency?: string;
|
||||
unit?: string;
|
||||
totalBalance: number;
|
||||
reservedBalance: number;
|
||||
availableBalance: number;
|
||||
totalUnits?: number;
|
||||
pricePerUnit?: number;
|
||||
totalValue?: number;
|
||||
}
|
||||
|
||||
interface AddMoneyDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
goalId: string;
|
||||
goalCurrency: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AddMoneyDialog({ open, onOpenChange, goalId, goalCurrency, onSuccess }: AddMoneyDialogProps) {
|
||||
const { t } = useLanguage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wallets, setWallets] = useState<Wallet[]>([]);
|
||||
const [loadingWallets, setLoadingWallets] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateAllocationDto>({
|
||||
walletId: '',
|
||||
amount: 0,
|
||||
notes: '',
|
||||
});
|
||||
const [selectedWallet, setSelectedWallet] = useState<Wallet | null>(null);
|
||||
const [exchangeRates, setExchangeRates] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchWallets();
|
||||
loadExchangeRates();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const loadExchangeRates = async () => {
|
||||
try {
|
||||
const rates = await fetchExchangeRates();
|
||||
setExchangeRates(rates);
|
||||
} catch (error) {
|
||||
console.error('Failed to load exchange rates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchWallets = async () => {
|
||||
try {
|
||||
setLoadingWallets(true);
|
||||
|
||||
// Get wallets with balances from centralized API
|
||||
const [walletsResponse, balancesResponse] = await Promise.all([
|
||||
axios.get(`${API_URL}/api/wallets`),
|
||||
axios.get(`${API_URL}/api/wallets/balances`),
|
||||
]);
|
||||
|
||||
// Merge wallet info with balance info
|
||||
const walletsWithBalance = walletsResponse.data.map((wallet: any) => {
|
||||
const balance = balancesResponse.data.find((b: any) => b.walletId === wallet.id);
|
||||
|
||||
if (!balance) {
|
||||
return {
|
||||
id: wallet.id,
|
||||
name: wallet.name,
|
||||
kind: wallet.kind,
|
||||
currency: wallet.currency || wallet.unit,
|
||||
unit: wallet.unit,
|
||||
totalBalance: 0,
|
||||
reservedBalance: 0,
|
||||
availableBalance: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: wallet.id,
|
||||
name: wallet.name,
|
||||
kind: balance.kind,
|
||||
currency: balance.currency,
|
||||
unit: balance.unit,
|
||||
totalBalance: balance.totalBalance,
|
||||
reservedBalance: balance.reservedBalance,
|
||||
availableBalance: balance.availableBalance,
|
||||
totalUnits: balance.totalUnits,
|
||||
pricePerUnit: balance.pricePerUnit,
|
||||
totalValue: balance.totalValue,
|
||||
};
|
||||
});
|
||||
|
||||
setWallets(walletsWithBalance);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch wallets:', error);
|
||||
toast.error(t.common.error);
|
||||
} finally {
|
||||
setLoadingWallets(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWalletChange = (walletId: string) => {
|
||||
const wallet = wallets.find(w => w.id === walletId);
|
||||
console.log('[AddMoneyDialog] Selected wallet:', wallet);
|
||||
console.log('[AddMoneyDialog] Exchange rates:', exchangeRates);
|
||||
if (wallet?.currency && exchangeRates) {
|
||||
console.log(`[AddMoneyDialog] Rate for ${wallet.currency}:`, exchangeRates[wallet.currency]);
|
||||
}
|
||||
setSelectedWallet(wallet || null);
|
||||
setFormData({ ...formData, walletId });
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.walletId) {
|
||||
toast.error(t.goals.selectWallet);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.amount <= 0) {
|
||||
toast.error(t.common.amount);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedWallet) {
|
||||
// For non-IDR wallets, convert input amount to wallet currency for validation
|
||||
let amountInWalletCurrency = formData.amount;
|
||||
if (selectedWallet.kind === 'money' && selectedWallet.currency && selectedWallet.currency !== 'IDR' && exchangeRates) {
|
||||
// Input is in IDR, convert to wallet currency using exchange rate
|
||||
// Rate is: 1 IDR = X foreign currency, so multiply
|
||||
const rate = exchangeRates[selectedWallet.currency];
|
||||
if (rate) {
|
||||
amountInWalletCurrency = formData.amount * rate;
|
||||
}
|
||||
} else if (selectedWallet.kind === 'asset' && selectedWallet.pricePerUnit) {
|
||||
// Input is in IDR, convert to units
|
||||
amountInWalletCurrency = formData.amount / selectedWallet.pricePerUnit;
|
||||
}
|
||||
|
||||
if (amountInWalletCurrency > selectedWallet.availableBalance) {
|
||||
toast.error(`Insufficient available balance. You have ${formatCurrency(selectedWallet.availableBalance, selectedWallet.currency || selectedWallet.unit || 'IDR')} available (${formatCurrency(selectedWallet.reservedBalance, selectedWallet.currency || selectedWallet.unit || 'IDR')} reserved for goals)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Send amount in IDR - backend will handle conversion
|
||||
await goalsApi.addAllocation(goalId, {
|
||||
walletId: formData.walletId,
|
||||
amount: formData.amount,
|
||||
notes: formData.notes || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
walletId: '',
|
||||
amount: 0,
|
||||
notes: '',
|
||||
});
|
||||
setSelectedWallet(null);
|
||||
|
||||
onSuccess();
|
||||
toast.success(t.goals.moneyAdded);
|
||||
setFormData({ walletId: '', amount: 0, notes: '' });
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to add money:', error);
|
||||
toast.error(error.response?.data?.message || t.common.error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatWalletBalance = (wallet: Wallet, amount: number) => {
|
||||
if (wallet.kind === 'asset' && wallet.totalUnits !== undefined) {
|
||||
// For assets: show units and converted value
|
||||
// Calculate the unit amount from the value amount
|
||||
const unitAmount = wallet.pricePerUnit && wallet.pricePerUnit > 0
|
||||
? amount / wallet.pricePerUnit
|
||||
: 0;
|
||||
|
||||
return `${formatCurrency(unitAmount, wallet.unit || 'units')} ≈ ${formatCurrency(amount, 'IDR')}`;
|
||||
} else {
|
||||
// For money: just show the currency
|
||||
return formatCurrency(amount, wallet.currency || 'IDR');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.goals.addMoneyTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.goals.addMoneyDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Wallet Selection */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wallet" className="text-base md:text-sm">{t.goals.selectWallet} *</Label>
|
||||
{loadingWallets ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.walletId}
|
||||
onValueChange={handleWalletChange}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t.goals.selectWalletPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wallets.map((wallet) => (
|
||||
<SelectItem key={wallet.id} value={wallet.id}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="flex items-center gap-2">
|
||||
<WalletIcon className="h-4 w-4" />
|
||||
{wallet.name}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground ml-4">
|
||||
{formatWalletBalance(wallet, wallet.availableBalance)}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{selectedWallet && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t.goals.totalBalance}:</span>
|
||||
<span className="font-medium">{formatWalletBalance(selectedWallet, selectedWallet.totalBalance)}</span>
|
||||
</div>
|
||||
{selectedWallet.reservedBalance > 0 && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t.goals.reservedForGoals}:</span>
|
||||
<span className="text-primary">-{formatWalletBalance(selectedWallet, selectedWallet.reservedBalance)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 pt-1 border-t">
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>{t.goals.availableToAllocate}:</span>
|
||||
<span className="text-green-600">{formatWalletBalance(selectedWallet, selectedWallet.availableBalance)}</span>
|
||||
</div>
|
||||
{(selectedWallet.kind === 'money' && selectedWallet.currency && selectedWallet.currency !== 'IDR' && exchangeRates?.[selectedWallet.currency]) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
≈ {formatCurrency(selectedWallet.availableBalance / exchangeRates[selectedWallet.currency], 'IDR')}
|
||||
</div>
|
||||
)}
|
||||
{(selectedWallet.kind === 'asset' && selectedWallet.pricePerUnit) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
≈ {formatCurrency(selectedWallet.availableBalance * selectedWallet.pricePerUnit, 'IDR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="amount" className="text-base md:text-sm">{t.common.amount} *</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="any"
|
||||
placeholder="0"
|
||||
value={formData.amount || ''}
|
||||
onChange={(e) => setFormData({ ...formData, amount: parseFloat(e.target.value) || 0 })}
|
||||
required
|
||||
className="h-11 md:h-9 text-base md:text-sm"
|
||||
/>
|
||||
{selectedWallet && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedWallet.kind === 'money' && selectedWallet.currency !== 'IDR'
|
||||
? `Amount in IDR (will be converted to ${selectedWallet.currency})`
|
||||
: selectedWallet.kind === 'asset'
|
||||
? `Amount in IDR (will be converted to ${selectedWallet.unit})`
|
||||
: 'Amount in IDR'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes" className="text-base md:text-sm">{t.goals.notesOptional}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder={t.goals.notesPlaceholder}
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
rows={3}
|
||||
className="text-base md:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading} className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm">
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t.goals.addMoney}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
234
apps/web/src/components/pages/goals/CreateGoalDialog.tsx
Executable file
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react';
|
||||
import { goalsApi, type CreateGoalDto } from '@/lib/api/goals';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateGoalDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'vacation', label: '🏖️ Vacation' },
|
||||
{ value: 'emergency', label: '🚨 Emergency Fund' },
|
||||
{ value: 'gadget', label: '💻 Gadget' },
|
||||
{ value: 'education', label: '🎓 Education' },
|
||||
{ value: 'vehicle', label: '🚗 Vehicle' },
|
||||
{ value: 'house', label: '🏠 House' },
|
||||
{ value: 'wedding', label: '💍 Wedding' },
|
||||
{ value: 'investment', label: '📈 Investment' },
|
||||
{ value: 'other', label: '🎯 Other' },
|
||||
];
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'IDR', label: 'IDR (Rupiah)' },
|
||||
{ value: 'USD', label: 'USD (Dollar)' },
|
||||
{ value: 'EUR', label: 'EUR (Euro)' },
|
||||
{ value: 'GBP', label: 'GBP (Pound)' },
|
||||
{ value: 'JPY', label: 'JPY (Yen)' },
|
||||
{ value: 'SGD', label: 'SGD (Singapore Dollar)' },
|
||||
{ value: 'MYR', label: 'MYR (Ringgit)' },
|
||||
];
|
||||
|
||||
export function CreateGoalDialog({ open, onOpenChange, onSuccess }: CreateGoalDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateGoalDto>({
|
||||
name: '',
|
||||
description: '',
|
||||
targetAmount: 0,
|
||||
currency: 'IDR',
|
||||
targetDate: '',
|
||||
category: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
toast.error('Please enter a goal name');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.targetAmount <= 0) {
|
||||
toast.error('Please enter a valid target amount');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await goalsApi.create({
|
||||
...formData,
|
||||
targetDate: formData.targetDate || undefined,
|
||||
description: formData.description || undefined,
|
||||
category: formData.category || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
targetAmount: 0,
|
||||
currency: 'IDR',
|
||||
targetDate: '',
|
||||
category: '',
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create goal:', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to create goal');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Goal</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set a savings goal and track your progress
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Goal Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Goal Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Vacation to Bali"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="category">Category</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(value) => setFormData({ ...formData, category: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Target Amount & Currency */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="targetAmount">Target Amount *</Label>
|
||||
<Input
|
||||
id="targetAmount"
|
||||
type="number"
|
||||
min="1"
|
||||
step="any"
|
||||
placeholder="0"
|
||||
value={formData.targetAmount || ''}
|
||||
onChange={(e) => setFormData({ ...formData, targetAmount: parseFloat(e.target.value) || 0 })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Select
|
||||
value={formData.currency}
|
||||
onValueChange={(value) => setFormData({ ...formData, currency: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CURRENCIES.map((curr) => (
|
||||
<SelectItem key={curr.value} value={curr.value}>
|
||||
{curr.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target Date */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="targetDate">Target Date (Optional)</Label>
|
||||
<Input
|
||||
id="targetDate"
|
||||
type="date"
|
||||
value={formData.targetDate}
|
||||
onChange={(e) => setFormData({ ...formData, targetDate: e.target.value })}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Goal'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
174
apps/web/src/components/pages/overview/GoalsSummaryCard.tsx
Executable file
@@ -0,0 +1,174 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Target, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { goalsApi, type Goal as BaseGoal, type GoalStats } from '@/lib/api/goals';
|
||||
|
||||
interface Goal extends BaseGoal {
|
||||
progress: number;
|
||||
}
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { formatCurrency } from '@/constants/currencies';
|
||||
|
||||
export function GoalsSummaryCard() {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [goals, setGoals] = useState<Goal[]>([]);
|
||||
const [stats, setStats] = useState<GoalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [goalsData, statsData] = await Promise.all([
|
||||
goalsApi.getAll('active'),
|
||||
goalsApi.getStats(),
|
||||
]);
|
||||
|
||||
// Get top 3 active goals sorted by progress
|
||||
const sortedGoals = goalsData
|
||||
.map(goal => ({
|
||||
...goal,
|
||||
progress: goal.targetAmount > 0 ? (goal.currentAmount / goal.targetAmount) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.progress - a.progress)
|
||||
.slice(0, 3);
|
||||
|
||||
setGoals(sortedGoals);
|
||||
setStats(statsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch goals:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
<CardTitle>{t.goals.title}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats || stats.activeGoals === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
<CardTitle>{t.goals.title}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>{t.goals.pageDescription}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Target className="h-12 w-12 text-muted-foreground mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{t.goals.noGoalsDescription}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate('/goals')}
|
||||
className="h-11 md:h-9 px-6 md:px-4 text-base md:text-sm"
|
||||
>
|
||||
{t.goals.createFirstGoal}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
<CardTitle>{t.goals.title}</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/goals')}
|
||||
className="gap-1"
|
||||
>
|
||||
{t.common.all}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{stats.activeGoals} {t.goals.activeGoals.toLowerCase()} • {stats.overallProgress.toFixed(0)}% {t.goals.progress.toLowerCase()}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Overall Stats */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t.goals.totalTarget}:</span>
|
||||
<span className="font-medium">{formatCurrency(stats.totalTargetAmount, 'IDR')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{t.goals.currentAmount}:</span>
|
||||
<span className="font-medium">{formatCurrency(stats.totalCurrentAmount, 'IDR')}</span>
|
||||
</div>
|
||||
<Progress value={stats.overallProgress} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Top 3 Goals */}
|
||||
{goals.length > 0 && (
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<p className="text-sm font-medium">{t.goals.activeGoals}:</p>
|
||||
{goals.map((goal) => (
|
||||
<div
|
||||
key={goal.id}
|
||||
className="space-y-1 cursor-pointer hover:bg-accent/50 p-2 rounded-md transition-colors"
|
||||
onClick={() => navigate(`/goals/${goal.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium line-clamp-1">{goal.name}</span>
|
||||
<span className="text-muted-foreground">{goal.progress.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={goal.progress} className="h-1.5" />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{formatCurrency(goal.currentAmount, goal.currency)}</span>
|
||||
<span>{formatCurrency(goal.targetAmount, goal.currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View All Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-11 md:h-9 text-base md:text-sm"
|
||||
onClick={() => navigate('/goals')}
|
||||
>
|
||||
{t.goals.title}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
345
apps/web/src/components/pages/wallets/WalletCard.tsx
Executable file
@@ -0,0 +1,345 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Wallet as WalletIcon, Edit, Trash2, TrendingUp, Target } from 'lucide-react';
|
||||
import { useLanguage } from '@/contexts/LanguageContext';
|
||||
import { formatCurrency } from '@/constants/currencies';
|
||||
import { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { convertIDRToWalletCurrency, formatAllocationAmount } from '@/utils/walletCalculations';
|
||||
import { fetchExchangeRates } from '@/utils/exchangeRate';
|
||||
|
||||
interface WalletBalance {
|
||||
walletId: string;
|
||||
kind: 'money' | 'asset';
|
||||
currency?: string;
|
||||
unit?: string;
|
||||
totalBalance: number;
|
||||
reservedBalance: number;
|
||||
availableBalance: number;
|
||||
totalUnits?: number;
|
||||
pricePerUnit?: number;
|
||||
totalValue?: number;
|
||||
}
|
||||
|
||||
interface GoalAllocation {
|
||||
goalId: string;
|
||||
goalName: string;
|
||||
amount: number;
|
||||
percentage: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Wallet {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: 'money' | 'asset';
|
||||
currency?: string | null;
|
||||
unit?: string | null;
|
||||
initialAmount?: number | null;
|
||||
deletedAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface WalletCardProps {
|
||||
wallet: Wallet;
|
||||
balance: WalletBalance;
|
||||
onEdit: (wallet: Wallet) => void;
|
||||
onDelete: (wallet: Wallet) => void;
|
||||
onClick: (wallet: Wallet) => void;
|
||||
}
|
||||
|
||||
export function WalletCard({ wallet, balance, onEdit, onDelete, onClick }: WalletCardProps) {
|
||||
const { t } = useLanguage();
|
||||
const [allocations, setAllocations] = useState<GoalAllocation[]>([]);
|
||||
const [exchangeRates, setExchangeRates] = useState<any>(null);
|
||||
|
||||
// Fetch exchange rates on mount
|
||||
useEffect(() => {
|
||||
const loadRates = async () => {
|
||||
try {
|
||||
const rates = await fetchExchangeRates();
|
||||
setExchangeRates(rates);
|
||||
} catch (error) {
|
||||
console.error('Failed to load exchange rates:', error);
|
||||
}
|
||||
};
|
||||
loadRates();
|
||||
}, []);
|
||||
|
||||
// Format large numbers with suffix
|
||||
const formatCompactNumber = (num: number): string => {
|
||||
const absNum = Math.abs(num);
|
||||
if (absNum >= 1_000_000_000) {
|
||||
return `${(num / 1_000_000_000).toFixed(1)} ${t.numberFormat.billion}`;
|
||||
} else if (absNum >= 1_000_000) {
|
||||
return `${(num / 1_000_000).toFixed(1)} ${t.numberFormat.million}`;
|
||||
} else if (absNum >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(1)} ${t.numberFormat.thousand}`;
|
||||
}
|
||||
return num.toLocaleString('id-ID');
|
||||
};
|
||||
|
||||
// Fetch goal allocations for this wallet
|
||||
useEffect(() => {
|
||||
const fetchAllocations = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/goals');
|
||||
const goals = response.data;
|
||||
|
||||
// Find allocations for this wallet
|
||||
const walletAllocations: GoalAllocation[] = [];
|
||||
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||||
let colorIndex = 0;
|
||||
|
||||
goals.forEach((goal: any) => {
|
||||
const allocation = goal.allocations?.find((a: any) => a.walletId === wallet.id);
|
||||
if (allocation) {
|
||||
// Allocation amount is always in IDR, convert to wallet currency for percentage calculation
|
||||
const amountInWalletCurrency = convertIDRToWalletCurrency(
|
||||
allocation.amount,
|
||||
{ kind: wallet.kind, currency: wallet.currency || undefined, unit: wallet.unit || undefined },
|
||||
exchangeRates,
|
||||
balance.pricePerUnit
|
||||
);
|
||||
|
||||
const percentage = balance.totalBalance > 0 ? (amountInWalletCurrency / balance.totalBalance) * 100 : 0;
|
||||
walletAllocations.push({
|
||||
goalId: goal.id,
|
||||
goalName: goal.name,
|
||||
amount: allocation.amount,
|
||||
percentage,
|
||||
color: colors[colorIndex % colors.length],
|
||||
});
|
||||
colorIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
setAllocations(walletAllocations);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch allocations:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (balance.reservedBalance > 0 && exchangeRates) {
|
||||
fetchAllocations();
|
||||
}
|
||||
}, [wallet.id, balance.reservedBalance, balance.totalBalance, exchangeRates]);
|
||||
|
||||
const formatBalance = (amount: number, isReserved = false) => {
|
||||
if (wallet.kind === 'asset' && wallet.unit) {
|
||||
// For assets: amount is already in units, convert to IDR for secondary display
|
||||
const idrValue = amount * (balance.pricePerUnit || 0);
|
||||
return `${formatCurrency(amount, wallet.unit)} ≈ ${formatCurrency(idrValue, 'IDR')}`;
|
||||
} else if (wallet.kind === 'money' && wallet.currency !== 'IDR' && balance.pricePerUnit) {
|
||||
// For non-IDR money: show original currency and IDR conversion
|
||||
const idrValue = amount * balance.pricePerUnit;
|
||||
return `${formatCurrency(amount, wallet.currency || 'USD')} ≈ ${formatCurrency(idrValue, 'IDR')}`;
|
||||
}
|
||||
// For IDR money: just show the currency
|
||||
return formatCurrency(amount, wallet.currency || 'IDR');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer flex flex-col" onClick={() => onClick(wallet)}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="p-2 rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<WalletIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg truncate">{wallet.name}</CardTitle>
|
||||
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{wallet.kind === 'money' ? wallet.currency : wallet.unit}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(wallet);
|
||||
}}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(wallet);
|
||||
}}
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 flex-1 flex flex-col">
|
||||
{/* Total Balance */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">{t.goals.totalBalance}:</div>
|
||||
<div className="text-lg font-bold break-words">
|
||||
{formatBalance(balance.totalBalance)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allocation Bar - Always visible */}
|
||||
<div className="space-y-2">
|
||||
{/* Stacked Allocation Bar - Always 100% */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="h-3 md:h-4 rounded-full overflow-hidden flex cursor-help hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Available/Deposit portion - Show FIRST (primary) */}
|
||||
{(() => {
|
||||
const totalAllocatedPercentage = allocations.reduce((sum, a) => sum + a.percentage, 0);
|
||||
const availablePercentage = Math.max(0, 100 - totalAllocatedPercentage);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${availablePercentage}%`,
|
||||
backgroundColor: 'var(--primary)', // Primary brand color (green)
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Allocated portions - Show AFTER (secondary) */}
|
||||
{allocations.map((allocation) => (
|
||||
<div
|
||||
key={allocation.goalId}
|
||||
style={{
|
||||
width: `${allocation.percentage}%`,
|
||||
backgroundColor: 'var(--accent)',
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64" align="start" side="top">
|
||||
<div className="space-y-2">
|
||||
{/* Available/Deposit row - FIRST (most important) */}
|
||||
<div className="flex gap-2 text-sm">
|
||||
<div className="w-3 h-3 rounded-sm flex-shrink-0 mt-1" style={{ backgroundColor: 'var(--primary)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">{t.goals.availableToAllocate || 'Tersedia'}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
({(() => {
|
||||
const totalAllocatedPercentage = allocations.reduce((sum, a) => sum + a.percentage, 0);
|
||||
return (100 - totalAllocatedPercentage).toFixed(1);
|
||||
})()}%)
|
||||
</span>
|
||||
</div>
|
||||
{/* Show wallet's native currency first for available balance */}
|
||||
{wallet.kind === 'asset' && wallet.unit ? (
|
||||
<>
|
||||
<div className="font-semibold text-base">
|
||||
{formatCurrency(balance.availableBalance, wallet.unit)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
≈ {formatCurrency(balance.availableBalance * (balance.pricePerUnit || 0), 'IDR')}
|
||||
</div>
|
||||
</>
|
||||
) : wallet.kind === 'money' && wallet.currency !== 'IDR' ? (
|
||||
<>
|
||||
<div className="font-semibold text-base">
|
||||
{formatCurrency(balance.availableBalance, wallet.currency || 'IDR')}
|
||||
</div>
|
||||
{balance.pricePerUnit && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
≈ {formatCurrency(balance.availableBalance * balance.pricePerUnit, 'IDR')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="font-semibold text-base">
|
||||
{formatCurrency(balance.availableBalance, 'IDR')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allocations section - AFTER available (if any) */}
|
||||
{allocations.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 border-t">
|
||||
<Target className="h-4 w-4 text-muted-foreground" />
|
||||
<h4 className="font-semibold text-sm text-muted-foreground">{t.goals.allocations || 'Allocations'}</h4>
|
||||
</div>
|
||||
{allocations.map((allocation) => {
|
||||
// Format allocation amount using utility
|
||||
const formatted = formatAllocationAmount(
|
||||
allocation.amount,
|
||||
{ kind: wallet.kind, currency: wallet.currency || undefined, unit: wallet.unit || undefined },
|
||||
exchangeRates,
|
||||
balance.pricePerUnit
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={allocation.goalId} className="flex gap-2 text-sm">
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm flex-shrink-0 mt-1"
|
||||
style={{ backgroundColor: 'var(--accent)' }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">{allocation.goalName}</span>
|
||||
<span className="text-muted-foreground text-xs">({allocation.percentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="font-semibold text-base">{formatted.primary}</div>
|
||||
{formatted.secondary && (
|
||||
<div className="text-xs text-muted-foreground">{formatted.secondary}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Exchange rate info */}
|
||||
{wallet.kind === 'money' && wallet.currency !== 'IDR' && balance.pricePerUnit && (
|
||||
<div className="pt-2 border-t mt-auto">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<TrendingUp className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{formatCurrency(balance.pricePerUnit, 'IDR')} / {wallet.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset-specific info */}
|
||||
{wallet.kind === 'asset' && balance.pricePerUnit && (
|
||||
<div className="pt-2 border-t mt-auto">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<TrendingUp className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{formatCurrency(balance.pricePerUnit, wallet.currency || 'IDR')} / {wallet.unit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
0
apps/web/src/components/test/CalendarTest.tsx
Normal file → Executable file
0
apps/web/src/components/ui/alert-dialog.tsx
Normal file → Executable file
0
apps/web/src/components/ui/alert.tsx
Normal file → Executable file
0
apps/web/src/components/ui/badge.tsx
Normal file → Executable file
0
apps/web/src/components/ui/button.tsx
Normal file → Executable file
0
apps/web/src/components/ui/calendar.tsx
Normal file → Executable file
0
apps/web/src/components/ui/card.tsx
Normal file → Executable file
0
apps/web/src/components/ui/chart.tsx
Normal file → Executable file
0
apps/web/src/components/ui/checkbox.tsx
Normal file → Executable file
0
apps/web/src/components/ui/command.tsx
Normal file → Executable file
0
apps/web/src/components/ui/date-picker.tsx
Normal file → Executable file
0
apps/web/src/components/ui/dialog.tsx
Normal file → Executable file
0
apps/web/src/components/ui/drawer.tsx
Normal file → Executable file
0
apps/web/src/components/ui/dropdown-menu.tsx
Normal file → Executable file
0
apps/web/src/components/ui/floating-action-button.tsx
Normal file → Executable file
0
apps/web/src/components/ui/form.tsx
Normal file → Executable file
0
apps/web/src/components/ui/input.tsx
Normal file → Executable file
0
apps/web/src/components/ui/label.tsx
Normal file → Executable file
0
apps/web/src/components/ui/multi-select.tsx
Normal file → Executable file
0
apps/web/src/components/ui/multiselector.tsx
Normal file → Executable file
0
apps/web/src/components/ui/popover.tsx
Normal file → Executable file
26
apps/web/src/components/ui/progress.tsx
Executable file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
0
apps/web/src/components/ui/responsive-dialog.tsx
Normal file → Executable file
0
apps/web/src/components/ui/select.tsx
Normal file → Executable file
0
apps/web/src/components/ui/separator.tsx
Normal file → Executable file
0
apps/web/src/components/ui/sheet.tsx
Normal file → Executable file
0
apps/web/src/components/ui/sidebar.tsx
Normal file → Executable file
0
apps/web/src/components/ui/skeleton.tsx
Normal file → Executable file
0
apps/web/src/components/ui/sonner.tsx
Normal file → Executable file
0
apps/web/src/components/ui/switch.tsx
Normal file → Executable file
0
apps/web/src/components/ui/table.tsx
Normal file → Executable file
0
apps/web/src/components/ui/tabs.tsx
Normal file → Executable file
0
apps/web/src/components/ui/textarea.tsx
Normal file → Executable file
0
apps/web/src/components/ui/tooltip.tsx
Normal file → Executable file
23
apps/web/src/constants/currencies.ts
Normal file → Executable file
@@ -43,13 +43,30 @@ 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 + (useLanguage == 'en' ? 's' : '')}`;
|
||||
|
||||
// For non-currency codes (units like "gram", "shares", etc.)
|
||||
if (!currency) {
|
||||
const formatter = new Intl.NumberFormat('id-ID', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
const formattedAmount = formatter.format(amount);
|
||||
return `${formattedAmount} ${(amount === 1) ? currencyCode : currencyCode + (useLanguage == 'en' ? 's' : '')}`;
|
||||
}
|
||||
|
||||
// For IDR, format without decimals
|
||||
if (currencyCode === 'IDR') {
|
||||
return `${currency.symbol} ${amount.toLocaleString('id-ID', { maximumFractionDigits: 0 })}`;
|
||||
const formatter = new Intl.NumberFormat('id-ID', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0
|
||||
});
|
||||
return `${currency.symbol} ${formatter.format(amount)}`;
|
||||
}
|
||||
|
||||
// For other currencies, use 2 decimal places
|
||||
return `${currency.symbol} ${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
return `${currency.symbol} ${formatter.format(amount)}`;
|
||||
};
|
||||
|
||||