whisper-money/resources/js/pages/cashflow/index.tsx

281 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BreakdownCard } from '@/components/cashflow/breakdown-card';
import { NetCashflowCard } from '@/components/cashflow/net-cashflow-card';
import { PeriodNavigation } from '@/components/cashflow/period-navigation';
import { SavedInvestedCard } from '@/components/cashflow/saved-invested-card';
import { CashflowTrendChart, SankeyChart } from '@/components/charts';
import HeadingSmall from '@/components/heading-small';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CashflowPeriodType, useCashflowData } from '@/hooks/use-cashflow-data';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { cashflow } from '@/routes';
import { BreadcrumbItem } from '@/types';
import { __ } from '@/utils/i18n';
import { Head, router, usePage } from '@inertiajs/react';
import {
endOfMonth,
endOfQuarter,
endOfYear,
format,
getQuarter,
parse,
startOfMonth,
startOfQuarter,
startOfYear,
} from 'date-fns';
import { useEffect, useState } from 'react';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Cashflow',
href: cashflow().url,
},
];
function parsePeriodParam(
period: string | null,
periodType: CashflowPeriodType,
): Date {
if (!period) {
return new Date();
}
try {
if (periodType === 'quarter') {
const match = /^(\d{4})-Q([1-4])$/.exec(period);
if (match) {
return new Date(Number(match[1]), (Number(match[2]) - 1) * 3);
}
}
if (periodType === 'year') {
const match = /^(\d{4})$/.exec(period);
if (match) {
return new Date(Number(match[1]), 0);
}
}
const parsedDate = parse(period, 'yyyy-MM', new Date());
if (!isNaN(parsedDate.getTime())) {
return parsedDate;
}
} catch {
return new Date();
}
return new Date();
}
function getPeriodRange(
currentDate: Date,
periodType: CashflowPeriodType,
): { from: Date; to: Date } {
if (periodType === 'quarter') {
return {
from: startOfQuarter(currentDate),
to: endOfQuarter(currentDate),
};
}
if (periodType === 'year') {
return {
from: startOfYear(currentDate),
to: endOfYear(currentDate),
};
}
return {
from: startOfMonth(currentDate),
to: endOfMonth(currentDate),
};
}
function formatPeriodParam(
currentDate: Date,
periodType: CashflowPeriodType,
): string {
if (periodType === 'quarter') {
return `${format(currentDate, 'yyyy')}-Q${getQuarter(currentDate)}`;
}
if (periodType === 'year') {
return format(currentDate, 'yyyy');
}
return format(currentDate, 'yyyy-MM');
}
export default function CashflowPage() {
const {
auth,
period: initialPeriod,
periodType: initialPeriodType,
} = usePage<{
auth: { user: { currency_code: string } };
period: string | null;
periodType: CashflowPeriodType;
}>().props;
const [periodType, setPeriodType] =
useState<CashflowPeriodType>(initialPeriodType);
const [currentDate, setCurrentDate] = useState<Date>(() =>
parsePeriodParam(initialPeriod, initialPeriodType),
);
const [sankeyDrill, setSankeyDrill] = useState<{
id: string;
label: string;
} | null>(null);
const period = getPeriodRange(currentDate, periodType);
const {
summary,
sankey,
trend,
incomeBreakdown,
expenseBreakdown,
isLoading,
} = useCashflowData({
...period,
periodType,
sankeyParent: sankeyDrill?.id ?? null,
});
// Leaving the period resets any Sankey drill-down.
useEffect(() => {
setSankeyDrill(null);
}, [currentDate, periodType]);
useEffect(() => {
const periodParam = formatPeriodParam(currentDate, periodType);
if (initialPeriod !== periodParam || initialPeriodType !== periodType) {
router.visit(
cashflow({
query: { period: periodParam, period_type: periodType },
}).url,
{
preserveScroll: true,
preserveState: true,
replace: true,
},
);
}
}, [currentDate, initialPeriod, initialPeriodType, periodType]);
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title={__('Cashflow')} />
<div className="space-y-6 p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<HeadingSmall
title={__('Cashflow')}
description={__(
'Track your income, expenses, and savings',
)}
/>
<PeriodNavigation
currentDate={currentDate}
periodType={periodType}
onDateChange={setCurrentDate}
onPeriodTypeChange={setPeriodType}
/>
</div>
{/* Summary Cards */}
<div className="grid gap-6 md:grid-cols-2">
<NetCashflowCard
current={summary.current}
previous={summary.previous}
loading={isLoading}
currency={auth.user.currency_code}
/>
<SavedInvestedCard
current={summary.current}
previous={summary.previous}
loading={isLoading}
currency={auth.user.currency_code}
/>
</div>
{/* Trend Chart */}
<CashflowTrendChart
data={trend}
loading={isLoading}
currency={auth.user.currency_code}
periodType={periodType}
/>
{/* Sankey Diagram */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">
{__('Money Flow')}
</CardTitle>
{sankeyDrill && (
<nav className="flex items-center gap-1 text-sm">
<button
type="button"
onClick={() => setSankeyDrill(null)}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{__('All categories')}
</button>
<span className="text-muted-foreground">
</span>
<span className="font-medium">
{sankeyDrill.label}
</span>
</nav>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="h-[400px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
) : (
<SankeyChart
data={sankey}
height={400}
currency={auth.user.currency_code}
period={period}
onDrill={(id, label) =>
setSankeyDrill({ id, label })
}
/>
)}
</CardContent>
</Card>
{/* Breakdown Cards */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<BreakdownCard
type="income"
data={incomeBreakdown}
loading={isLoading}
currency={auth.user.currency_code}
period={period}
/>
<BreakdownCard
type="expense"
data={expenseBreakdown}
loading={isLoading}
currency={auth.user.currency_code}
period={period}
/>
</div>
</div>
</AppSidebarLayout>
);
}