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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user