- Мобильная навигация: hamburger-меню и drawer вместо фиксированного sidebar - Модальные окна на весь экран при ширине < 480px - Адаптивные заголовки страниц и фильтры (touch-friendly) - Card view для таблицы операций при ширине < 600px - Горизонтальный скролл вкладок настроек - Увеличенные touch-targets (44px) для пагинации и кнопок - Уменьшенная высота графиков на мобильных - Поддержка safe-area-inset для устройств с вырезами - theme-color в index.html Made-with: Cursor
173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
import type { Transaction } from '@family-budget/shared';
|
||
import { formatAmount, formatDateTime } from '../utils/format';
|
||
|
||
interface Props {
|
||
transactions: Transaction[];
|
||
loading: boolean;
|
||
onEdit: (tx: Transaction) => void;
|
||
}
|
||
|
||
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>;
|
||
}
|
||
|
||
if (transactions.length === 0) {
|
||
return <div className="table-empty">Операции не найдены</div>;
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<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>
|
||
</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>
|
||
</>
|
||
);
|
||
}
|