feat(frontend): адаптация под мобильные устройства

- Мобильная навигация: hamburger-меню и drawer вместо фиксированного sidebar
- Модальные окна на весь экран при ширине < 480px
- Адаптивные заголовки страниц и фильтры (touch-friendly)
- Card view для таблицы операций при ширине < 600px
- Горизонтальный скролл вкладок настроек
- Увеличенные touch-targets (44px) для пагинации и кнопок
- Уменьшенная высота графиков на мобильных
- Поддержка safe-area-inset для устройств с вырезами
- theme-color в index.html

Made-with: Cursor
This commit is contained in:
Anton
2026-03-10 11:50:36 +03:00
parent a895bb4b2f
commit 56b5c81ec5
7 changed files with 477 additions and 87 deletions

View File

@@ -7,6 +7,7 @@ import {
} from 'recharts';
import type { ByCategoryItem } from '@family-budget/shared';
import { formatAmount } from '../utils/format';
import { useMediaQuery } from '../hooks/useMediaQuery';
interface Props {
data: ByCategoryItem[];
@@ -25,6 +26,9 @@ const rubFormatter = new Intl.NumberFormat('ru-RU', {
});
export function CategoryChart({ data }: Props) {
const isMobile = useMediaQuery('(max-width: 600px)');
const chartHeight = isMobile ? 250 : 300;
if (data.length === 0) {
return <div className="chart-empty">Нет данных за период</div>;
}
@@ -38,7 +42,7 @@ export function CategoryChart({ data }: Props) {
return (
<div className="category-chart-wrapper">
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={chartHeight}>
<PieChart>
<Pie
data={chartData}

View File

@@ -1,13 +1,37 @@
import type { ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export function Layout({ children }: { children: ReactNode }) {
const { user, logout } = useAuth();
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = () => setDrawerOpen(false);
return (
<div className="layout">
<aside className="sidebar">
<button
type="button"
className="burger-btn"
aria-label="Открыть меню"
onClick={() => setDrawerOpen(true)}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
{drawerOpen && (
<div
className="sidebar-overlay"
aria-hidden="true"
onClick={closeDrawer}
/>
)}
<aside className={`sidebar ${drawerOpen ? 'sidebar-open' : ''}`}>
<div className="sidebar-brand">
<span className="sidebar-brand-icon"></span>
<span className="sidebar-brand-text">Семейный бюджет</span>
@@ -19,6 +43,7 @@ export function Layout({ children }: { children: ReactNode }) {
className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}`
}
onClick={closeDrawer}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
@@ -35,6 +60,7 @@ export function Layout({ children }: { children: ReactNode }) {
className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}`
}
onClick={closeDrawer}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10" />
@@ -49,6 +75,7 @@ export function Layout({ children }: { children: ReactNode }) {
className={({ isActive }) =>
`nav-link${isActive ? ' active' : ''}`
}
onClick={closeDrawer}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />

View File

@@ -9,6 +9,7 @@ import {
ResponsiveContainer,
} from 'recharts';
import type { TimeseriesItem } from '@family-budget/shared';
import { useMediaQuery } from '../hooks/useMediaQuery';
interface Props {
data: TimeseriesItem[];
@@ -21,6 +22,9 @@ const rubFormatter = new Intl.NumberFormat('ru-RU', {
});
export function TimeseriesChart({ data }: Props) {
const isMobile = useMediaQuery('(max-width: 600px)');
const chartHeight = isMobile ? 250 : 300;
if (data.length === 0) {
return <div className="chart-empty">Нет данных за период</div>;
}
@@ -32,7 +36,7 @@ export function TimeseriesChart({ data }: Props) {
}));
return (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis

View File

@@ -7,18 +7,75 @@ interface Props {
onEdit: (tx: Transaction) => void;
}
const DIRECTION_LABELS: Record<string, string> = {
income: 'Приход',
expense: 'Расход',
transfer: 'Перевод',
};
const DIRECTION_CLASSES: Record<string, string> = {
income: 'amount-income',
expense: 'amount-expense',
transfer: 'amount-transfer',
};
function TransactionCard({
tx,
onEdit,
}: {
tx: Transaction;
onEdit: (tx: Transaction) => void;
}) {
const directionClass = DIRECTION_CLASSES[tx.direction] ?? '';
const isUnconfirmed =
!tx.isCategoryConfirmed && tx.categoryId != null;
return (
<div
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`}
>
<div className="transaction-card-header">
<span className="transaction-card-date">
{formatDateTime(tx.operationAt)}
</span>
<span className={`transaction-card-amount ${directionClass}`}>
{formatAmount(tx.amountSigned)}
</span>
</div>
<div className="transaction-card-body">
<span className="description-text">{tx.description}</span>
{tx.comment && (
<span className="comment-badge" title={tx.comment}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</span>
)}
</div>
<div className="transaction-card-footer">
<span className="transaction-card-meta">
{tx.accountAlias || '—'} · {tx.categoryName || '—'}
</span>
<div className="transaction-card-actions">
{tx.categoryId != null && !tx.isCategoryConfirmed && (
<span
className="badge badge-warning"
title="Категория не подтверждена"
>
?
</span>
)}
<button
type="button"
className="btn-icon btn-icon-touch"
onClick={() => onEdit(tx)}
title="Редактировать"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div>
</div>
</div>
);
}
export function TransactionTable({ transactions, loading, onEdit }: Props) {
if (loading) {
return <div className="table-loading">Загрузка операций...</div>;
@@ -29,79 +86,87 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
}
return (
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th>Дата</th>
<th>Счёт</th>
<th>Сумма</th>
<th>Описание</th>
<th>Категория</th>
<th className="th-center">Статус</th>
<th></th>
</tr>
</thead>
<tbody>
{transactions.map((tx) => (
<tr
key={tx.id}
className={
!tx.isCategoryConfirmed && tx.categoryId
? 'row-unconfirmed'
: ''
}
>
<td className="td-nowrap">
{formatDateTime(tx.operationAt)}
</td>
<td className="td-nowrap">{tx.accountAlias || '—'}</td>
<td
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
>
{formatAmount(tx.amountSigned)}
</td>
<td className="td-description">
<span className="description-text">{tx.description}</span>
{tx.comment && (
<span className="comment-badge" title={tx.comment}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</span>
)}
</td>
<td className="td-nowrap">
{tx.categoryName || (
<span className="text-muted"></span>
)}
</td>
<td className="td-center">
{tx.categoryId != null && !tx.isCategoryConfirmed && (
<span
className="badge badge-warning"
title="Категория не подтверждена"
>
?
</span>
)}
</td>
<td>
<button
className="btn-icon"
onClick={() => onEdit(tx)}
title="Редактировать"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</td>
<>
<div className="table-wrapper table-desktop">
<table className="data-table">
<thead>
<tr>
<th>Дата</th>
<th>Счёт</th>
<th>Сумма</th>
<th>Описание</th>
<th>Категория</th>
<th className="th-center">Статус</th>
<th></th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{transactions.map((tx) => (
<tr
key={tx.id}
className={
!tx.isCategoryConfirmed && tx.categoryId
? 'row-unconfirmed'
: ''
}
>
<td className="td-nowrap">
{formatDateTime(tx.operationAt)}
</td>
<td className="td-nowrap">{tx.accountAlias || '—'}</td>
<td
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
>
{formatAmount(tx.amountSigned)}
</td>
<td className="td-description">
<span className="description-text">{tx.description}</span>
{tx.comment && (
<span className="comment-badge" title={tx.comment}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</span>
)}
</td>
<td className="td-nowrap">
{tx.categoryName || (
<span className="text-muted"></span>
)}
</td>
<td className="td-center">
{tx.categoryId != null && !tx.isCategoryConfirmed && (
<span
className="badge badge-warning"
title="Категория не подтверждена"
>
?
</span>
)}
</td>
<td>
<button
className="btn-icon"
onClick={() => onEdit(tx)}
title="Редактировать"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="transaction-cards transaction-mobile">
{transactions.map((tx) => (
<TransactionCard key={tx.id} tx={tx} onEdit={onEdit} />
))}
</div>
</>
);
}