feat(frontend): refresh UI with warm fintech redesign
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.8.5",
|
||||
"version": "0.8.6",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function App() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div className="app-loading">Загрузка...</div>;
|
||||
return <div className="app-state app-state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function AccountsList() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -72,21 +72,21 @@ export function AccountsList() {
|
||||
/>
|
||||
) : (
|
||||
a.alias || (
|
||||
<span className="text-muted">не задан</span>
|
||||
<span className="muted-text">не задан</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingId === a.id ? (
|
||||
<div className="btn-group">
|
||||
<div className="button-group">
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
className="button button--primary button--small"
|
||||
onClick={() => handleSave(a.id)}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
className="button button--secondary button--small"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
Отмена
|
||||
@@ -94,7 +94,7 @@ export function AccountsList() {
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
className="button button--secondary button--small"
|
||||
onClick={() => handleEdit(a)}
|
||||
>
|
||||
Изменить
|
||||
@@ -105,7 +105,7 @@ export function AccountsList() {
|
||||
))}
|
||||
{accounts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="td-center text-muted">
|
||||
<td colSpan={5} className="data-table__cell data-table__cell--center muted-text">
|
||||
Нет счетов. Импортируйте выписку.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function CategoriesList() {
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -36,9 +36,9 @@ export function CategoriesList() {
|
||||
<tbody>
|
||||
{categories.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.name}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${c.type}`}>
|
||||
<td>{c.name}</td>
|
||||
<td>
|
||||
<span className={`badge badge--${c.type}`}>
|
||||
{TYPE_LABELS[c.type] ?? c.type}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
@@ -14,9 +14,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
|
||||
'#14b8a6', '#e11d48', '#0ea5e9', '#a855f7', '#22c55e',
|
||||
'#2563eb', '#e85d3f', '#0f9f7f', '#d89b17', '#7c5cdb',
|
||||
'#c8558f', '#1495a3', '#79a92f', '#e06d2f', '#4f6fd7',
|
||||
'#1c9a8a', '#d43d5c', '#277bbd', '#9b59b6', '#2f9f63',
|
||||
];
|
||||
|
||||
const rubFormatter = new Intl.NumberFormat('ru-RU', {
|
||||
@@ -30,7 +30,7 @@ export function CategoryChart({ data }: Props) {
|
||||
const chartHeight = isMobile ? 250 : 300;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных за период</div>;
|
||||
return <div className="state state--empty">Нет данных за период</div>;
|
||||
}
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
@@ -41,7 +41,7 @@ export function CategoryChart({ data }: Props) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="category-chart-wrapper">
|
||||
<div className="category-chart">
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
@@ -58,7 +58,7 @@ export function CategoryChart({ data }: Props) {
|
||||
>
|
||||
{chartData.map((_, idx) => (
|
||||
<Cell
|
||||
key={idx}
|
||||
key={`${idx}-${COLORS[idx % COLORS.length]}`}
|
||||
fill={COLORS[idx % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
@@ -69,13 +69,13 @@ export function CategoryChart({ data }: Props) {
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
<table className="category-table">
|
||||
<table className="category-chart__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Сумма</th>
|
||||
<th className="th-center">Операций</th>
|
||||
<th className="th-center">Доля</th>
|
||||
<th className="category-chart__cell category-chart__cell--center">Операций</th>
|
||||
<th className="category-chart__cell category-chart__cell--center">Доля</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -83,7 +83,7 @@ export function CategoryChart({ data }: Props) {
|
||||
<tr key={item.categoryId}>
|
||||
<td>
|
||||
<span
|
||||
className="color-dot"
|
||||
className="category-chart__dot"
|
||||
style={{
|
||||
backgroundColor:
|
||||
COLORS[idx % COLORS.length],
|
||||
@@ -92,8 +92,8 @@ export function CategoryChart({ data }: Props) {
|
||||
{item.categoryName}
|
||||
</td>
|
||||
<td>{formatAmount(item.amount)}</td>
|
||||
<td className="td-center">{item.txCount}</td>
|
||||
<td className="td-center">
|
||||
<td className="category-chart__cell category-chart__cell--center">{item.txCount}</td>
|
||||
<td className="category-chart__cell category-chart__cell--center">
|
||||
{(item.share * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -32,28 +32,28 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal__header">
|
||||
<h2>Очистить историю операций</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="clear-history-warn">
|
||||
<div className="modal__body">
|
||||
<p className="danger-note">
|
||||
Все транзакции будут безвозвратно удалены. Счета и категории
|
||||
сохранятся.
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<div className="form-group form-group-checkbox clear-history-check">
|
||||
<div className="field field--checkbox danger-note__check">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -64,7 +64,7 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-group form-group-checkbox clear-history-check">
|
||||
<div className="field field--checkbox danger-note__check">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -77,15 +77,15 @@ export function ClearHistoryModal({ onClose, onDone }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
className="button button--danger"
|
||||
onClick={handleConfirm}
|
||||
disabled={!canConfirm || loading}
|
||||
>
|
||||
{loading ? 'Удаление…' : 'Удалить всё'}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
<button className="button button--secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,16 +34,16 @@ export function DataSection() {
|
||||
|
||||
return (
|
||||
<div className="data-section">
|
||||
<div className="section-block">
|
||||
<div className="data-section__block">
|
||||
<h3>История импортов</h3>
|
||||
<p className="section-desc">
|
||||
<p className="data-section__description">
|
||||
Список импортов выписок. Можно удалить операции конкретного импорта.
|
||||
</p>
|
||||
{imports.length === 0 ? (
|
||||
<p className="muted">Импортов пока нет.</p>
|
||||
<p className="muted-text">Импортов пока нет.</p>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<div className="table-shell">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
@@ -67,7 +67,7 @@ export function DataSection() {
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger"
|
||||
className="button button--danger button--small"
|
||||
onClick={() => setImpToDelete(imp)}
|
||||
disabled={imp.importedCount === 0}
|
||||
>
|
||||
@@ -82,15 +82,15 @@ export function DataSection() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-block">
|
||||
<div className="data-section__block">
|
||||
<h3>Очистка данных</h3>
|
||||
<p className="section-desc">
|
||||
<p className="data-section__description">
|
||||
Очистить историю операций (все транзакции). Счета, категории и
|
||||
правила сохранятся.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
className="button button--danger"
|
||||
onClick={() => setShowClearModal(true)}
|
||||
>
|
||||
Очистить историю
|
||||
|
||||
@@ -32,40 +32,40 @@ export function DeleteImportModal({ imp, onClose, onDone }: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal__header">
|
||||
<h2>Удалить импорт</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<p className="clear-history-warn">
|
||||
<div className="modal__body">
|
||||
<p className="danger-note">
|
||||
Будут удалены все операции этого импорта ({imp.importedCount}{' '}
|
||||
шт.): {imp.bank} / {accountLabel}
|
||||
</p>
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<p>Действие необратимо.</p>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
className="button button--danger"
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Удаление…' : 'Удалить'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
<button type="button" className="button button--secondary" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -83,47 +83,47 @@ export function EditTransactionModal({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal__header">
|
||||
<h2>Редактирование операции</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="modal__body">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
<div className="modal-tx-info">
|
||||
<div className="modal-tx-row">
|
||||
<span className="modal-tx-label">Дата</span>
|
||||
<div className="transaction-preview">
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Дата</span>
|
||||
<span>{formatDateTime(transaction.operationAt)}</span>
|
||||
</div>
|
||||
<div className="modal-tx-row">
|
||||
<span className="modal-tx-label">Сумма</span>
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Сумма</span>
|
||||
<span>{formatAmount(transaction.amountSigned)}</span>
|
||||
</div>
|
||||
{transaction.commission !== 0 && (
|
||||
<div className="modal-tx-row">
|
||||
<span className="modal-tx-label">Комиссия</span>
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Комиссия</span>
|
||||
<span>{formatAmount(getCommissionAmountSigned(transaction))}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="modal-tx-row">
|
||||
<span className="modal-tx-label">Описание</span>
|
||||
<span className="modal-tx-description">
|
||||
<div className="transaction-preview__row">
|
||||
<span className="transaction-preview__label">Описание</span>
|
||||
<span className="transaction-preview__description">
|
||||
{transaction.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="field">
|
||||
<label htmlFor="edit-category">Категория</label>
|
||||
<select
|
||||
id="edit-category"
|
||||
@@ -139,7 +139,7 @@ export function EditTransactionModal({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="field">
|
||||
<label htmlFor="edit-comment">Комментарий</label>
|
||||
<textarea
|
||||
id="edit-comment"
|
||||
@@ -152,7 +152,7 @@ export function EditTransactionModal({
|
||||
|
||||
<div className="form-divider" />
|
||||
|
||||
<div className="form-group form-group-checkbox">
|
||||
<div className="field field--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -165,7 +165,7 @@ export function EditTransactionModal({
|
||||
|
||||
{createRule && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div className="field">
|
||||
<label htmlFor="edit-pattern">
|
||||
Шаблон (ключевая строка)
|
||||
</label>
|
||||
@@ -177,7 +177,7 @@ export function EditTransactionModal({
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group form-group-checkbox">
|
||||
<div className="field field--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -193,17 +193,17 @@ export function EditTransactionModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="button button--secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
className="button button--primary"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
|
||||
@@ -60,21 +60,21 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div className="modal__header">
|
||||
<h2>Импорт выписки</h2>
|
||||
<button className="btn-close" onClick={onClose}>
|
||||
<button className="modal__close-button" onClick={onClose} aria-label="Закрыть">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="modal__body">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
|
||||
{!result && (
|
||||
<div className="import-upload">
|
||||
@@ -84,19 +84,19 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
type="file"
|
||||
accept=".pdf,.json,application/pdf,application/json"
|
||||
onChange={handleFileChange}
|
||||
className="file-input"
|
||||
className="import-upload__input"
|
||||
/>
|
||||
{loading && (
|
||||
<div className="import-loading">Импорт...</div>
|
||||
<div className="import-upload__loading">Импорт...</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="import-result">
|
||||
<div className="import-result-icon" aria-hidden="true">✓</div>
|
||||
<div className="import-result__icon" aria-hidden="true">✓</div>
|
||||
<h3>Импорт завершён</h3>
|
||||
<table className="import-stats">
|
||||
<table className="import-result__stats">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Счёт</td>
|
||||
@@ -122,9 +122,9 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
</table>
|
||||
|
||||
{result.isNewAccount && !aliasSaved && (
|
||||
<div className="import-alias">
|
||||
<div className="import-result__alias">
|
||||
<label>Алиас для нового счёта</label>
|
||||
<div className="import-alias-row">
|
||||
<div className="import-result__alias-row">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Напр.: Текущий, Накопительный"
|
||||
@@ -133,7 +133,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
maxLength={50}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
className="button button--primary button--small"
|
||||
onClick={handleSaveAlias}
|
||||
disabled={!alias.trim()}
|
||||
>
|
||||
@@ -144,7 +144,7 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
)}
|
||||
|
||||
{aliasSaved && (
|
||||
<div className="import-alias-saved">
|
||||
<div className="import-result__alias-saved">
|
||||
Алиас сохранён
|
||||
</div>
|
||||
)}
|
||||
@@ -152,14 +152,14 @@ export function ImportModal({ onClose, onDone }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<div className="modal__footer">
|
||||
{result ? (
|
||||
<button className="btn btn-primary" onClick={onDone}>
|
||||
<button className="button button--primary" onClick={onDone}>
|
||||
Готово
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
className="button button--secondary"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -17,10 +17,10 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="layout">
|
||||
<div className="app-shell">
|
||||
<button
|
||||
type="button"
|
||||
className="burger-btn"
|
||||
className="menu-button"
|
||||
aria-label="Открыть меню"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
>
|
||||
@@ -33,23 +33,23 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
|
||||
{drawerOpen && (
|
||||
<div
|
||||
className="sidebar-overlay"
|
||||
className="app-shell__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>
|
||||
<aside className={`sidebar ${drawerOpen ? 'sidebar--open' : ''}`}>
|
||||
<div className="sidebar__brand">
|
||||
<span className="sidebar__brand-icon">₽</span>
|
||||
<span className="sidebar__brand-text">Семейный бюджет</span>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<nav className="sidebar__nav" aria-label="Основная навигация">
|
||||
<NavLink
|
||||
to="/history"
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
|
||||
}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
@@ -66,7 +66,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
|
||||
}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
@@ -81,7 +81,7 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`nav-link${isActive ? ' active' : ''}`
|
||||
`sidebar__nav-link${isActive ? ' sidebar__nav-link--active' : ''}`
|
||||
}
|
||||
onClick={closeDrawer}
|
||||
>
|
||||
@@ -93,23 +93,23 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="sidebar-footer-top">
|
||||
<span className="sidebar-user">{user?.login}</span>
|
||||
<button className="btn-logout" onClick={() => logout()}>
|
||||
<div className="sidebar__footer">
|
||||
<div className="sidebar__user-row">
|
||||
<span className="sidebar__user">{user?.login}</span>
|
||||
<button className="sidebar__logout" onClick={() => logout()}>
|
||||
Выход
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-footer-bottom">
|
||||
<span className="sidebar-version">
|
||||
<div className="sidebar__meta">
|
||||
<span className="sidebar__version">
|
||||
FE {__FE_VERSION__} · BE {beVersion ?? '…'}
|
||||
</span>
|
||||
<span className="sidebar-copyright">© 2025 Семейный бюджет</span>
|
||||
<span className="sidebar__copyright">© 2025 Семейный бюджет</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">{children}</main>
|
||||
<main className="app-shell__main">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,35 +20,38 @@ export function Pagination({
|
||||
|
||||
return (
|
||||
<div className="pagination">
|
||||
<div className="pagination-info">
|
||||
<div className="pagination__info">
|
||||
{totalItems > 0
|
||||
? `Показано ${from}–${to} из ${totalItems}`
|
||||
: 'Нет записей'}
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<div className="pagination__controls">
|
||||
<select
|
||||
className="pagination-size"
|
||||
className="pagination__size"
|
||||
value={pageSize}
|
||||
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||
aria-label="Количество операций на странице"
|
||||
>
|
||||
<option value={10}>10</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="Предыдущая страница"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="pagination-current">
|
||||
<span className="pagination__current">
|
||||
{page} / {totalPages || 1}
|
||||
</span>
|
||||
<button
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="Следующая страница"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
|
||||
@@ -85,13 +85,13 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="period-selector">
|
||||
<div className="period-modes">
|
||||
<div className="period-picker">
|
||||
<div className="segmented-control">
|
||||
{(['week', 'month', 'year', 'custom'] as PeriodMode[]).map(
|
||||
(m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`btn-preset ${period.mode === m ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${period.mode === m ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{MODE_LABELS[m]}
|
||||
@@ -100,13 +100,18 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="period-nav">
|
||||
<div className="period-picker__nav">
|
||||
{period.mode !== 'custom' && (
|
||||
<button className="btn-page" onClick={() => navigate(-1)}>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Предыдущий период"
|
||||
title="Предыдущий период"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="period-dates">
|
||||
<div className="period-picker__dates">
|
||||
<input
|
||||
type="date"
|
||||
value={period.from}
|
||||
@@ -114,7 +119,7 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
onChange({ ...period, mode: 'custom', from: e.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="filter-separator">—</span>
|
||||
<span className="period-picker__separator">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={period.to}
|
||||
@@ -124,7 +129,12 @@ export function PeriodSelector({ period, onChange }: Props) {
|
||||
/>
|
||||
</div>
|
||||
{period.mode !== 'custom' && (
|
||||
<button className="btn-page" onClick={() => navigate(1)}>
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => navigate(1)}
|
||||
aria-label="Следующий период"
|
||||
title="Следующий период"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function RulesList() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="section-loading">Загрузка...</div>;
|
||||
return <div className="state state--loading">Загрузка...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -61,10 +61,10 @@ export function RulesList() {
|
||||
<tr>
|
||||
<th>Шаблон</th>
|
||||
<th>Категория</th>
|
||||
<th className="th-center">Приоритет</th>
|
||||
<th className="th-center">Подтверждение</th>
|
||||
<th className="data-table__head-cell data-table__head-cell--center">Приоритет</th>
|
||||
<th className="data-table__head-cell data-table__head-cell--center">Подтверждение</th>
|
||||
<th>Создано</th>
|
||||
<th className="th-center">Активно</th>
|
||||
<th className="data-table__head-cell data-table__head-cell--center">Активно</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -72,35 +72,38 @@ export function RulesList() {
|
||||
{rules.map((r) => (
|
||||
<tr
|
||||
key={r.id}
|
||||
className={!r.isActive ? 'row-inactive' : ''}
|
||||
className={!r.isActive ? 'data-table__row--inactive' : ''}
|
||||
>
|
||||
<td>
|
||||
<code>{r.pattern}</code>
|
||||
</td>
|
||||
<td>{r.categoryName}</td>
|
||||
<td className="td-center">{r.priority}</td>
|
||||
<td className="td-center">
|
||||
<td className="data-table__cell data-table__cell--center">{r.priority}</td>
|
||||
<td className="data-table__cell data-table__cell--center">
|
||||
{r.requiresConfirmation ? 'Да' : 'Нет'}
|
||||
</td>
|
||||
<td className="td-nowrap">
|
||||
<td className="data-table__cell data-table__cell--nowrap">
|
||||
{formatDate(r.createdAt)}
|
||||
</td>
|
||||
<td className="td-center">
|
||||
<td className="data-table__cell data-table__cell--center">
|
||||
<button
|
||||
className={`toggle ${r.isActive ? 'toggle-on' : 'toggle-off'}`}
|
||||
className={`switch-button ${r.isActive ? 'switch-button--on' : 'switch-button--off'}`}
|
||||
onClick={() => handleToggle(r)}
|
||||
title={
|
||||
r.isActive ? 'Деактивировать' : 'Активировать'
|
||||
}
|
||||
aria-label={
|
||||
r.isActive ? 'Деактивировать правило' : 'Активировать правило'
|
||||
}
|
||||
>
|
||||
{r.isActive ? 'Вкл' : 'Выкл'}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<div className="rules-actions">
|
||||
<div className="rules-list__actions">
|
||||
{r.isActive && (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
className="button button--secondary button--small"
|
||||
onClick={() => handleApply(r.id)}
|
||||
disabled={applyingId === r.id}
|
||||
>
|
||||
@@ -108,7 +111,7 @@ export function RulesList() {
|
||||
</button>
|
||||
)}
|
||||
{applyResult?.id === r.id && (
|
||||
<span className="apply-result">
|
||||
<span className="rules-list__result">
|
||||
Применено: {applyResult.count}
|
||||
</span>
|
||||
)}
|
||||
@@ -118,7 +121,7 @@ export function RulesList() {
|
||||
))}
|
||||
{rules.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="td-center text-muted">
|
||||
<td colSpan={7} className="data-table__cell data-table__cell--center muted-text">
|
||||
Нет правил
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -6,59 +6,61 @@ interface Props {
|
||||
}
|
||||
|
||||
export function SummaryCards({ summary }: Props) {
|
||||
const balanceModifier = summary.net >= 0 ? 'positive' : 'negative';
|
||||
|
||||
return (
|
||||
<div className="summary-cards">
|
||||
<div className="summary-card summary-card-income">
|
||||
<div className="summary-label">Доходы</div>
|
||||
<div className="summary-value">
|
||||
<div className="summary">
|
||||
<div className="summary__card summary__card--income">
|
||||
<div className="summary__label">Доходы</div>
|
||||
<div className="summary__value">
|
||||
{formatAmount(summary.totalIncome)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-card summary-card-expense">
|
||||
<div className="summary-label">Расходы</div>
|
||||
<div className="summary-value">
|
||||
<div className="summary__card summary__card--expense">
|
||||
<div className="summary__label">Расходы</div>
|
||||
<div className="summary__value">
|
||||
{formatAmount(summary.totalExpense)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`summary-card ${summary.net >= 0 ? 'summary-card-positive' : 'summary-card-negative'}`}
|
||||
className={`summary__card summary__card--${balanceModifier}`}
|
||||
>
|
||||
<div className="summary-label">Баланс</div>
|
||||
<div className="summary-value">
|
||||
<div className="summary__label">Баланс</div>
|
||||
<div className="summary__value">
|
||||
{formatAmount(summary.net)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="summary-card summary-card-investments">
|
||||
<div className="summary-label">На инвестиции</div>
|
||||
<div className="summary-value">
|
||||
<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">
|
||||
<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>
|
||||
<div className="summary-top-list">
|
||||
<div className="summary__card summary__card--top">
|
||||
<div className="summary__label">Топ расходов</div>
|
||||
<div className="summary__top-list">
|
||||
{summary.topCategories.map((cat) => (
|
||||
<div
|
||||
key={cat.categoryId}
|
||||
className="top-category-item"
|
||||
className="summary__top-item"
|
||||
>
|
||||
<span className="top-category-name">
|
||||
<span className="summary__top-name">
|
||||
{cat.categoryName}
|
||||
</span>
|
||||
<span className="top-category-amount">
|
||||
<span className="summary__top-amount">
|
||||
{formatAmount(cat.amount)}
|
||||
</span>
|
||||
<span className="top-category-share">
|
||||
<span className="summary__top-share">
|
||||
{(cat.share * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export function TimeseriesChart({ data }: Props) {
|
||||
const chartHeight = isMobile ? 250 : 300;
|
||||
|
||||
if (data.length === 0) {
|
||||
return <div className="chart-empty">Нет данных за период</div>;
|
||||
return <div className="state state--empty">Нет данных за период</div>;
|
||||
}
|
||||
|
||||
const chartData = data.map((item) => ({
|
||||
@@ -39,7 +39,7 @@ export function TimeseriesChart({ data }: Props) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e7ded2" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
tickFormatter={(v: string) => {
|
||||
@@ -47,32 +47,42 @@ export function TimeseriesChart({ data }: Props) {
|
||||
return `${d.getDate()}.${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
}}
|
||||
fontSize={12}
|
||||
stroke="#64748b"
|
||||
stroke="#7d7164"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v: number) =>
|
||||
v >= 1000 ? `${(v / 1000).toFixed(0)}к` : String(v)
|
||||
}
|
||||
fontSize={12}
|
||||
stroke="#64748b"
|
||||
stroke="#7d7164"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => rubFormatter.format(value)}
|
||||
cursor={{ fill: 'rgba(129, 93, 58, 0.08)' }}
|
||||
contentStyle={{
|
||||
border: '1px solid #e7ded2',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 16px 36px rgba(54, 42, 30, 0.14)',
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="Расходы"
|
||||
fill="#ef4444"
|
||||
fill="#e85d3f"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Доходы"
|
||||
fill="#10b981"
|
||||
fill="#0f9f7f"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Инвестиции"
|
||||
fill="#f59e0b"
|
||||
fill="#d89b17"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
|
||||
@@ -115,28 +115,29 @@ export function TransactionFilters({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="filters-panel">
|
||||
<div className="filters-row">
|
||||
<div className="filter-group">
|
||||
<div className="filters">
|
||||
<div className="filters__row">
|
||||
<div className="field field--period">
|
||||
<label>Период</label>
|
||||
<div className="filter-dates-wrap">
|
||||
<div className="filters__date-control">
|
||||
{filters.periodMode !== 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="Предыдущий период"
|
||||
title="Предыдущий период"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="filter-dates">
|
||||
<div className="filters__dates">
|
||||
<input
|
||||
type="date"
|
||||
value={filters.from}
|
||||
onChange={(e) => handleDateChange('from', e.target.value)}
|
||||
/>
|
||||
<span className="filter-separator">—</span>
|
||||
<span className="filters__separator">—</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.to}
|
||||
@@ -146,29 +147,30 @@ export function TransactionFilters({
|
||||
{filters.periodMode !== 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-page"
|
||||
className="icon-button"
|
||||
onClick={() => navigate(1)}
|
||||
aria-label="Следующий период"
|
||||
title="Следующий период"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-presets">
|
||||
<div className="segmented-control segmented-control--compact">
|
||||
<button
|
||||
className={`btn-preset ${filters.periodMode === 'week' ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${filters.periodMode === 'week' ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => applyPreset('week')}
|
||||
>
|
||||
Неделя
|
||||
</button>
|
||||
<button
|
||||
className={`btn-preset ${filters.periodMode === 'month' ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${filters.periodMode === 'month' ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => applyPreset('month')}
|
||||
>
|
||||
Месяц
|
||||
</button>
|
||||
<button
|
||||
className={`btn-preset ${filters.periodMode === 'year' ? 'active' : ''}`}
|
||||
className={`segmented-control__button ${filters.periodMode === 'year' ? 'segmented-control__button--active' : ''}`}
|
||||
onClick={() => applyPreset('year')}
|
||||
>
|
||||
Год
|
||||
@@ -176,7 +178,7 @@ export function TransactionFilters({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="field">
|
||||
<label>Счёт</label>
|
||||
<select
|
||||
value={filters.accountId}
|
||||
@@ -191,7 +193,7 @@ export function TransactionFilters({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="field">
|
||||
<label>Тип</label>
|
||||
<select
|
||||
value={filters.direction}
|
||||
@@ -204,7 +206,7 @@ export function TransactionFilters({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="field">
|
||||
<label>Категория</label>
|
||||
<select
|
||||
value={filters.categoryId}
|
||||
@@ -220,8 +222,8 @@ export function TransactionFilters({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filters-row">
|
||||
<div className="filter-group filter-group-wide">
|
||||
<div className="filters__row">
|
||||
<div className="field field--wide">
|
||||
<label>Поиск</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -231,7 +233,7 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="field">
|
||||
<label>Сумма от (₽)</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -241,7 +243,7 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="field">
|
||||
<label>Сумма до (₽)</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -251,7 +253,7 @@ export function TransactionFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group filter-group-checkbox">
|
||||
<div className="field field--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -262,9 +264,9 @@ export function TransactionFilters({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<div className="field">
|
||||
<label>Сортировка</label>
|
||||
<div className="filter-sort">
|
||||
<div className="filters__sort">
|
||||
<select
|
||||
value={filters.sortBy}
|
||||
onChange={(e) =>
|
||||
@@ -275,7 +277,7 @@ export function TransactionFilters({
|
||||
<option value="amount">По сумме</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn-sort-order"
|
||||
className="icon-button"
|
||||
onClick={() =>
|
||||
set(
|
||||
'sortOrder',
|
||||
@@ -287,6 +289,11 @@ export function TransactionFilters({
|
||||
? 'По возрастанию'
|
||||
: 'По убыванию'
|
||||
}
|
||||
aria-label={
|
||||
filters.sortOrder === 'asc'
|
||||
? 'Сортировать по убыванию'
|
||||
: 'Сортировать по возрастанию'
|
||||
}
|
||||
>
|
||||
{filters.sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</button>
|
||||
|
||||
@@ -8,9 +8,9 @@ interface Props {
|
||||
}
|
||||
|
||||
const DIRECTION_CLASSES: Record<string, string> = {
|
||||
income: 'amount-income',
|
||||
expense: 'amount-expense',
|
||||
transfer: 'amount-transfer',
|
||||
income: 'money-amount--income',
|
||||
expense: 'money-amount--expense',
|
||||
transfer: 'money-amount--transfer',
|
||||
};
|
||||
|
||||
function getCommissionAmountSigned(tx: Transaction): number {
|
||||
@@ -33,39 +33,39 @@ function TransactionCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`transaction-card ${isUnconfirmed ? 'row-unconfirmed' : ''}`}
|
||||
className={`transaction-card ${isUnconfirmed ? 'transaction-card--unconfirmed' : ''}`}
|
||||
>
|
||||
<div className="transaction-card-header">
|
||||
<span className="transaction-card-date">
|
||||
<div className="transaction-card__header">
|
||||
<span className="transaction-card__date">
|
||||
{formatDateTime(tx.operationAt)}
|
||||
</span>
|
||||
<span className={`transaction-card-amount ${directionClass}`}>
|
||||
<span className={`money-amount transaction-card__amount ${directionClass}`}>
|
||||
{formatAmount(tx.amountSigned)}
|
||||
</span>
|
||||
</div>
|
||||
{tx.commission !== 0 && (
|
||||
<div className="transaction-card-commission">
|
||||
<div className="transaction-card__commission">
|
||||
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||
</div>
|
||||
)}
|
||||
<div className="transaction-card-body">
|
||||
<span className="description-text">{tx.description}</span>
|
||||
<div className="transaction-card__body">
|
||||
<span className="transaction-card__description">{tx.description}</span>
|
||||
{tx.comment && (
|
||||
<span className="comment-badge" title={tx.comment}>
|
||||
<span className="comment-indicator" 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">
|
||||
<div className="transaction-card__footer">
|
||||
<span className="transaction-card__meta">
|
||||
{tx.accountAlias || '—'} · {tx.categoryName || '—'}
|
||||
</span>
|
||||
<div className="transaction-card-actions">
|
||||
<div className="transaction-card__actions">
|
||||
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||
<span
|
||||
className="badge badge-warning"
|
||||
className="badge badge--warning"
|
||||
title="Категория не подтверждена"
|
||||
>
|
||||
?
|
||||
@@ -73,8 +73,9 @@ function TransactionCard({
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon btn-icon-touch"
|
||||
className="icon-button icon-button--touch"
|
||||
onClick={() => onEdit(tx)}
|
||||
aria-label="Редактировать операцию"
|
||||
title="Редактировать"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -90,16 +91,16 @@ function TransactionCard({
|
||||
|
||||
export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
if (loading) {
|
||||
return <div className="table-loading">Загрузка операций...</div>;
|
||||
return <div className="state state--loading">Загрузка операций...</div>;
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return <div className="table-empty">Операции не найдены</div>;
|
||||
return <div className="state state--empty">Операции не найдены</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-wrapper table-desktop">
|
||||
<div className="table-shell table-shell--desktop">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -108,7 +109,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
<th>Сумма</th>
|
||||
<th>Описание</th>
|
||||
<th>Категория</th>
|
||||
<th className="th-center">Статус</th>
|
||||
<th className="data-table__head-cell data-table__head-cell--center">Статус</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -118,43 +119,43 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
key={tx.id}
|
||||
className={
|
||||
!tx.isCategoryConfirmed && tx.categoryId
|
||||
? 'row-unconfirmed'
|
||||
? 'data-table__row--unconfirmed'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<td className="td-nowrap">
|
||||
<td className="data-table__cell data-table__cell--nowrap">
|
||||
{formatDateTime(tx.operationAt)}
|
||||
</td>
|
||||
<td className="td-nowrap">{tx.accountAlias || '—'}</td>
|
||||
<td className="data-table__cell data-table__cell--nowrap">{tx.accountAlias || '—'}</td>
|
||||
<td
|
||||
className={`td-nowrap td-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
||||
className={`data-table__cell data-table__cell--nowrap money-amount ${DIRECTION_CLASSES[tx.direction] ?? ''}`}
|
||||
>
|
||||
<div>{formatAmount(tx.amountSigned)}</div>
|
||||
{tx.commission !== 0 && (
|
||||
<div className="td-commission">
|
||||
<div className="data-table__subtext">
|
||||
Комиссия: {formatAmount(getCommissionAmountSigned(tx))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="td-description">
|
||||
<span className="description-text">{tx.description}</span>
|
||||
<td className="data-table__cell data-table__cell--description">
|
||||
<span className="data-table__description">{tx.description}</span>
|
||||
{tx.comment && (
|
||||
<span className="comment-badge" title={tx.comment}>
|
||||
<span className="comment-indicator" 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">
|
||||
<td className="data-table__cell data-table__cell--nowrap">
|
||||
{tx.categoryName || (
|
||||
<span className="text-muted">—</span>
|
||||
<span className="muted-text">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="td-center">
|
||||
<td className="data-table__cell data-table__cell--center">
|
||||
{tx.categoryId != null && !tx.isCategoryConfirmed && (
|
||||
<span
|
||||
className="badge badge-warning"
|
||||
className="badge badge--warning"
|
||||
title="Категория не подтверждена"
|
||||
>
|
||||
?
|
||||
@@ -163,8 +164,9 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn-icon"
|
||||
className="icon-button"
|
||||
onClick={() => onEdit(tx)}
|
||||
aria-label="Редактировать операцию"
|
||||
title="Редактировать"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
@@ -179,7 +181,7 @@ export function TransactionTable({ transactions, loading, onEdit }: Props) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="transaction-cards transaction-mobile">
|
||||
<div className="transaction-list transaction-list--mobile">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionCard key={tx.id} tx={tx} onEdit={onEdit} />
|
||||
))}
|
||||
|
||||
@@ -88,14 +88,17 @@ export function AnalyticsPage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Аналитика</h1>
|
||||
<div className="page__header">
|
||||
<div>
|
||||
<p className="page__eyebrow">Обзор периода</p>
|
||||
<h1 className="page__title">Аналитика</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-controls">
|
||||
<div className="analytics-panel">
|
||||
<PeriodSelector period={period} onChange={setPeriod} />
|
||||
<div className="analytics-filters">
|
||||
<div className="filter-group">
|
||||
<div className="analytics-panel__filters">
|
||||
<div className="field">
|
||||
<label>Счёт</label>
|
||||
<select
|
||||
value={accountId}
|
||||
@@ -109,7 +112,7 @@ export function AnalyticsPage() {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="filter-group filter-group-checkbox">
|
||||
<div className="field field--checkbox">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -123,11 +126,11 @@ export function AnalyticsPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="section-loading">Загрузка данных...</div>
|
||||
<div className="state state--loading">Загрузка данных...</div>
|
||||
) : (
|
||||
<>
|
||||
{summary && <SummaryCards summary={summary} />}
|
||||
<div className="analytics-charts">
|
||||
<div className="analytics-grid">
|
||||
<div className="chart-card">
|
||||
<h3>Динамика</h3>
|
||||
<TimeseriesChart data={timeseries} />
|
||||
|
||||
@@ -204,12 +204,20 @@ export function HistoryPage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>История операций</h1>
|
||||
<div className="page__header">
|
||||
<div>
|
||||
<p className="page__eyebrow">Операции</p>
|
||||
<h1 className="page__title">История операций</h1>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
className="button button--primary"
|
||||
onClick={() => setShowImport(true)}
|
||||
>
|
||||
<svg className="button__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7,10 12,15 17,10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
Импорт выписки
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -20,16 +20,16 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<span className="login-icon">₽</span>
|
||||
<div className="login">
|
||||
<div className="login__panel">
|
||||
<div className="login__header">
|
||||
<span className="login__icon">₽</span>
|
||||
<h1>Семейный бюджет</h1>
|
||||
<p>Войдите для продолжения</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<div className="form-group">
|
||||
<form onSubmit={handleSubmit} className="login__form">
|
||||
{error && <div className="alert alert--error">{error}</div>}
|
||||
<div className="field">
|
||||
<label htmlFor="login">Логин</label>
|
||||
<input
|
||||
id="login"
|
||||
@@ -41,7 +41,7 @@ export function LoginPage() {
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="field">
|
||||
<label htmlFor="password">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -54,7 +54,7 @@ export function LoginPage() {
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
className="button button--primary button--block"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Вход...' : 'Войти'}
|
||||
|
||||
@@ -11,38 +11,41 @@ export function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Настройки</h1>
|
||||
<div className="page__header">
|
||||
<div>
|
||||
<p className="page__eyebrow">Справочники</p>
|
||||
<h1 className="page__title">Настройки</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={`tab ${tab === 'accounts' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'accounts' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('accounts')}
|
||||
>
|
||||
Счета
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${tab === 'categories' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'categories' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('categories')}
|
||||
>
|
||||
Категории
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${tab === 'rules' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'rules' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('rules')}
|
||||
>
|
||||
Правила
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${tab === 'data' ? 'active' : ''}`}
|
||||
className={`tabs__button ${tab === 'data' ? 'tabs__button--active' : ''}`}
|
||||
onClick={() => setTab('data')}
|
||||
>
|
||||
Данные
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
<div className="tabs__content">
|
||||
{tab === 'accounts' && <AccountsList />}
|
||||
{tab === 'categories' && <CategoriesList />}
|
||||
{tab === 'rules' && <RulesList />}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
2
package-lock.json
generated
2
package-lock.json
generated
@@ -64,7 +64,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "@family-budget/frontend",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.6",
|
||||
"dependencies": {
|
||||
"@family-budget/shared": "*",
|
||||
"react": "^19.0.0",
|
||||
|
||||
Reference in New Issue
Block a user