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

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