feat(analytics): account commission and investment transfers

Handle cashback commission imports, include commissions in analytics with separate investment metrics, and expose commission/version details in the UI.

Made-with: Cursor
This commit is contained in:
Anton
2026-04-14 16:15:05 +03:00
parent 495c1e89bb
commit fccde4259d
18 changed files with 502 additions and 80 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@family-budget/frontend",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -24,6 +24,14 @@ function extractPattern(description: string): string {
.slice(0, 50);
}
function getCommissionAmountSigned(transaction: Transaction): number {
if (transaction.commission === 0) return 0;
const isCashbackIncome =
transaction.amountSigned === 0 &&
transaction.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? transaction.commission : -transaction.commission;
}
export function EditTransactionModal({
transaction,
categories,
@@ -96,6 +104,12 @@ export function EditTransactionModal({
<span className="modal-tx-label">Сумма</span>
<span>{formatAmount(transaction.amountSigned)}</span>
</div>
{transaction.commission !== 0 && (
<div className="modal-tx-row">
<span className="modal-tx-label">Комиссия</span>
<span>{formatAmount(getCommissionAmountSigned(transaction))}</span>
</div>
)}
<div className="modal-tx-row">
<span className="modal-tx-label">Описание</span>
<span className="modal-tx-description">

View File

@@ -1,6 +1,7 @@
import { useState, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import frontendPackage from '../../package.json';
export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth();
@@ -86,10 +87,15 @@ export function Layout({ children }: { children: ReactNode }) {
</nav>
<div className="sidebar-footer">
<span className="sidebar-user">{user?.login}</span>
<button className="btn-logout" onClick={() => logout()}>
Выход
</button>
<div className="sidebar-footer-top">
<span className="sidebar-user">{user?.login}</span>
<button className="btn-logout" onClick={() => logout()}>
Выход
</button>
</div>
<div className="sidebar-version">
FE v{frontendPackage.version} · BE v{user?.backendVersion ?? '—'}
</div>
</div>
</aside>

View File

@@ -31,6 +31,18 @@ export function SummaryCards({ summary }: Props) {
</div>
</div>
<div className="summary-card summary-card-investments">
<div className="summary-label">На инвестиции</div>
<div className="summary-value">
{formatAmount(summary.investmentOutflow)}
</div>
{summary.investmentIncomeExcluded > 0 && (
<div className="summary-subvalue">
Исключено из доходов: {formatAmount(summary.investmentIncomeExcluded)}
</div>
)}
</div>
{summary.topCategories.length > 0 && (
<div className="summary-card summary-card-top">
<div className="summary-label">Топ расходов</div>

View File

@@ -33,6 +33,7 @@ export function TimeseriesChart({ data }: Props) {
period: item.periodStart,
Расходы: Math.abs(item.expenseAmount) / 100,
Доходы: item.incomeAmount / 100,
Инвестиции: Math.abs(item.investmentOutflow) / 100,
}));
return (
@@ -69,6 +70,11 @@ export function TimeseriesChart({ data }: Props) {
fill="#10b981"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="Инвестиции"
fill="#f59e0b"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
);

View File

@@ -13,6 +13,13 @@ const DIRECTION_CLASSES: Record<string, string> = {
transfer: 'amount-transfer',
};
function getCommissionAmountSigned(tx: Transaction): number {
if (tx.commission === 0) return 0;
const isCashbackIncome =
tx.amountSigned === 0 && tx.description.toLowerCase().includes('зачисление');
return isCashbackIncome ? tx.commission : -tx.commission;
}
function TransactionCard({
tx,
onEdit,
@@ -36,6 +43,11 @@ function TransactionCard({
{formatAmount(tx.amountSigned)}
</span>
</div>
{tx.commission !== 0 && (
<div className="transaction-card-commission">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
<div className="transaction-card-body">
<span className="description-text">{tx.description}</span>
{tx.comment && (
@@ -117,7 +129,12 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
<td
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
>
{formatAmount(tx.amountSigned)}
<div>{formatAmount(tx.amountSigned)}</div>
{tx.commission !== 0 && (
<div className="td-commission">
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
</div>
)}
</td>
<td className="td-description">
<span className="description-text">{tx.description}</span>

View File

@@ -10,7 +10,7 @@ import { getMe, login as apiLogin, logout as apiLogout } from '../api/auth';
import { setOnUnauthorized } from '../api/client';
interface AuthState {
user: { login: string } | null;
user: { login: string; backendVersion: string } | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
@@ -20,7 +20,7 @@ interface AuthState {
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<{ login: string } | null>(null);
const [user, setUser] = useState<{ login: string; backendVersion: string } | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -31,7 +31,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
setOnUnauthorized(clearUser);
getMe()
.then((me) => setUser({ login: me.login }))
.then((me) => setUser({ login: me.login, backendVersion: me.backendVersion }))
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, [clearUser]);
@@ -41,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
await apiLogin({ login: username, password });
const me = await getMe();
setUser({ login: me.login });
setUser({ login: me.login, backendVersion: me.backendVersion });
} catch (e: unknown) {
const msg = e instanceof Error && e.message === 'Failed to fetch'
? 'Сервер недоступен. Проверьте, что backend запущен и NPM проксирует /api на порт 3000.'

View File

@@ -139,8 +139,9 @@ body {
padding: 16px 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
flex-direction: column;
align-items: stretch;
gap: 10px;
}
.sidebar-user {
@@ -148,6 +149,18 @@ body {
color: var(--color-sidebar-text);
}
.sidebar-footer-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.sidebar-version {
font-size: 11px;
line-height: 1.4;
color: var(--color-sidebar-text);
}
.btn-logout {
background: none;
border: none;
@@ -645,6 +658,12 @@ textarea {
margin-bottom: 8px;
}
.transaction-card-commission {
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 6px;
}
.transaction-card-footer {
display: flex;
align-items: center;
@@ -715,6 +734,12 @@ textarea {
font-weight: 500;
}
.td-commission {
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 400;
}
.amount-income {
color: var(--color-success);
}
@@ -1288,6 +1313,10 @@ textarea {
border-left-color: var(--color-primary);
}
.summary-card-investments {
border-left-color: var(--color-warning);
}
.summary-label {
font-size: 12px;
font-weight: 600;
@@ -1303,6 +1332,12 @@ textarea {
font-variant-numeric: tabular-nums;
}
.summary-subvalue {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-secondary);
}
.summary-top-list {
display: flex;
flex-direction: column;