- 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
330 lines
12 KiB
TypeScript
Executable File
330 lines
12 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|