145 lines
4.1 KiB
TypeScript
145 lines
4.1 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import type {
|
||
Account,
|
||
AnalyticsSummaryResponse,
|
||
ByCategoryItem,
|
||
TimeseriesItem,
|
||
Granularity,
|
||
} from '@family-budget/shared';
|
||
import { getAccounts } from '../api/accounts';
|
||
import { getSummary, getByCategory, getTimeseries } from '../api/analytics';
|
||
import {
|
||
PeriodSelector,
|
||
type PeriodState,
|
||
} from '../components/PeriodSelector';
|
||
import { SummaryCards } from '../components/SummaryCards';
|
||
import { TimeseriesChart } from '../components/TimeseriesChart';
|
||
import { CategoryChart } from '../components/CategoryChart';
|
||
import { toISODate } from '../utils/format';
|
||
|
||
function getDefaultPeriod(): PeriodState {
|
||
const now = new Date();
|
||
return {
|
||
mode: 'month',
|
||
from: toISODate(new Date(now.getFullYear(), now.getMonth(), 1)),
|
||
to: toISODate(now),
|
||
};
|
||
}
|
||
|
||
export function AnalyticsPage() {
|
||
const [period, setPeriod] = useState<PeriodState>(getDefaultPeriod);
|
||
const [accountId, setAccountId] = useState<string>('');
|
||
const [onlyConfirmed, setOnlyConfirmed] = useState(false);
|
||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||
const [summary, setSummary] = useState<AnalyticsSummaryResponse | null>(
|
||
null,
|
||
);
|
||
const [byCategory, setByCategory] = useState<ByCategoryItem[]>([]);
|
||
const [timeseries, setTimeseries] = useState<TimeseriesItem[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
getAccounts().then(setAccounts).catch(() => {});
|
||
}, []);
|
||
|
||
const fetchAll = useCallback(async () => {
|
||
if (!period.from || !period.to) return;
|
||
setLoading(true);
|
||
|
||
let gran: Granularity;
|
||
switch (period.mode) {
|
||
case 'week':
|
||
gran = 'day';
|
||
break;
|
||
case 'month':
|
||
gran = 'week';
|
||
break;
|
||
default:
|
||
gran = 'month';
|
||
break;
|
||
}
|
||
|
||
const base = {
|
||
from: period.from,
|
||
to: period.to,
|
||
...(accountId ? { accountId: Number(accountId) } : {}),
|
||
...(onlyConfirmed ? { onlyConfirmed: true } : {}),
|
||
};
|
||
|
||
try {
|
||
const [s, bc, ts] = await Promise.all([
|
||
getSummary(base),
|
||
getByCategory(base),
|
||
getTimeseries({ ...base, granularity: gran }),
|
||
]);
|
||
setSummary(s);
|
||
setByCategory(bc);
|
||
setTimeseries(ts);
|
||
} catch {
|
||
// 401 handled by interceptor
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [period, accountId, onlyConfirmed]);
|
||
|
||
useEffect(() => {
|
||
fetchAll();
|
||
}, [fetchAll]);
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="page-header">
|
||
<h1>Аналитика</h1>
|
||
</div>
|
||
|
||
<div className="analytics-controls">
|
||
<PeriodSelector period={period} onChange={setPeriod} />
|
||
<div className="analytics-filters">
|
||
<div className="filter-group">
|
||
<label>Счёт</label>
|
||
<select
|
||
value={accountId}
|
||
onChange={(e) => setAccountId(e.target.value)}
|
||
>
|
||
<option value="">Все счета</option>
|
||
{accounts.map((a) => (
|
||
<option key={a.id} value={a.id}>
|
||
{a.alias || a.accountNumberMasked}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="filter-group filter-group-checkbox">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={onlyConfirmed}
|
||
onChange={(e) => setOnlyConfirmed(e.target.checked)}
|
||
/>
|
||
Только подтверждённые
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="section-loading">Загрузка данных...</div>
|
||
) : (
|
||
<>
|
||
{summary && <SummaryCards summary={summary} />}
|
||
<div className="analytics-charts">
|
||
<div className="chart-card">
|
||
<h3>Динамика</h3>
|
||
<TimeseriesChart data={timeseries} />
|
||
</div>
|
||
<div className="chart-card">
|
||
<h3>По категориям</h3>
|
||
<CategoryChart data={byCategory} />
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|