whisper-money/resources/js/components/cashflow/saved-invested-card.tsx

238 lines
8.2 KiB
TypeScript

import { AmountDisplay } from '@/components/ui/amount-display';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { CashflowSummary } from '@/hooks/use-cashflow-data';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
import { HelpCircle, TrendingDown, TrendingUp } from 'lucide-react';
interface SavedInvestedCardProps {
current: CashflowSummary;
previous: CashflowSummary;
loading?: boolean;
currency?: string;
}
interface PercentageComparisonProps {
diff: number;
}
function PercentageComparison({ diff }: PercentageComparisonProps) {
const isPositive = diff >= 0;
const Icon = isPositive ? TrendingUp : TrendingDown;
return (
<div className="mt-2 flex items-center gap-1 text-sm">
<Icon
className={cn(
'size-4',
isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400',
)}
/>
<span>
{isPositive ? '+' : ''}
{diff.toFixed(1)}%
</span>
<span className="text-muted-foreground">
{__('vs last period')}
</span>
</div>
);
}
interface AmountComparisonProps {
diff: number;
currency: string;
}
function AmountComparison({ diff, currency }: AmountComparisonProps) {
const isPositive = diff >= 0;
const Icon = isPositive ? TrendingUp : TrendingDown;
return (
<div className="mt-1 flex items-center gap-1 text-xs">
<Icon
className={cn(
'size-3',
isPositive
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400',
)}
/>
<span>
{isPositive ? '+' : ''}
<AmountDisplay
amountInCents={diff}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
className="text-xs"
highlightPositive
/>
</span>
<span className="text-muted-foreground">
{__('vs last period')}
</span>
</div>
);
}
function allocationRate(summary: CashflowSummary): number {
if (summary.net <= 0) {
return 0;
}
return ((summary.savings + summary.investments) / summary.net) * 100;
}
export function SavedInvestedCard({
current,
previous,
loading,
currency = 'USD',
}: SavedInvestedCardProps) {
if (loading) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
{__('Saved & Invested')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-12 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent>
</Card>
);
}
const allocated = current.savings + current.investments;
const previousAllocated = previous.savings + previous.investments;
const allocatedDiff = allocated - previousAllocated;
const rate = allocationRate(current);
const rateDiff = rate - allocationRate(previous);
const savingsDiff = current.savings - previous.savings;
const investmentsDiff = current.investments - previous.investments;
const hasPreviousData = previous.income > 0;
// Determine color based on how much of the net cashflow was set aside
const rateColor =
rate >= 50
? 'text-green-600 dark:text-green-400'
: rate >= 25
? 'text-yellow-600 dark:text-yellow-400'
: rate >= 0
? 'text-orange-600 dark:text-orange-400'
: 'text-red-600 dark:text-red-400';
return (
<Card>
<CardHeader className="relative pb-2">
<CardTitle className="text-sm font-medium">
{__('Saved & Invested')}
</CardTitle>
<CardDescription>
{__('Share of net cashflow set aside')}
</CardDescription>
<Popover>
<PopoverTrigger
aria-label={__('Where do these numbers come from?')}
className="absolute top-0 right-6 text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
>
<HelpCircle className="size-4" />
</PopoverTrigger>
<PopoverContent align="end" className="text-sm">
{__(
'These figures come from transactions categorized with a "saving" or "investment" category type. Make sure you use those category types so all the money set aside is counted here.',
)}
</PopoverContent>
</Popover>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<AmountDisplay
amountInCents={allocated}
currencyCode={currency}
size="2xl"
weight="bold"
minimumFractionDigits={0}
maximumFractionDigits={0}
highlightPositive
/>
<span
className={cn(
'text-lg font-medium tabular-nums',
rateColor,
)}
>
{rate.toFixed(1)}%
</span>
</div>
{hasPreviousData && (
<>
<PercentageComparison diff={rateDiff} />
<AmountComparison
diff={allocatedDiff}
currency={currency}
/>
</>
)}
<div className="mt-3 grid grid-cols-2 gap-4 border-t pt-3">
<div>
<p className="text-xs text-muted-foreground">
{__('Saved (cashflow)')}
</p>
<AmountDisplay
amountInCents={current.savings}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="medium"
highlightPositive
/>
{hasPreviousData && (
<AmountComparison
diff={savingsDiff}
currency={currency}
/>
)}
</div>
<div>
<p className="text-xs text-muted-foreground">
{__('Invested')}
</p>
<AmountDisplay
amountInCents={current.investments}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="medium"
highlightPositive
/>
{hasPreviousData && (
<AmountComparison
diff={investmentsDiff}
currency={currency}
/>
)}
</div>
</div>
</CardContent>
</Card>
);
}