281 lines
8.9 KiB
TypeScript
281 lines
8.9 KiB
TypeScript
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>
|
||
);
|
||
}
|