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
This commit is contained in:
Dwindi Ramadhana
2026-06-17 20:40:00 +07:00
parent 35e93b826a
commit 6a6e74562c
401 changed files with 9517 additions and 397 deletions

0
apps/web/.env.example Normal file → Executable file
View File

0
apps/web/.env.local.example Normal file → Executable file
View File

0
apps/web/.gitignore vendored Normal file → Executable file
View File

0
apps/web/README.md Normal file → Executable file
View File

0
apps/web/components.json Normal file → Executable file
View File

0
apps/web/eslint.config.js Normal file → Executable file
View File

0
apps/web/index.html Normal file → Executable file
View File

25
apps/web/package-lock.json generated Normal file → Executable file
View 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
View 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
View File

0
apps/web/public/vite.svg Normal file → Executable file
View 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
View File

0
apps/web/src/App.tsx Normal file → Executable file
View File

0
apps/web/src/assets/images/logo-dark.png Normal file → Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

0
apps/web/src/components/Logo.tsx Normal file → Executable file
View File

0
apps/web/src/components/ThemeProvider.tsx Normal file → Executable file
View File

0
apps/web/src/components/ThemeToggle.tsx Normal file → Executable file
View File

0
apps/web/src/components/admin/AdminBreadcrumb.tsx Normal file → Executable file
View File

0
apps/web/src/components/admin/AdminLayout.tsx Normal file → Executable file
View File

0
apps/web/src/components/admin/AdminSidebar.tsx Normal file → Executable file
View File

0
apps/web/src/components/admin/pages/AdminDashboard.tsx Normal file → Executable file
View File

View File

0
apps/web/src/components/admin/pages/AdminPayments.tsx Normal file → Executable file
View File

0
apps/web/src/components/admin/pages/AdminPlans.tsx Normal file → Executable file
View File

0
apps/web/src/components/admin/pages/AdminSettings.tsx Normal file → Executable file
View File

View File

0
apps/web/src/components/admin/pages/AdminUsers.tsx Normal file → Executable file
View File

View File

View File

View File

0
apps/web/src/components/dialogs/TransactionDialog.tsx Normal file → Executable file
View File

2
apps/web/src/components/dialogs/WalletDialog.tsx Normal file → Executable file
View 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
View 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
View File

2
apps/web/src/components/layout/DashboardLayout.tsx Normal file → Executable file
View 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
View File

View 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>
);
}

View 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
View File

0
apps/web/src/components/pages/MaintenancePage.tsx Normal file → Executable file
View File

0
apps/web/src/components/pages/OtpVerification.tsx Normal file → Executable file
View File

47
apps/web/src/components/pages/Overview.tsx Normal file → Executable file
View 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
View File

0
apps/web/src/components/pages/Register.tsx Normal file → Executable file
View File

0
apps/web/src/components/pages/Transactions.tsx Normal file → Executable file
View File

View 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
View 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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View File

0
apps/web/src/components/ui/alert-dialog.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/alert.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/badge.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/button.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/calendar.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/card.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/chart.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/checkbox.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/command.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/date-picker.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/dialog.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/drawer.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/dropdown-menu.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/floating-action-button.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/form.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/input.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/label.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/multi-select.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/multiselector.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/popover.tsx Normal file → Executable file
View File

View 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
View File

0
apps/web/src/components/ui/select.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/separator.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/sheet.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/sidebar.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/skeleton.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/sonner.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/switch.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/table.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/tabs.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/textarea.tsx Normal file → Executable file
View File

0
apps/web/src/components/ui/tooltip.tsx Normal file → Executable file
View File

23
apps/web/src/constants/currencies.ts Normal file → Executable file
View 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)}`;
};

0
apps/web/src/contexts/AuthContext.tsx Normal file → Executable file
View File

0
apps/web/src/contexts/LanguageContext.tsx Normal file → Executable file
View File

0
apps/web/src/hooks/use-media-query.ts Normal file → Executable file
View File

0
apps/web/src/hooks/use-mobile.ts Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More