feat: add tests for import polling, login page, money utilities, and query keys

- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses.
- Created tests for the login page, validating user interactions and error handling.
- Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values.
- Developed tests for query keys, ensuring proper scoping and invalidation roots.

chore: setup testing environment and configuration

- Added setup file for testing with Vitest and Testing Library.
- Configured TypeScript and Vite for the frontend project.
- Created Docker and Nginx configurations for local development and production environments.
- Established a PostgreSQL initialization script for setting up application roles.

docs: add README and environment configuration for infra

- Included README for the infra directory, detailing local development setup and service descriptions.
- Created an example environment configuration file for local development with placeholders for sensitive information.
This commit is contained in:
k1nq
2026-09-14 09:26:33 +05:00
parent d8b938adfb
commit d582f15681
164 changed files with 25553 additions and 1 deletions
+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import { AppRoutes } from './app/router'
import { ToastProvider } from '@/components/ui/Toaster'
import { isApiError } from '@/lib/apiErrors'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) =>
(!isApiError(error) || error.status === 0) && failureCount < 2,
refetchOnWindowFocus: false,
staleTime: 15_000,
},
mutations: { retry: false },
},
})
export default function App() {
const { t, i18n } = useTranslation()
useEffect(() => {
document.documentElement.lang = i18n.language
document.title = t('app.title')
}, [i18n.language, t])
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</ToastProvider>
</QueryClientProvider>
)
}
+118
View File
@@ -0,0 +1,118 @@
export class ApiError extends Error {
readonly status: number
readonly code: string
readonly detail: string | null
constructor(status: number, code: string, detail: string | null) {
super(detail ?? code)
this.name = 'ApiError'
this.status = status
this.code = code
this.detail = detail
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError
}
type QueryValue = string | number | boolean | undefined | null
export interface RequestOptions {
query?: Record<string, QueryValue>
json?: unknown
formData?: FormData
signal?: AbortSignal
}
const API_BASE = '/api/v1'
const CSRF_COOKIE = 'csrf_token'
function buildUrl(path: string, query: Record<string, QueryValue> | undefined): string {
const url = new URL(`${API_BASE}${path}`, window.location.origin)
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === null || value === '') continue
url.searchParams.set(key, String(value))
}
}
return url.toString()
}
function readCsrfToken(): string | undefined {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${CSRF_COOKIE}=([^;]*)`))
return match?.[1] ? decodeURIComponent(match[1]) : undefined
}
interface ErrorEnvelope {
code?: unknown
message?: unknown
detail?: unknown
error?: { code?: unknown; message?: unknown; detail?: unknown }
}
function parseErrorEnvelope(body: unknown): { code: string; detail: string | null } {
const envelope = (body ?? {}) as ErrorEnvelope
const inner = envelope.error ?? envelope
const code = typeof inner.code === 'string' ? inner.code : ''
const detail =
typeof inner.detail === 'string'
? inner.detail
: typeof inner.message === 'string'
? inner.message
: null
return { code: code || 'unknown', detail }
}
async function toApiError(response: Response): Promise<ApiError> {
let body: unknown = null
try {
body = await response.json()
} catch {
body = null
}
const { code, detail } = parseErrorEnvelope(body)
return new ApiError(response.status, code || `http_${response.status}`, detail)
}
async function request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
const headers = new Headers({ Accept: 'application/json' })
let body: BodyInit | undefined
if (options.formData) {
body = options.formData
} else if (options.json !== undefined) {
headers.set('Content-Type', 'application/json')
body = JSON.stringify(options.json)
}
if (method !== 'GET') {
const csrfToken = readCsrfToken()
if (csrfToken) headers.set('X-CSRF-Token', csrfToken)
}
let response: Response
try {
response = await fetch(buildUrl(path, options.query), {
method,
headers,
body,
credentials: 'same-origin',
signal: options.signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new ApiError(0, 'network_error', null)
}
if (!response.ok) throw await toApiError(response)
if (response.status === 204) return undefined as T
return (await response.json()) as T
}
export const apiClient = {
get: <T>(path: string, options?: RequestOptions) => request<T>('GET', path, options),
post: <T>(path: string, options?: RequestOptions) => request<T>('POST', path, options),
patch: <T>(path: string, options?: RequestOptions) => request<T>('PATCH', path, options),
delete: <T>(path: string, options?: RequestOptions) => request<T>('DELETE', path, options),
}
+111
View File
@@ -0,0 +1,111 @@
import { apiClient } from './client'
import type {
AcceptInvitePayload,
Account,
BalancesReport,
BudgetCategory,
BudgetSummary,
CashFlowMonth,
CategorySpendItem,
ConfirmImportPayload,
CreateAccountPayload,
CreateOperationPayload,
ExportInfo,
ImportDetail,
ImportSummary,
LoginPayload,
Operation,
OperationFilters,
Page,
PlanFactReport,
PasswordResetPayload,
SaveRowDecisionPayload,
SpaceDeletionStatus,
User,
} from './types'
export const api = {
auth: {
me: () => apiClient.get<User>('/auth/me'),
login: (payload: LoginPayload) => apiClient.post<User>('/auth/login', { json: payload }),
logout: () => apiClient.post<void>('/auth/logout'),
acceptInvite: (payload: AcceptInvitePayload) =>
apiClient.post<User>('/auth/invitations/accept', { json: payload }),
requestPasswordReset: (email: string) =>
apiClient.post<{ accepted: boolean }>('/auth/password-reset', { json: { email } }),
confirmPasswordReset: (payload: PasswordResetPayload) =>
apiClient.post<User>('/auth/password-reset/confirm', { json: payload }),
},
accounts: {
list: () => apiClient.get<Account[]>('/accounts'),
create: (payload: CreateAccountPayload) =>
apiClient.post<Account>('/accounts', { json: payload }),
archive: (id: string) =>
apiClient.patch<Account>(`/accounts/${id}`, { json: { status: 'archived' } }),
},
operations: {
list: (filters: OperationFilters) =>
apiClient.get<Page<Operation>>('/operations', { query: { ...filters } }),
create: (payload: CreateOperationPayload) =>
apiClient.post<Operation>('/operations', { json: payload }),
void: (id: string, reason: string) =>
apiClient.post<Operation>(`/operations/${id}/void`, { json: { reason } }),
categorize: (id: string, categoryId: string) =>
apiClient.patch<Operation>(`/operations/${id}`, { json: { categoryId } }),
},
budget: {
summary: (month: string) =>
apiClient.get<BudgetSummary>('/budget/summary', { query: { month } }),
categories: (month: string) =>
apiClient.get<BudgetCategory[]>('/budget/categories', { query: { month } }),
updatePlan: (categoryId: string, month: string, plannedKopecks: number) =>
apiClient.patch<BudgetCategory>(`/budget/categories/${categoryId}`, {
json: { month, plannedKopecks },
}),
fund: (categoryId: string, month: string, amountKopecks: number) =>
apiClient.post<BudgetCategory>(`/budget/categories/${categoryId}/fund`, {
json: { month, amountKopecks },
}),
close: (month: string) => apiClient.post<BudgetSummary>('/budget/close', { json: { month } }),
reopen: (month: string) => apiClient.post<BudgetSummary>('/budget/reopen', { json: { month } }),
copyPrevious: (month: string) =>
apiClient.post<BudgetSummary>('/budget/copy-previous', { json: { month } }),
},
imports: {
list: () => apiClient.get<ImportSummary[]>('/imports'),
get: (id: string) => apiClient.get<ImportDetail>(`/imports/${id}`),
upload: (file: File, accountId: string) => {
const formData = new FormData()
formData.append('file', file)
formData.append('accountId', accountId)
return apiClient.post<ImportSummary>('/imports', { formData })
},
saveRowDecision: (id: string, rowId: string, payload: SaveRowDecisionPayload) =>
apiClient.patch<ImportDetail>(`/imports/${id}/rows/${rowId}`, { json: payload }),
confirm: (id: string, payload: ConfirmImportPayload) =>
apiClient.post<ImportDetail>(`/imports/${id}/confirm`, { json: payload }),
reparse: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/reparse`),
revert: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/revert`),
},
analytics: {
categorySpend: (from: string, to: string) =>
apiClient.get<CategorySpendItem[]>('/analytics/category-spend', { query: { from, to } }),
planFact: (month: string) =>
apiClient.get<PlanFactReport>('/analytics/plan-fact', { query: { month } }),
cashFlow: (from: string, to: string) =>
apiClient.get<CashFlowMonth[]>('/analytics/cash-flow', { query: { from, to } }),
balances: () => apiClient.get<BalancesReport>('/analytics/balances'),
},
exports: {
request: () => apiClient.post<ExportInfo>('/exports'),
get: (id: string) => apiClient.get<ExportInfo>(`/exports/${id}`),
},
spaces: {
deletionStatus: () => apiClient.get<SpaceDeletionStatus>('/spaces/current/deletion'),
requestDeletion: (confirmationPhrase: string) =>
apiClient.post<SpaceDeletionStatus>('/spaces/current/deletion', {
json: { confirmationPhrase },
}),
cancelDeletion: () => apiClient.delete<SpaceDeletionStatus>('/spaces/current/deletion'),
},
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
import type { OperationFilters } from './types'
export const SPACE_ROOT = 'space'
export const SESSION_ROOT = 'session'
export const sessionInvalidationRoot = [SESSION_ROOT] as const
export const spaceInvalidationRoot = [SPACE_ROOT] as const
export const invalidationRoots = {
space: spaceInvalidationRoot,
accounts: [SPACE_ROOT, 'accounts'] as const,
operations: [SPACE_ROOT, 'operations'] as const,
budget: [SPACE_ROOT, 'budget'] as const,
imports: [SPACE_ROOT, 'imports'] as const,
analytics: [SPACE_ROOT, 'analytics'] as const,
deletion: [SPACE_ROOT, 'deletion'] as const,
}
export const queryKeys = {
me: () => [SESSION_ROOT, 'me'] as const,
accounts: () => [SPACE_ROOT, 'accounts'] as const,
operations: (filters: OperationFilters) => [SPACE_ROOT, 'operations', filters] as const,
budgetSummary: (month: string) => [SPACE_ROOT, 'budget', 'summary', month] as const,
budgetCategories: (month: string) => [SPACE_ROOT, 'budget', 'categories', month] as const,
imports: () => [SPACE_ROOT, 'imports'] as const,
import: (id: string) => [SPACE_ROOT, 'imports', id] as const,
analyticsCategorySpend: (from: string, to: string) =>
[SPACE_ROOT, 'analytics', 'category-spend', from, to] as const,
analyticsPlanFact: (month: string) => [SPACE_ROOT, 'analytics', 'plan-fact', month] as const,
analyticsCashFlow: (from: string, to: string) =>
[SPACE_ROOT, 'analytics', 'cash-flow', from, to] as const,
analyticsBalances: () => [SPACE_ROOT, 'analytics', 'balances'] as const,
spaceDeletion: () => [SPACE_ROOT, 'deletion'] as const,
}
+312
View File
@@ -0,0 +1,312 @@
export type Locale = 'ru' | 'en'
export interface User {
id: string
email: string
fullName: string
locale: Locale
timezone: string
}
export type AccountType = 'cash' | 'debit' | 'credit'
export type AccountStatus = 'active' | 'archived'
export interface Account {
id: string
name: string
type: AccountType
status: AccountStatus
bankCode: string | null
maskedNumber: string | null
openedOn: string
currency: 'RUB'
balanceKopecks: number
}
export interface CreateAccountPayload {
name: string
type: AccountType
openedOn: string
openingBalanceKopecks: number
bankCode?: string
maskedNumber?: string
}
export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund'
export type OperationStatus = 'confirmed' | 'voided'
export interface Operation {
id: string
kind: OperationKind
status: OperationStatus
amountKopecks: number
occurredOn: string
postedOn: string | null
accountId: string
counterpartyAccountId: string | null
categoryId: string | null
categoryName: string | null
categoryKey: string | null
payeeName: string | null
incomeSourceName: string | null
description: string | null
parentOperationId: string | null
version: number
createdAt: string
}
export type CreateOperationPayload =
| {
kind: 'expense'
accountId: string
amountKopecks: number
occurredOn: string
categoryId?: string
payeeName?: string
description?: string
}
| {
kind: 'income'
accountId: string
amountKopecks: number
occurredOn: string
incomeSourceName?: string
description?: string
}
| {
kind: 'transfer'
fromAccountId: string
toAccountId: string
amountKopecks: number
occurredOn: string
description?: string
}
| {
kind: 'refund'
accountId: string
amountKopecks: number
occurredOn: string
parentOperationId?: string
description?: string
}
export interface OperationFilters {
cursor?: string
limit?: number
search?: string
accountId?: string
kind?: OperationKind
categoryId?: string
status?: OperationStatus
uncategorized?: boolean
occurredFrom?: string
occurredTo?: string
}
export interface Page<T> {
items: T[]
nextCursor: string | null
}
export interface LoginPayload {
email: string
password: string
}
export interface AcceptInvitePayload {
token: string
fullName: string
password: string
}
export interface PasswordResetPayload {
token: string
password: string
}
export type BudgetStatus = 'open' | 'closed'
export interface BudgetWarning {
code: string
message?: string
}
export interface BudgetBlocker {
code: string
message?: string
}
export type ExpectedIncomeStatus = 'expected' | 'partially_matched' | 'matched'
export interface ExpectedIncome {
id: string
expectedOn: string
amountKopecks: number
sourceName: string | null
matchedKopecks: number
status: ExpectedIncomeStatus
}
export interface BudgetSummary {
month: string
status: BudgetStatus
projectedPoolKopecks: number
actualPoolKopecks: number
plannedTotalKopecks: number
fundedTotalKopecks: number
spentTotalKopecks: number
availableKopecks: number
warnings: BudgetWarning[]
blockers: BudgetBlocker[]
expectedIncomes: ExpectedIncome[]
}
export type CategoryKind = 'monthly' | 'savings_envelope' | 'credit_reserve'
export interface BudgetCategory {
id: string
name: string | null
nameKey: string | null
kind: CategoryKind
archived: boolean
creditAccountId: string | null
plannedKopecks: number
fundedKopecks: number
spentKopecks: number
availableKopecks: number
}
export type ImportStatus =
| 'uploaded'
| 'queued'
| 'processing'
| 'review_required'
| 'ready'
| 'confirmed'
| 'failed'
export interface ImportSummary {
id: string
fileName: string
bankAdapter: string
status: ImportStatus
accountId: string | null
accountName: string | null
periodFrom: string | null
periodTo: string | null
createdAt: string
}
export interface ImportIssue {
code: string
message: string | null
rowId: string | null
}
export type ReconciliationStatus = 'matched' | 'mismatched' | 'not_available' | 'confirmed_without'
export interface Reconciliation {
status: ReconciliationStatus
openingBalanceKopecks: number | null
closingBalanceKopecks: number | null
computedClosingKopecks: number | null
reason: string | null
}
export type RowDecision = 'pending' | 'create' | 'discard' | 'duplicate' | 'transfer'
export interface ImportRow {
id: string
externalId: string
occurredOn: string
amountKopecks: number
direction: 'in' | 'out'
description: string | null
payeeHint: string | null
warnings: string[]
decision: RowDecision
duplicateOfOperationId: string | null
}
export interface ImportDetail extends ImportSummary {
failureReason: string | null
blockers: ImportIssue[]
warnings: ImportIssue[]
reconciliation: Reconciliation
rows: ImportRow[]
totals: {
total: number
create: number
discard: number
duplicate: number
transfer: number
}
}
export interface SaveRowDecisionPayload {
decision: Exclude<RowDecision, 'pending'>
duplicateOfOperationId?: string
}
export interface ConfirmImportPayload {
confirmedWithoutFullReconciliation?: boolean
reason?: string
}
export interface CategorySpendItem {
categoryId: string | null
name: string | null
nameKey: string | null
amountKopecks: number
}
export interface PlanFactItem {
categoryId: string
name: string | null
nameKey: string | null
plannedKopecks: number
actualKopecks: number
}
export interface PlanFactReport {
month: string
items: PlanFactItem[]
}
export interface CashFlowMonth {
month: string
incomeKopecks: number
expenseKopecks: number
refundsKopecks: number
netKopecks: number
}
export interface BalanceItem {
accountId: string
name: string
type: AccountType
balanceKopecks: number
}
export interface BalancesReport {
asOf: string
assetsKopecks: number
liabilitiesKopecks: number
netKopecks: number
items: BalanceItem[]
}
export type ExportStatus = 'queued' | 'processing' | 'ready' | 'failed'
export interface ExportInfo {
id: string
status: ExportStatus
downloadUrl: string | null
createdAt: string
}
export interface SpaceDeletionStatus {
status: 'active' | 'pending_deletion'
scheduledAt: string | null
cancelsUntil: string | null
}
+91
View File
@@ -0,0 +1,91 @@
import { lazy, Suspense } from 'react'
import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'
import { Spinner } from '@/components/ui/Spinner'
import { AppLayout } from '@/components/layout/AppLayout'
import { useMe } from '@/hooks/useAuth'
const AcceptInvitePage = lazy(() =>
import('@/pages/AcceptInvitePage').then((module) => ({ default: module.AcceptInvitePage })),
)
const AccountsPage = lazy(() =>
import('@/pages/AccountsPage').then((module) => ({ default: module.AccountsPage })),
)
const AnalyticsPage = lazy(() =>
import('@/pages/AnalyticsPage').then((module) => ({ default: module.AnalyticsPage })),
)
const BudgetPage = lazy(() =>
import('@/pages/BudgetPage').then((module) => ({ default: module.BudgetPage })),
)
const ImportDetailPage = lazy(() =>
import('@/pages/ImportDetailPage').then((module) => ({ default: module.ImportDetailPage })),
)
const ImportsPage = lazy(() =>
import('@/pages/ImportsPage').then((module) => ({ default: module.ImportsPage })),
)
const LoginPage = lazy(() =>
import('@/pages/LoginPage').then((module) => ({ default: module.LoginPage })),
)
const NotFoundPage = lazy(() =>
import('@/pages/NotFoundPage').then((module) => ({ default: module.NotFoundPage })),
)
const OperationsPage = lazy(() =>
import('@/pages/OperationsPage').then((module) => ({ default: module.OperationsPage })),
)
const ReviewQueuePage = lazy(() =>
import('@/pages/ReviewQueuePage').then((module) => ({ default: module.ReviewQueuePage })),
)
const ResetPasswordPage = lazy(() =>
import('@/pages/ResetPasswordPage').then((module) => ({ default: module.ResetPasswordPage })),
)
const SettingsPage = lazy(() =>
import('@/pages/SettingsPage').then((module) => ({ default: module.SettingsPage })),
)
function RequireAuth() {
const me = useMe()
const location = useLocation()
if (me.isPending) {
return (
<div className="center-page">
<Spinner />
</div>
)
}
if (me.isError) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />
}
return <Outlet />
}
export function AppRoutes() {
return (
<Suspense
fallback={
<div className="center-page" aria-busy="true">
<Spinner />
</div>
}
>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/invite/accept" element={<AcceptInvitePage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<AppLayout />}>
<Route index element={<Navigate to="/accounts" replace />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="budget" element={<BudgetPage />} />
<Route path="operations" element={<OperationsPage />} />
<Route path="review" element={<ReviewQueuePage />} />
<Route path="imports" element={<ImportsPage />} />
<Route path="imports/:id" element={<ImportDetailPage />} />
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
)
}
@@ -0,0 +1,113 @@
import { useTranslation } from 'react-i18next'
export interface CashFlowColumn {
key: string
label: string
income: number
expense: number
}
interface ColumnChartProps {
caption: string
columns: CashFlowColumn[]
formatValue: (value: number) => string
legendIncome: string
legendExpense: string
extraColumns?: { label: string; render: (column: CashFlowColumn) => string }
}
const BAR_AREA = 120
const BASELINE_Y = 150
const COLUMN_STEP = 68
export function ColumnChart({
caption,
columns,
formatValue,
legendIncome,
legendExpense,
extraColumns,
}: ColumnChartProps) {
const { t } = useTranslation()
const max = Math.max(...columns.flatMap((c) => [c.income, c.expense]), 1)
const width = Math.max(columns.length * COLUMN_STEP, COLUMN_STEP)
return (
<figure className="chart-figure">
<div className="chart-legend" aria-hidden="true">
<span className="legend-dot dot-accent" /> {legendIncome}
<span className="legend-dot dot-danger" /> {legendExpense}
</div>
<svg
className="column-chart"
viewBox={`0 0 ${width} ${BASELINE_Y + 30}`}
role="presentation"
aria-hidden="true"
focusable="false"
>
<line x1="0" y1={BASELINE_Y} x2={width} y2={BASELINE_Y} className="chart-baseline" />
{columns.map((column, index) => {
const incomeHeight = Math.round((column.income / max) * BAR_AREA)
const expenseHeight = Math.round((column.expense / max) * BAR_AREA)
const x = index * COLUMN_STEP
return (
<g key={column.key}>
<rect
x={x + 8}
y={BASELINE_Y - incomeHeight}
width="22"
height={incomeHeight}
rx="3"
className="bar-income"
/>
<rect
x={x + 36}
y={BASELINE_Y - expenseHeight}
width="22"
height={expenseHeight}
rx="3"
className="bar-expense"
/>
<text x={x + 33} y={BASELINE_Y + 20} textAnchor="middle" className="chart-label">
{column.label}
</text>
</g>
)
})}
</svg>
<figcaption className="visually-hidden">{caption}</figcaption>
<details className="table-toggle">
<summary>{t('common.showTable')}</summary>
<table className="mini-table">
<caption className="visually-hidden">{caption}</caption>
<thead>
<tr>
<th scope="col">{t('budget.month')}</th>
<th scope="col" className="num">
{legendIncome}
</th>
<th scope="col" className="num">
{legendExpense}
</th>
{extraColumns ? (
<th scope="col" className="num">
{extraColumns.label}
</th>
) : null}
</tr>
</thead>
<tbody>
{columns.map((column) => (
<tr key={column.key}>
<th scope="row">{column.label}</th>
<td className="num">{formatValue(column.income)}</td>
<td className="num">{formatValue(column.expense)}</td>
{extraColumns ? <td className="num">{extraColumns.render(column)}</td> : null}
</tr>
))}
</tbody>
</table>
</details>
</figure>
)
}
@@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next'
export interface BarRow {
key: string
label: string
value: number
}
interface HorizontalBarsProps {
caption: string
rows: BarRow[]
formatValue: (value: number) => string
color?: 'accent' | 'info' | 'warn'
}
export function HorizontalBars({ caption, rows, formatValue, color = 'accent' }: HorizontalBarsProps) {
const { t } = useTranslation()
const max = Math.max(...rows.map((row) => Math.abs(row.value)), 1)
return (
<figure className="chart-figure">
<div className="hbars" aria-hidden="true">
{rows.map((row) => (
<div className="hbar-row" key={row.key}>
<span className="hbar-label">{row.label}</span>
<span className="hbar-track">
<span
className={`hbar-fill fill-${color}`}
style={{ width: `${Math.max((Math.abs(row.value) / max) * 100, 1.5)}%` }}
/>
</span>
<span className="hbar-value num">{formatValue(row.value)}</span>
</div>
))}
</div>
<figcaption className="visually-hidden">{caption}</figcaption>
<details className="table-toggle">
<summary>{t('common.showTable')}</summary>
<table className="mini-table">
<caption className="visually-hidden">{caption}</caption>
<thead>
<tr>
<th scope="col">{t('common.name')}</th>
<th scope="col" className="num">
{t('common.amount')}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<th scope="row">{row.label}</th>
<td className="num">{formatValue(row.value)}</td>
</tr>
))}
</tbody>
</table>
</details>
</figure>
)
}
@@ -0,0 +1,80 @@
import { useTranslation } from 'react-i18next'
export interface PlanFactRow {
key: string
label: string
planned: number
actual: number
}
interface PlanFactBarsProps {
caption: string
rows: PlanFactRow[]
formatValue: (value: number) => string
legendPlanned: string
legendActual: string
}
export function PlanFactBars({
caption,
rows,
formatValue,
legendPlanned,
legendActual,
}: PlanFactBarsProps) {
const { t } = useTranslation()
const max = Math.max(...rows.flatMap((row) => [row.planned, row.actual]), 1)
return (
<figure className="chart-figure">
<div className="chart-legend" aria-hidden="true">
<span className="legend-dot dot-muted" /> {legendPlanned}
<span className="legend-dot dot-accent" /> {legendActual}
</div>
<div className="hbars" aria-hidden="true">
{rows.map((row) => (
<div className="hbar-row" key={row.key}>
<span className="hbar-label">{row.label}</span>
<span className="hbar-track hbar-track-stacked">
<span
className="hbar-fill fill-muted"
style={{ width: `${Math.max((row.planned / max) * 100, 0)}%` }}
/>
<span
className="hbar-fill fill-accent-overlay"
style={{ width: `${Math.max((row.actual / max) * 100, 0)}%` }}
/>
</span>
<span className="hbar-value num">{formatValue(row.actual)}</span>
</div>
))}
</div>
<figcaption className="visually-hidden">{caption}</figcaption>
<details className="table-toggle">
<summary>{t('common.showTable')}</summary>
<table className="mini-table">
<caption className="visually-hidden">{caption}</caption>
<thead>
<tr>
<th scope="col">{t('common.name')}</th>
<th scope="col" className="num">
{legendPlanned}
</th>
<th scope="col" className="num">
{legendActual}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key}>
<th scope="row">{row.label}</th>
<td className="num">{formatValue(row.planned)}</td>
<td className="num">{formatValue(row.actual)}</td>
</tr>
))}
</tbody>
</table>
</details>
</figure>
)
}
@@ -0,0 +1,67 @@
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/Button'
import { LangSwitcher } from '@/components/layout/LangSwitcher'
import { useLogout, useMe } from '@/hooks/useAuth'
const NAV_ITEMS = [
{ to: '/accounts', key: 'nav.accounts' },
{ to: '/budget', key: 'nav.budget' },
{ to: '/operations', key: 'nav.operations' },
{ to: '/review', key: 'nav.review' },
{ to: '/imports', key: 'nav.imports' },
{ to: '/analytics', key: 'nav.analytics' },
{ to: '/settings', key: 'nav.settings' },
] as const
export function AppLayout() {
const { t } = useTranslation()
const me = useMe()
const logout = useLogout()
const navigate = useNavigate()
return (
<div className="app-shell">
<a className="skip-link" href="#main">
{t('common.skipToContent')}
</a>
<header className="app-header">
<Link to="/accounts" className="brand">
<span className="brand-mark" aria-hidden="true" />
{t('app.brand')}
</Link>
<div className="header-side">
<LangSwitcher />
{me.data ? (
<span className="user-chip" title={me.data.email}>
{me.data.fullName}
</span>
) : null}
<Button
variant="ghost"
onClick={() =>
logout.mutate(undefined, {
onSettled: () => navigate('/login', { replace: true }),
})
}
>
{t('auth.logout')}
</Button>
</div>
</header>
<nav className="app-nav" aria-label={t('nav.label')}>
{NAV_ITEMS.map((item) => (
<NavLink key={item.to} to={item.to} className="nav-pill">
{t(item.key)}
</NavLink>
))}
</nav>
<main id="main" className="app-main">
<Outlet />
</main>
<footer className="app-footer">
<span>{t('app.footer')}</span>
</footer>
</div>
)
}
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next'
import { LOCALES, setLocale } from '@/i18n'
import { currentLocale } from '@/lib/locale'
export function LangSwitcher() {
const { t, i18n } = useTranslation()
const active = currentLocale(i18n.language)
return (
<div className="lang-switch" role="group" aria-label={t('lang.label')}>
{LOCALES.map((locale) => (
<button
key={locale}
type="button"
className={`btn btn-ghost btn-small${locale === active ? ' is-active' : ''}`}
aria-pressed={locale === active}
onClick={() => setLocale(locale)}
>
{locale.toUpperCase()}
</button>
))}
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from 'react'
export type BadgeTone = 'neutral' | 'accent' | 'warn' | 'danger' | 'info'
export function Badge({ tone = 'neutral', children }: { tone?: BadgeTone; children: ReactNode }) {
return <span className={`badge badge-${tone}`}>{children}</span>
}
+17
View File
@@ -0,0 +1,17 @@
import type { ButtonHTMLAttributes } from 'react'
type Variant = 'primary' | 'ghost' | 'danger' | 'subtle'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
}
export function Button({ variant = 'primary', className, type, ...rest }: ButtonProps) {
return (
<button
type={type ?? 'button'}
className={['btn', `btn-${variant}`, className].filter(Boolean).join(' ')}
{...rest}
/>
)
}
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import { Button } from './Button'
import { Modal } from './Modal'
interface ConfirmDialogProps {
open: boolean
title: string
body?: string
confirmLabel?: string
danger?: boolean
onConfirm: () => void
onClose: () => void
}
export function ConfirmDialog({
open,
title,
body,
confirmLabel,
danger,
onConfirm,
onClose,
}: ConfirmDialogProps) {
const { t } = useTranslation()
return (
<Modal open={open} onClose={onClose} title={title}>
{body ? <p className="confirm-body">{body}</p> : null}
<div className="modal-actions">
<Button variant="subtle" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant={danger ? 'danger' : 'primary'}
onClick={() => {
onConfirm()
onClose()
}}
>
{confirmLabel ?? t('common.confirm')}
</Button>
</div>
</Modal>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
interface EmptyStateProps {
title?: string
children?: ReactNode
}
export function EmptyState({ title, children }: EmptyStateProps) {
const { t } = useTranslation()
return (
<div className="empty-state">
<p className="empty-state-title">{title ?? t('common.empty')}</p>
{children}
</div>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
import { Button } from './Button'
interface ErrorStateProps {
error: unknown
onRetry?: () => void
}
export function ErrorState({ error, onRetry }: ErrorStateProps) {
const { t } = useTranslation()
return (
<div className="error-state" role="alert">
<p className="error-state-message">{describeApiError(error, t)}</p>
{onRetry ? (
<Button variant="subtle" onClick={onRetry}>
{t('common.retry')}
</Button>
) : null}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useId, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { ReactNode } from 'react'
interface ModalProps {
open: boolean
onClose: () => void
title: string
children: ReactNode
}
export function Modal({ open, onClose, title, children }: ModalProps) {
const { t } = useTranslation()
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
useEffect(() => {
const dialog = dialogRef.current
if (!dialog) return
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) dialog.close()
}, [open])
return (
<dialog
ref={dialogRef}
className="modal"
aria-labelledby={titleId}
onClose={onClose}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
>
<div className="modal-head">
<h2 className="modal-title" id={titleId}>
{title}
</h2>
<button
type="button"
className="btn btn-ghost btn-icon"
onClick={onClose}
aria-label={t('common.close')}
>
✕
</button>
</div>
<div className="modal-body">{children}</div>
</dialog>
)
}
@@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next'
import { formatMonth, shiftMonth } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
interface MonthSwitcherProps {
month: string
onChange: (month: string) => void
}
export function MonthSwitcher({ month, onChange }: MonthSwitcherProps) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
return (
<div className="month-switch" role="group" aria-label={t('budget.monthPicker')}>
<button
type="button"
className="btn btn-ghost btn-icon"
aria-label={t('budget.prevMonth')}
onClick={() => onChange(shiftMonth(month, -1))}
>
‹
</button>
<span className="month-label" aria-live="polite">
{formatMonth(month, locale)}
</span>
<button
type="button"
className="btn btn-ghost btn-icon"
aria-label={t('budget.nextMonth')}
onClick={() => onChange(shiftMonth(month, 1))}
>
›
</button>
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
subtitle?: string
actions?: ReactNode
}
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<header className="page-header">
<div>
<h1 className="page-title">{title}</h1>
{subtitle ? <p className="page-subtitle">{subtitle}</p> : null}
</div>
{actions ? <div className="page-actions">{actions}</div> : null}
</header>
)
}
+25
View File
@@ -0,0 +1,25 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { ReactNode } from 'react'
import { EmptyState } from './EmptyState'
import { ErrorState } from './ErrorState'
import { Spinner } from './Spinner'
interface QueryGateProps<T, E extends Error = Error> {
query: UseQueryResult<T, E>
empty?: ReactNode
children: (data: T) => ReactNode
}
export function QueryGate<T, E extends Error = Error>({
query,
empty,
children,
}: QueryGateProps<T, E>) {
if (query.isPending) return <Spinner />
if (query.isError) return <ErrorState error={query.error} onRetry={() => void query.refetch()} />
const isEmpty = Array.isArray(query.data) ? query.data.length === 0 : false
if (isEmpty && empty !== undefined) return <>{empty}</>
return <>{children(query.data)}</>
}
export { EmptyState }
+11
View File
@@ -0,0 +1,11 @@
import { useTranslation } from 'react-i18next'
export function Spinner({ label }: { label?: string }) {
const { t } = useTranslation()
return (
<div className="spinner-row" role="status">
<span className="spinner" aria-hidden="true" />
<span>{label ?? t('common.loading')}</span>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
type ToastKind = 'success' | 'error' | 'info'
interface ToastItem {
id: number
kind: ToastKind
message: string
}
interface ToastApi {
push: (kind: ToastKind, message: string) => void
}
const ToastContext = createContext<ToastApi | null>(null)
let nextToastId = 1
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([])
const push = useCallback((kind: ToastKind, message: string) => {
const id = nextToastId++
setToasts((current) => [...current, { id, kind, message }])
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id))
}, 5000)
}, [])
const api = useMemo<ToastApi>(() => ({ push }), [push])
return (
<ToastContext.Provider value={api}>
{children}
<div className="toasts" aria-live="polite">
{toasts.map((toast) => (
<div
key={toast.id}
className={`toast toast-${toast.kind}`}
role={toast.kind === 'error' ? 'alert' : 'status'}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
)
}
export function useToast(): ToastApi {
const context = useContext(ToastContext)
if (!context) throw new Error('useToast must be used within ToastProvider')
return context
}
+159
View File
@@ -0,0 +1,159 @@
import { useId } from 'react'
import type {
DetailedHTMLProps,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
} from 'react'
type InputProps = DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
type SelectProps = DetailedHTMLProps<SelectHTMLAttributes<HTMLSelectElement>, HTMLSelectElement>
type TextareaProps = DetailedHTMLProps<
TextareaHTMLAttributes<HTMLTextAreaElement>,
HTMLTextAreaElement
>
interface FieldChrome {
label: string
error?: string
hint?: string
}
function FieldChromeWrap({
label,
error,
hint,
inputId,
children,
}: FieldChrome & { inputId: string; children: ReactNode }) {
return (
<div className="field">
<label className="field-label" htmlFor={inputId}>
{label}
</label>
{hint ? (
<p className="field-hint" id={`${inputId}-hint`}>
{hint}
</p>
) : null}
{children}
{error ? (
<p className="field-error" id={`${inputId}-error`} role="alert">
{error}
</p>
) : null}
</div>
)
}
interface TextFieldProps extends InputProps {
label: string
error?: string
hint?: string
}
export function TextField({ label, error, hint, id, ...rest }: TextFieldProps) {
const autoId = useId()
const inputId = id ?? autoId
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined
return (
<FieldChromeWrap label={label} error={error} hint={hint} inputId={inputId}>
<input
{...rest}
id={inputId}
className="input"
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
/>
</FieldChromeWrap>
)
}
interface MoneyFieldProps extends InputProps {
label: string
error?: string
hint?: string
currencyLabel?: string
}
export function MoneyField({
label,
error,
hint,
id,
currencyLabel,
...rest
}: MoneyFieldProps) {
const autoId = useId()
const inputId = id ?? autoId
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined
return (
<FieldChromeWrap label={label} error={error} hint={hint} inputId={inputId}>
<div className="money-field">
<input
{...rest}
id={inputId}
className="input"
inputMode="decimal"
autoComplete="off"
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
/>
<span className="money-suffix" aria-hidden="true">
{currencyLabel ?? '₽'}
</span>
</div>
</FieldChromeWrap>
)
}
interface SelectFieldProps extends SelectProps {
label: string
error?: string
hint?: string
children: ReactNode
}
export function SelectField({ label, error, hint, id, children, ...rest }: SelectFieldProps) {
const autoId = useId()
const inputId = id ?? autoId
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined
return (
<FieldChromeWrap label={label} error={error} hint={hint} inputId={inputId}>
<select
{...rest}
id={inputId}
className="input select"
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
>
{children}
</select>
</FieldChromeWrap>
)
}
interface TextareaFieldProps extends TextareaProps {
label: string
error?: string
hint?: string
}
export function TextareaField({ label, error, hint, id, ...rest }: TextareaFieldProps) {
const autoId = useId()
const inputId = id ?? autoId
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined
return (
<FieldChromeWrap label={label} error={error} hint={hint} inputId={inputId}>
<textarea
{...rest}
id={inputId}
className="input"
rows={3}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
/>
</FieldChromeWrap>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type { CreateAccountPayload } from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
export function useAccounts() {
return useQuery({ queryKey: queryKeys.accounts(), queryFn: api.accounts.list })
}
export function useActiveAccounts() {
const accounts = useAccounts()
return { ...accounts, data: accounts.data?.filter((account) => account.status === 'active') }
}
export function useCreateAccount() {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: (payload: CreateAccountPayload) => api.accounts.create(payload),
onSuccess: (account) => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
toast.push('success', t('toast.accountCreated', { name: account.name }))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useArchiveAccount() {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: (id: string) => api.accounts.archive(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
toast.push('success', t('toast.accountArchived'))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
+31
View File
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { queryKeys } from '@/api/queryKeys'
export function useAnalyticsCategorySpend(from: string, to: string) {
return useQuery({
queryKey: queryKeys.analyticsCategorySpend(from, to),
queryFn: () => api.analytics.categorySpend(from, to),
})
}
export function useAnalyticsPlanFact(month: string) {
return useQuery({
queryKey: queryKeys.analyticsPlanFact(month),
queryFn: () => api.analytics.planFact(month),
})
}
export function useAnalyticsCashFlow(from: string, to: string) {
return useQuery({
queryKey: queryKeys.analyticsCashFlow(from, to),
queryFn: () => api.analytics.cashFlow(from, to),
})
}
export function useAnalyticsBalances() {
return useQuery({
queryKey: queryKeys.analyticsBalances(),
queryFn: api.analytics.balances,
})
}
+50
View File
@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { queryKeys } from '@/api/queryKeys'
import type { AcceptInvitePayload, LoginPayload, PasswordResetPayload, User } from '@/api/types'
export function useMe() {
return useQuery({
queryKey: queryKeys.me(),
queryFn: api.auth.me,
retry: false,
staleTime: 60_000,
})
}
function useSessionMutation<P>(mutationFn: (payload: P) => Promise<User>) {
const queryClient = useQueryClient()
return useMutation({
mutationFn,
onSuccess: (user) => {
queryClient.clear()
queryClient.setQueryData(queryKeys.me(), user)
},
})
}
export function useLogin() {
return useSessionMutation<LoginPayload>((payload) => api.auth.login(payload))
}
export function useAcceptInvite() {
return useSessionMutation<AcceptInvitePayload>((payload) => api.auth.acceptInvite(payload))
}
export function useRequestPasswordReset() {
return useMutation({ mutationFn: (email: string) => api.auth.requestPasswordReset(email) })
}
export function useConfirmPasswordReset() {
return useSessionMutation<PasswordResetPayload>((payload) => api.auth.confirmPasswordReset(payload))
}
export function useLogout() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: () => api.auth.logout(),
onSettled: () => {
queryClient.clear()
},
})
}
+87
View File
@@ -0,0 +1,87 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
export function useBudgetSummary(month: string) {
return useQuery({ queryKey: queryKeys.budgetSummary(month), queryFn: () => api.budget.summary(month) })
}
export function useBudgetCategories(month: string) {
return useQuery({
queryKey: queryKeys.budgetCategories(month),
queryFn: () => api.budget.categories(month),
})
}
function useBudgetErrorToaster() {
const { t } = useTranslation()
const toast = useToast()
return (error: unknown) => toast.push('error', describeApiError(error, t))
}
export function useUpdatePlan(month: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
const onError = useBudgetErrorToaster()
return useMutation({
mutationFn: ({ categoryId, plannedKopecks }: { categoryId: string; plannedKopecks: number }) =>
api.budget.updatePlan(categoryId, month, plannedKopecks),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
toast.push('success', t('toast.planUpdated'))
},
onError,
})
}
export function useFundCategory(month: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
const onError = useBudgetErrorToaster()
return useMutation({
mutationFn: ({ categoryId, amountKopecks }: { categoryId: string; amountKopecks: number }) =>
api.budget.fund(categoryId, month, amountKopecks),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
toast.push('success', t('toast.categoryFunded'))
},
onError,
})
}
function usePeriodMutation(
mutationFn: (month: string) => Promise<unknown>,
successKey: string,
) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
const onError = useBudgetErrorToaster()
return useMutation({
mutationFn,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.space })
toast.push('success', t(successKey))
},
onError,
})
}
export function useCloseMonth() {
return usePeriodMutation((month) => api.budget.close(month), 'toast.monthClosed')
}
export function useReopenMonth() {
return usePeriodMutation((month) => api.budget.reopen(month), 'toast.monthReopened')
}
export function useCopyPreviousMonth() {
return usePeriodMutation((month) => api.budget.copyPrevious(month), 'toast.planCopied')
}
+114
View File
@@ -0,0 +1,114 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type {
ConfirmImportPayload,
ImportStatus,
SaveRowDecisionPayload,
} from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
import { importListPollInterval, importPollInterval } from '@/lib/importStatus'
export function useImports() {
return useQuery({
queryKey: queryKeys.imports(),
queryFn: api.imports.list,
refetchInterval: (query) => {
const statuses = query.state.data?.map((item) => item.status as ImportStatus)
return importListPollInterval(statuses)
},
})
}
export function useImport(id: string) {
return useQuery({
queryKey: queryKeys.import(id),
queryFn: () => api.imports.get(id),
refetchInterval: (query) => importPollInterval(query.state.data?.status),
refetchIntervalInBackground: false,
})
}
function useImportErrorToaster() {
const { t } = useTranslation()
const toast = useToast()
return (error: unknown) => toast.push('error', describeApiError(error, t))
}
export function useUploadImport() {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
const onError = useImportErrorToaster()
return useMutation({
mutationFn: ({ file, accountId }: { file: File; accountId: string }) =>
api.imports.upload(file, accountId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.imports })
toast.push('success', t('toast.importUploaded'))
},
onError,
})
}
export function useSaveRowDecision(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: ({ rowId, payload }: { rowId: string; payload: SaveRowDecisionPayload }) =>
api.imports.saveRowDecision(importId, rowId, payload),
onSuccess: (updated) => {
queryClient.setQueryData(queryKeys.import(importId), updated)
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useConfirmImport(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
const onError = useImportErrorToaster()
return useMutation({
mutationFn: (payload: ConfirmImportPayload) => api.imports.confirm(importId, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.space })
toast.push('success', t('toast.importConfirmed'))
},
onError,
})
}
export function useReparseImport(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
const onError = useImportErrorToaster()
return useMutation({
mutationFn: () => api.imports.reparse(importId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.import(importId) })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.imports })
toast.push('info', t('toast.reparseQueued'))
},
onError,
})
}
export function useRevertImport(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
const onError = useImportErrorToaster()
return useMutation({
mutationFn: () => api.imports.revert(importId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.space })
toast.push('success', t('toast.importReverted'))
},
onError,
})
}
+57
View File
@@ -0,0 +1,57 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type { CreateOperationPayload, OperationFilters } from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
export function useOperations(filters: OperationFilters) {
return useQuery({
queryKey: queryKeys.operations(filters),
queryFn: () => api.operations.list(filters),
placeholderData: keepPreviousData,
})
}
function useLedgerMutation<TVariables>(
mutationFn: (variables: TVariables) => Promise<unknown>,
successKey: string,
) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
toast.push('success', t(successKey))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useCreateOperation() {
return useLedgerMutation(
(payload: CreateOperationPayload) => api.operations.create(payload),
'toast.operationCreated',
)
}
export function useVoidOperation() {
return useLedgerMutation(
({ id, reason }: { id: string; reason: string }) => api.operations.void(id, reason),
'toast.operationVoided',
)
}
export function useCategorizeOperation() {
return useLedgerMutation(
({ id, categoryId }: { id: string; categoryId: string }) =>
api.operations.categorize(id, categoryId),
'toast.categoryAssigned',
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
export function useSpaceDeletion() {
return useQuery({ queryKey: queryKeys.spaceDeletion(), queryFn: api.spaces.deletionStatus })
}
export function useRequestExport() {
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: async () => {
let info = await api.exports.request()
for (let attempt = 0; attempt < 30 && info.status !== 'ready' && info.status !== 'failed'; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 2_000))
info = await api.exports.get(info.id)
}
return info
},
onSuccess: (info) => {
if (info.downloadUrl) {
toast.push('success', t('toast.exportReady'))
window.location.assign(info.downloadUrl)
} else {
toast.push('info', t('toast.exportQueued'))
}
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useRequestDeletion() {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: (confirmationPhrase: string) => api.spaces.requestDeletion(confirmationPhrase),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.deletion })
toast.push('info', t('toast.deletionRequested'))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useCancelDeletion() {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: () => api.spaces.cancelDeletion(),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.deletion })
toast.push('success', t('toast.deletionCancelled'))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
+57
View File
@@ -0,0 +1,57 @@
import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './locales/en.json'
import ru from './locales/ru.json'
import type { Locale } from '@/api/types'
export const LOCALES: readonly Locale[] = ['ru', 'en']
const STORAGE_KEY = 'finance.locale'
function storedLocale(): Locale | undefined {
try {
const value = localStorage.getItem(STORAGE_KEY)
return value === 'ru' || value === 'en' ? value : undefined
} catch {
return undefined
}
}
function detectLocale(): Locale {
const stored = storedLocale()
if (stored) return stored
if (typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('ru')) {
return 'ru'
}
return 'en'
}
export function createI18n() {
const instance = i18next.createInstance()
void instance.use(initReactI18next).init({
resources: {
ru: { translation: ru },
en: { translation: en },
},
lng: detectLocale(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
returnNull: false,
})
return instance
}
export const i18n = createI18n()
export function setLocale(locale: Locale): void {
try {
localStorage.setItem(STORAGE_KEY, locale)
} catch {
return
}
void i18n.changeLanguage(locale)
}
export function localeLabel(locale: Locale): string {
return locale === 'ru' ? 'Русский' : 'English'
}
+374
View File
@@ -0,0 +1,374 @@
{
"app": {
"title": "Grossbook — finance tracker",
"brand": "Grossbook",
"footer": "Closed beta · RUB · zero-based budget"
},
"nav": {
"label": "Primary navigation",
"accounts": "Accounts",
"budget": "Budget",
"operations": "Operations",
"review": "Review queue",
"imports": "Imports",
"analytics": "Analytics",
"settings": "Settings & data"
},
"lang": {
"label": "Language"
},
"common": {
"actions": "Actions",
"amount": "Amount",
"cancel": "Cancel",
"choose": "— choose —",
"close": "Close",
"confirm": "Confirm",
"create": "Create",
"dash": "—",
"date": "Date",
"empty": "Nothing here yet",
"loading": "Loading…",
"name": "Name",
"requiredField": "Required field",
"retry": "Retry",
"save": "Save",
"saving": "Saving…",
"showTable": "Show table",
"skipToContent": "Skip to content",
"status": "Status"
},
"errors": {
"generic": "Something went wrong. Please try again.",
"http": "Server error ({{status}}).",
"badAmount": "Enter an amount in rubles, greater than zero, with at most two decimal places.",
"network_error": "Cannot reach the server. Check your connection.",
"auth_invalid_credentials": "Wrong email or password.",
"unauthorized": "Your session has expired. Please sign in again.",
"forbidden": "You do not have permission for this action.",
"not_found": "Record not found.",
"validation_error": "Please check the form fields.",
"conflict": "Data was changed elsewhere. Refresh and try again.",
"rate_limited": "Too many attempts. Please wait a moment.",
"session_expired": "Your session has expired. Please sign in again."
},
"auth": {
"loginTitle": "Sign in",
"loginSubtitle": "Sign in to open your financial space.",
"email": "Email",
"password": "Password",
"passwordConfirm": "Confirm password",
"passwordHint": "At least 12 characters",
"passwordMismatch": "Passwords do not match",
"invalidEmail": "Enter a valid email",
"submit": "Sign in",
"loggingIn": "Signing in…",
"logout": "Sign out",
"haveInvite": "Have an invite? Accept it",
"inviteTitle": "Accept invitation",
"inviteSubtitle": "Set your name and password to create your financial space.",
"inviteTokenMissing": "This link has no invitation token. Request a new link.",
"accept": "Accept and create space",
"accepting": "Creating…",
"backToLogin": "Back to sign in",
"fullName": "Name",
"forgotPassword": "Forgot password?",
"resetTitle": "Reset password",
"resetSubtitle": "Request a one-time reset link by email.",
"resetSend": "Send reset link",
"resetSent": "If the account exists, a reset link has been sent.",
"resetSubmit": "Set new password",
"resetTokenMissing": "Request a new reset link."
},
"accounts": {
"title": "Accounts",
"subtitle": "Debit, cash and credit accounts of the space.",
"add": "Add account",
"empty": "No accounts yet. Create your first account to start tracking.",
"formTitle": "New account",
"type": "Type",
"type.cash": "Cash",
"type.debit": "Debit",
"type.credit": "Credit card",
"bank": "Bank",
"bankHint": "Optional, e.g. “alfa”",
"maskedNumber": "Masked number",
"openedOn": "Opened on",
"openingBalance": "Opening position",
"openingBalanceHint": "Money on the account at the start of tracking",
"balance": "Balance",
"assets": "Assets",
"liabilities": "Card debt",
"netWorth": "Net worth",
"status.active": "Active",
"status.archived": "Archived",
"archive": "Archive",
"archiveBlockedHint": "Only an account with zero balance can be archived",
"archiveConfirmTitle": "Archive account",
"archiveConfirmBody": "Account “{{name}}” will leave working lists but remain in history."
},
"operations": {
"title": "Operations",
"subtitle": "Expenses, income, transfers and refunds.",
"new": "New operation",
"empty": "No operations match the selected filters.",
"search": "Search",
"filterAccount": "Account",
"filterKind": "Type",
"filterFrom": "From date",
"filterTo": "To date",
"filterAll": "All",
"uncategorizedOnly": "Uncategorized only",
"kindLabel": "Type",
"kind.expense": "Expense",
"kind.income": "Income",
"kind.transfer": "Transfer",
"kind.refund": "Refund",
"status.confirmed": "Confirmed",
"status.voided": "Voided",
"noCategory": "Uncategorized",
"category": "Category",
"payee": "Payee",
"incomeSource": "Income source",
"incomeSourceHint": "Optional",
"description": "Description",
"account": "Account",
"fromAccount": "From account",
"toAccount": "To account",
"refundAccount": "Refund account",
"parentOperation": "Original purchase",
"parentOperationHint": "Optional; link the refund to the original expense",
"sameAccount": "Source and target accounts must differ",
"occurredOn": "Operation date",
"void": "Void",
"voidTitle": "Void operation",
"voidBody": "Operation of {{date}} for {{amount}} will be marked voided.",
"voidReason": "Void reason",
"voidReasonHint": "At least 3 characters",
"prevPage": "Back",
"nextPage": "Next"
},
"budget": {
"title": "Budget",
"subtitle": "Month plan, funding and the general pool.",
"monthPicker": "Month picker",
"month": "Month",
"prevMonth": "Previous month",
"nextMonth": "Next month",
"status.open": "Open",
"status.closed": "Closed",
"projectedPool": "Projected pool",
"actualPool": "Actual pool",
"plannedTotal": "Planned",
"fundedTotal": "Funded",
"spentTotal": "Spent",
"available": "Available",
"planned": "Planned",
"funded": "Funded",
"spent": "Actual",
"remaining": "Left",
"categories": "Categories",
"reserves": "Credit reserves",
"noCategories": "No categories this month yet. Copy the previous month or create a plan.",
"category.kind.monthly": "Monthly",
"category.kind.savings_envelope": "Envelope",
"category.kind.credit_reserve": "Credit reserve",
"planTitle": "Plan: {{name}}",
"fundTitle": "Fund: {{name}}",
"fund": "Fund",
"fundHint": "Moves real money from the general pool into the category.",
"expectedIncome": "Expected income",
"incomeSource": "Source",
"noSource": "No source",
"matched": "Matched",
"income.status.expected": "Expected",
"income.status.partially_matched": "Partial",
"income.status.matched": "Received",
"blockersTitle": "Close is blocked",
"blockers.uncategorized_expenses": "There are expenses without a category.",
"blockers.negative_envelope": "A savings envelope balance is negative.",
"blockers.unresolved_imports": "There are unfinished imports.",
"blockers.open_decisions": "There are unresolved import decisions.",
"warnings.plan_exceeds_pool": "The plan exceeds the projected pool.",
"warnings.underfunded_credit": "Credit card debt is not fully funded.",
"warnings.expected_income_variance": "Expected income deviates from actual.",
"close": "Close month",
"closeConfirmTitle": "Close month",
"closeConfirmBody": "{{month}} will be closed and monthly category leftovers will roll into the general pool.",
"reopen": "Reopen",
"reopenConfirmTitle": "Reopen month",
"reopenConfirmBody": "This month and all following periods will be reopened and recalculated.",
"copyPrevious": "Copy previous month"
},
"review": {
"title": "Review queue",
"subtitle": "Problematic imports and uncategorized expenses.",
"empty": "The queue is empty — everything is resolved.",
"sectionImports": "Imports needing attention",
"sectionUncategorized": "Uncategorized expenses",
"uncategorizedEmpty": "No uncategorized expenses.",
"assignCategory": "Assign category",
"openImport": "Open import"
},
"imports": {
"title": "Statement imports",
"subtitle": "Upload and parse bank statements.",
"upload": "Upload statement",
"uploadTitle": "New import",
"uploading": "Uploading…",
"adapterHint": "Supported: Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF.",
"file": "Statement file",
"targetAccount": "Target account",
"unassigned": "Unassigned",
"empty": "No imports yet.",
"bank": "Bank / contract",
"period": "Period",
"createdAt": "Created",
"createdAtLine": "Created: {{createdAt}}",
"status.uploaded": "Uploaded",
"status.queued": "Queued",
"status.processing": "Processing",
"status.review_required": "Review required",
"status.ready": "Ready to apply",
"status.confirmed": "Applied",
"status.failed": "Failed",
"progress": "Parse progress",
"steps.uploaded": "Uploaded",
"steps.queued": "Queued",
"steps.processing": "Processing",
"steps.review_required": "Review",
"steps.ready": "Ready",
"steps.confirmed": "Applied",
"detailTitle": "Import",
"backToList": "Back to list",
"failedReason": "Parsing failed: {{reason}}",
"blockers": "Critical issues",
"blockers.currency_mismatch": "Statement currency does not match the account.",
"blockers.amount_mismatch": "Row amount does not match the source.",
"blockers.missing_occurred_date": "Operation date is missing.",
"blockers.missing_target_account": "Target account is not confirmed.",
"blockers.arithmetic_mismatch": "Control totals do not add up.",
"blockers.unresolved_duplicate": "There are unresolved duplicates.",
"blockers.unresolved_transfer": "There are unresolved transfers.",
"blockers.account_conflict": "Account fingerprint conflicts with an existing one.",
"warnings": "Warnings ({{count}})",
"warnings.amount_warning": "Unusual amount.",
"warnings.date_warning": "Unusual date.",
"warnings.payee_warning": "Payee is uncertain.",
"warnings.mcc_missing": "MCC is missing.",
"warnings.description_warning": "Description is incomplete.",
"reconciliationTitle": "Reconciliation",
"openingBalance": "Opening balance",
"closingBalance": "Closing balance",
"computedBalance": "Computed balance",
"reconciliation.matched": "Matched",
"reconciliation.mismatched": "Mismatched",
"reconciliation.not_available": "Not available",
"reconciliation.confirmed_without": "Confirmed without reconciliation",
"totals": "Rows: {{total}} · create: {{create}} · discard: {{discard}} · duplicates: {{duplicate}} · transfers: {{transfer}}",
"rowsTitle": "Statement rows",
"noRows": "No rows.",
"rowWarnings": "Notes",
"rowWarnings.amount": "amount",
"rowWarnings.date": "date",
"rowWarnings.direction": "direction",
"rowWarnings.mcc": "MCC",
"rowWarnings.description": "description",
"decision": "Decision",
"decision.pending": "Undecided",
"decision.create": "Create operation",
"decision.discard": "Discard",
"decision.duplicate": "Duplicate",
"decision.transfer": "Transfer",
"confirm": "Apply import",
"confirmTitle": "Apply import",
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
"confirmWithout": "I confirm applying without full balance reconciliation",
"confirmReason": "Reason",
"reparse": "Reparse",
"revert": "Revert import",
"revertTitle": "Revert import",
"revertBody": "Operations created by the “{{fileName}}” import will be removed atomically; affected months will require reopening."
},
"analytics": {
"title": "Analytics",
"subtitle": "Four fixed reports for your space.",
"range": "Period:",
"months_one": "{{count}} month",
"months_other": "{{count}} months",
"noData": "No data for the period.",
"categorySpend": "Spending by category",
"categorySpendTable": "Spending by category for the period",
"planFact": "Plan vs fact",
"planFactTable": "Plan vs fact by category for the month",
"cashFlow": "Income and expenses",
"cashFlowTable": "Income, expenses and net by month",
"balances": "Balances and debt",
"balancesTable": "Balances by account",
"income": "Income",
"expense": "Expenses",
"net": "Net",
"assets": "Assets",
"liabilities": "Liabilities",
"asOf": "As of {{asOf}}",
"unallocated": "Unallocated"
},
"settings": {
"title": "Settings & data",
"subtitle": "Profile, export and space data controls.",
"profile": "Profile",
"timezone": "Time zone",
"dataTitle": "Data",
"export": "Request export",
"exportHint": "A ZIP archive (CSV + JSON) with accounts, operations, budgets and dictionaries is prepared in the background; the link appears in the app.",
"deletionTitle": "Space deletion",
"deletionHint": "The space is locked immediately and deleted after 30 days. You can cancel until then.",
"deletionPhrase": "Confirmation phrase",
"deletionPhraseHint": "Type DELETE in capitals",
"phraseMismatch": "Type exactly DELETE",
"deletionRequest": "Request deletion",
"deletionPending": "The space awaits deletion until {{cancelsUntil}}. You can still cancel.",
"deletionCancel": "Cancel deletion",
"deletionStatusActive": "Active"
},
"toast": {
"accountCreated": "Account “{{name}}” created",
"accountArchived": "Account archived",
"operationCreated": "Operation created",
"operationVoided": "Operation voided",
"categoryAssigned": "Category assigned",
"planUpdated": "Plan updated",
"categoryFunded": "Category funded",
"monthClosed": "Month closed",
"monthReopened": "Month reopened",
"planCopied": "Previous month plan copied",
"importUploaded": "Statement uploaded, parsing started",
"importConfirmed": "Import applied",
"importReverted": "Import reverted",
"reparseQueued": "Reparse queued",
"exportQueued": "Export queued",
"exportReady": "Export is ready to download",
"deletionRequested": "Deletion requested: 30 days to cancel",
"deletionCancelled": "Deletion cancelled"
},
"notFound": {
"title": "Page not found",
"subtitle": "This page does not exist. Check the address or go back to accounts.",
"back": "To accounts"
},
"categories": {
"groceries": "Groceries",
"transport": "Transport",
"housing": "Housing",
"utilities": "Utilities",
"health": "Health",
"education": "Education",
"entertainment": "Entertainment",
"eating_out": "Eating out",
"clothes": "Clothes",
"personal": "Personal",
"savings": "Savings",
"debts": "Debt payments",
"other": "Other"
}
}
+376
View File
@@ -0,0 +1,376 @@
{
"app": {
"title": "Гроссбух — финансовый трекер",
"brand": "Гроссбух",
"footer": "Закрытая бета · RUB · zero-based бюджет"
},
"nav": {
"label": "Основная навигация",
"accounts": "Счета",
"budget": "Бюджет",
"operations": "Операции",
"review": "Очередь разбора",
"imports": "Импорт",
"analytics": "Аналитика",
"settings": "Настройки и данные"
},
"lang": {
"label": "Язык"
},
"common": {
"actions": "Действия",
"amount": "Сумма",
"cancel": "Отмена",
"choose": "— выберите —",
"close": "Закрыть",
"confirm": "Подтвердить",
"create": "Создать",
"dash": "—",
"date": "Дата",
"empty": "Пока ничего нет",
"loading": "Загрузка…",
"name": "Название",
"requiredField": "Обязательное поле",
"retry": "Повторить",
"save": "Сохранить",
"saving": "Сохранение…",
"showTable": "Показать таблицу",
"skipToContent": "Перейти к содержимому",
"status": "Статус"
},
"errors": {
"generic": "Что-то пошло не так. Попробуйте ещё раз.",
"http": "Ошибка сервера ({{status}}).",
"badAmount": "Введите сумму в рублях, больше нуля, не более двух знаков после запятой.",
"network_error": "Нет связи с сервером. Проверьте соединение.",
"auth_invalid_credentials": "Неверный email или пароль.",
"unauthorized": "Сессия истекла. Войдите снова.",
"forbidden": "Недостаточно прав для этого действия.",
"not_found": "Запись не найдена.",
"validation_error": "Проверьте правильность заполнения полей.",
"conflict": "Данные изменены кем-то другим. Обновите страницу и повторите.",
"rate_limited": "Слишком много попыток. Подождите немного.",
"session_expired": "Сессия истекла. Войдите снова."
},
"auth": {
"loginTitle": "Вход",
"loginSubtitle": "Войдите, чтобы открыть своё финансовое пространство.",
"email": "Email",
"password": "Пароль",
"passwordConfirm": "Повторите пароль",
"passwordHint": "Минимум 12 символов",
"passwordMismatch": "Пароли не совпадают",
"invalidEmail": "Введите корректный email",
"submit": "Войти",
"loggingIn": "Вход…",
"logout": "Выйти",
"haveInvite": "Есть приглашение? Принять его",
"inviteTitle": "Принятие приглашения",
"inviteSubtitle": "Задайте имя и пароль, чтобы создать своё финансовое пространство.",
"inviteTokenMissing": "Ссылка не содержит токен приглашения. Запросите новую ссылку.",
"accept": "Принять и создать пространство",
"accepting": "Создаём…",
"backToLogin": "Вернуться ко входу",
"fullName": "Имя",
"forgotPassword": "Забыли пароль?",
"resetTitle": "Сброс пароля",
"resetSubtitle": "Запросите одноразовую ссылку на email.",
"resetSend": "Отправить ссылку",
"resetSent": "Если аккаунт существует, ссылка для сброса отправлена.",
"resetSubmit": "Установить новый пароль",
"resetTokenMissing": "Запросите новую ссылку для сброса."
},
"accounts": {
"title": "Счета",
"subtitle": "Дебетовые, наличные и кредитные счета пространства.",
"add": "Добавить счёт",
"empty": "Счетов пока нет. Создайте первый счёт, чтобы начать учёт.",
"formTitle": "Новый счёт",
"type": "Тип",
"type.cash": "Наличные",
"type.debit": "Дебетовый",
"type.credit": "Кредитная карта",
"bank": "Банк",
"bankHint": "Необязательно, например «alfa»",
"maskedNumber": "Маскированный номер",
"openedOn": "Дата открытия",
"openingBalance": "Начальная позиция",
"openingBalanceHint": "Сколько денег на счёте на момент начала учёта",
"balance": "Остаток",
"assets": "Активы",
"liabilities": "Долг по кредиткам",
"netWorth": "Итого",
"status.active": "Активен",
"status.archived": "В архиве",
"archive": "В архив",
"archiveBlockedHint": "Архивировать можно только счёт с нулевым остатком",
"archiveConfirmTitle": "Архивировать счёт",
"archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории."
},
"operations": {
"title": "Операции",
"subtitle": "Расходы, доходы, переводы и возвраты.",
"new": "Новая операция",
"empty": "Операций по выбранным условиям не найдено.",
"search": "Поиск",
"filterAccount": "Счёт",
"filterKind": "Тип",
"filterFrom": "Дата с",
"filterTo": "Дата по",
"filterAll": "Все",
"uncategorizedOnly": "Только без категории",
"kindLabel": "Тип",
"kind.expense": "Расход",
"kind.income": "Доход",
"kind.transfer": "Перевод",
"kind.refund": "Возврат",
"status.confirmed": "Подтверждена",
"status.voided": "Отменена",
"noCategory": "Без категории",
"category": "Категория",
"payee": "Получатель",
"incomeSource": "Источник дохода",
"incomeSourceHint": "Необязательно",
"description": "Описание",
"account": "Счёт",
"fromAccount": "Счёт списания",
"toAccount": "Счёт зачисления",
"refundAccount": "Счёт возврата",
"parentOperation": "Исходная покупка",
"parentOperationHint": "Необязательно; свяжите возврат с исходным расходом",
"sameAccount": "Счета списания и зачисления должны различаться",
"occurredOn": "Дата операции",
"void": "Отменить",
"voidTitle": "Отмена операции",
"voidBody": "Операция от {{date}} на {{amount}} будет помечена отменённой.",
"voidReason": "Причина отмены",
"voidReasonHint": "Минимум 3 символа",
"prevPage": "Назад",
"nextPage": "Дальше"
},
"budget": {
"title": "Бюджет",
"subtitle": "План месяца, обеспечение и общий пул.",
"monthPicker": "Выбор месяца",
"month": "Месяц",
"prevMonth": "Предыдущий месяц",
"nextMonth": "Следующий месяц",
"status.open": "Открыт",
"status.closed": "Закрыт",
"projectedPool": "Прогнозный пул",
"actualPool": "Фактический пул",
"plannedTotal": "Запланировано",
"fundedTotal": "Обеспечено",
"spentTotal": "Потрачено",
"available": "Свободно",
"planned": "План",
"funded": "Обеспечено",
"spent": "Факт",
"remaining": "Остаток",
"categories": "Категории",
"reserves": "Резервы кредиток",
"noCategories": "В этом месяце ещё нет категорий. Скопируйте прошлый месяц или создайте план.",
"category.kind.monthly": "Месячная",
"category.kind.savings_envelope": "Конверт",
"category.kind.credit_reserve": "Резерв кредитки",
"planTitle": "План: {{name}}",
"fundTitle": "Обеспечение: {{name}}",
"fund": "Обеспечить",
"fundHint": "Перемещает фактические деньги из общего пула в категорию.",
"expectedIncome": "Ожидаемые доходы",
"incomeSource": "Источник",
"noSource": "Без источника",
"matched": "Сопоставлено",
"income.status.expected": "Ожидается",
"income.status.partially_matched": "Частично",
"income.status.matched": "Поступил",
"blockersTitle": "Закрытие заблокировано",
"blockers.uncategorized_expenses": "Есть расходы без категории.",
"blockers.negative_envelope": "Отрицательный баланс накопительного конверта.",
"blockers.unresolved_imports": "Есть незавершённые импорты.",
"blockers.open_decisions": "Есть неразобранные решения по импорту.",
"warnings.plan_exceeds_pool": "План превышает прогнозный пул.",
"warnings.underfunded_credit": "Долг по кредитке обеспечен не полностью.",
"warnings.expected_income_variance": "Ожидаемый доход расходится с фактическим.",
"close": "Закрыть месяц",
"closeConfirmTitle": "Закрыть месяц",
"closeConfirmBody": "Месяц {{month}} будет закрыт, остатки месячных категорий свернутся в общий пул.",
"reopen": "Открыть снова",
"reopenConfirmTitle": "Повторное открытие месяца",
"reopenConfirmBody": "Месяц и все последующие периоды будут открыты заново и пересчитаны.",
"copyPrevious": "Скопировать прошлый месяц"
},
"review": {
"title": "Очередь разбора",
"subtitle": "Проблемные импорты и расходы без категории.",
"empty": "Очередь пуста — всё разобрано.",
"sectionImports": "Импорты, требующие внимания",
"sectionUncategorized": "Расходы без категории",
"uncategorizedEmpty": "Расходов без категории нет.",
"assignCategory": "Назначить категорию",
"openImport": "Открыть импорт"
},
"imports": {
"title": "Импорт выписок",
"subtitle": "Загрузка и разбор банковских выписок.",
"upload": "Загрузить выписку",
"uploadTitle": "Новый импорт",
"uploading": "Загрузка…",
"adapterHint": "Поддерживаются: Альфа debit/credit XLSX, Ozon debit/credit PDF, Рокетбанк debit PDF.",
"file": "Файл выписки",
"targetAccount": "Счёт привязки",
"unassigned": "Не привязан",
"empty": "Импортов пока нет.",
"bank": "Банк / контракт",
"period": "Период",
"createdAt": "Создан",
"createdAtLine": "Создан: {{createdAt}}",
"status.uploaded": "Загружен",
"status.queued": "В очереди",
"status.processing": "Разбирается",
"status.review_required": "Требует разбора",
"status.ready": "Готов к применению",
"status.confirmed": "Применён",
"status.failed": "Ошибка",
"progress": "Прогресс разбора",
"steps.uploaded": "Загружен",
"steps.queued": "В очереди",
"steps.processing": "Разбирается",
"steps.review_required": "Разбор",
"steps.ready": "Готов",
"steps.confirmed": "Применён",
"detailTitle": "Импорт",
"backToList": "К списку",
"failedReason": "Разбор не удался: {{reason}}",
"blockers": "Критические проблемы",
"blockers.currency_mismatch": "Валюта выписки не совпадает со счётом.",
"blockers.amount_mismatch": "Сумма строки не совпадает с исходной.",
"blockers.missing_occurred_date": "Не определена дата операции.",
"blockers.missing_target_account": "Не подтверждён счёт привязки.",
"blockers.arithmetic_mismatch": "Контрольные суммы не сходятся.",
"blockers.unresolved_duplicate": "Есть неразобранные дубликаты.",
"blockers.unresolved_transfer": "Есть неразобранные переводы.",
"blockers.account_conflict": "Отпечаток счёта конфликтует с существующим.",
"warnings": "Предупреждения ({{count}})",
"warnings.amount_warning": "Нестандартная сумма.",
"warnings.date_warning": "Необычная дата.",
"warnings.payee_warning": "Получатель определён неточно.",
"warnings.mcc_missing": "MCC отсутствует.",
"warnings.description_warning": "Описание неполное.",
"reconciliationTitle": "Сверка",
"openingBalance": "Входящий остаток",
"closingBalance": "Исходящий остаток",
"computedBalance": "Расчётный остаток",
"reconciliation.matched": "Сошлось",
"reconciliation.mismatched": "Расхождение",
"reconciliation.not_available": "Данных для сверки нет",
"reconciliation.confirmed_without": "Подтверждено без сверки",
"totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}}",
"rowsTitle": "Строки выписки",
"noRows": "Строк нет.",
"rowWarnings": "Замечания",
"rowWarnings.amount": "сумма",
"rowWarnings.date": "дата",
"rowWarnings.direction": "направление",
"rowWarnings.mcc": "MCC",
"rowWarnings.description": "описание",
"decision": "Решение",
"decision.pending": "Не решено",
"decision.create": "Создать операцию",
"decision.discard": "Пропустить",
"decision.duplicate": "Это дубликат",
"decision.transfer": "Это перевод",
"confirm": "Применить импорт",
"confirmTitle": "Применить импорт",
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
"confirmWithout": "Подтверждаю применение без полной сверки остатков",
"confirmReason": "Причина",
"reparse": "Переразобрать",
"revert": "Отменить применение",
"revertTitle": "Отменить применение импорта",
"revertBody": "Операции, созданные импортом «{{fileName}}», будут удалены атомарно; затронутые месяцы потребуют переоткрытия."
},
"analytics": {
"title": "Аналитика",
"subtitle": "Четыре фиксированных отчёта по вашему пространству.",
"range": "Период:",
"months_one": "{{count}} месяц",
"months_few": "{{count}} месяца",
"months_many": "{{count}} месяцев",
"months_other": "{{count}} месяца",
"noData": "Нет данных за период.",
"categorySpend": "Расходы по категориям",
"categorySpendTable": "Расходы по категориям за период",
"planFact": "План и факт",
"planFactTable": "План и факт по категориям за месяц",
"cashFlow": "Доходы и расходы",
"cashFlowTable": "Доходы, расходы и нетто по месяцам",
"balances": "Остатки и долг",
"balancesTable": "Остатки по счетам",
"income": "Доходы",
"expense": "Расходы",
"net": "Нетто",
"assets": "Активы",
"liabilities": "Долги",
"asOf": "Состояние на {{asOf}}",
"unallocated": "Без распределения"
},
"settings": {
"title": "Настройки и данные",
"subtitle": "Профиль, экспорт и управление данными пространства.",
"profile": "Профиль",
"timezone": "Часовой пояс",
"dataTitle": "Данные",
"export": "Запросить экспорт",
"exportHint": "Архив ZIP (CSV + JSON) со счетами, операциями, бюджетами и справочниками готовится в фоне; ссылка придёт в приложении.",
"deletionTitle": "Удаление пространства",
"deletionHint": "Пространство будет заблокировано немедленно и удалено через 30 дней. До истечения срока удаление можно отменить.",
"deletionPhrase": "Подтверждающая фраза",
"deletionPhraseHint": "Введите DELETE прописными буквами",
"phraseMismatch": "Введите ровно DELETE",
"deletionRequest": "Запросить удаление",
"deletionPending": "Пространство ожидает удаления {{cancelsUntil}}. Вы ещё можете отменить.",
"deletionCancel": "Отменить удаление",
"deletionStatusActive": "Активно"
},
"toast": {
"accountCreated": "Счёт «{{name}}» создан",
"accountArchived": "Счёт отправлен в архив",
"operationCreated": "Операция создана",
"operationVoided": "Операция отменена",
"categoryAssigned": "Категория назначена",
"planUpdated": "План обновлён",
"categoryFunded": "Категория обеспечена",
"monthClosed": "Месяц закрыт",
"monthReopened": "Месяц открыт заново",
"planCopied": "План прошлого месяца скопирован",
"importUploaded": "Выписка загружена, разбор запущен",
"importConfirmed": "Импорт применён",
"importReverted": "Импорт отменён",
"reparseQueued": "Переразбор запущен",
"exportQueued": "Экспорт поставлен в очередь",
"exportReady": "Экспорт готов к скачиванию",
"deletionRequested": "Удаление запрошено: 30 дней на отмену",
"deletionCancelled": "Удаление отменено"
},
"notFound": {
"title": "Страница не найдена",
"subtitle": "Такой страницы нет. Проверьте адрес или вернитесь к счетам.",
"back": "К счетам"
},
"categories": {
"groceries": "Продукты",
"transport": "Транспорт",
"housing": "Жильё",
"utilities": "Коммунальные",
"health": "Здоровье",
"education": "Образование",
"entertainment": "Развлечения",
"eating_out": "Кафе и рестораны",
"clothes": "Одежда",
"personal": "Личное",
"savings": "Накопления",
"debts": "Погашение долгов",
"other": "Прочее"
}
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './i18n'
import './styles/global.css'
const container = document.getElementById('root')
if (!container) throw new Error('root container is missing')
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
)
+120
View File
@@ -0,0 +1,120 @@
import { useMemo, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'
import { z } from 'zod'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/fields'
import { LangSwitcher } from '@/components/layout/LangSwitcher'
import { useAcceptInvite, useMe } from '@/hooks/useAuth'
import { describeApiError } from '@/lib/apiErrors'
interface AcceptInviteValues {
fullName: string
password: string
confirm: string
}
export function AcceptInvitePage() {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const token = searchParams.get('token') ?? ''
const accept = useAcceptInvite()
const me = useMe()
const navigate = useNavigate()
const [apiError, setApiError] = useState<string | null>(null)
const schema = useMemo(
() =>
z
.object({
fullName: z.string().min(1, t('common.requiredField')),
password: z.string().min(12, t('auth.passwordHint')),
confirm: z.string().min(1, t('common.requiredField')),
})
.refine((values) => values.password === values.confirm, {
path: ['confirm'],
message: t('auth.passwordMismatch'),
}),
[t],
)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<AcceptInviteValues>({
resolver: zodResolver(schema),
defaultValues: { fullName: '', password: '', confirm: '' },
})
if (me.isSuccess) return <Navigate to="/accounts" replace />
const submit = handleSubmit((values) => {
setApiError(null)
accept.mutate(
{ token, fullName: values.fullName, password: values.password },
{
onSuccess: () => navigate('/accounts', { replace: true }),
onError: (error) => setApiError(describeApiError(error, t)),
},
)
})
return (
<div className="auth-page">
<div className="auth-card card">
<div className="auth-top">
<span className="brand">
<span className="brand-mark" aria-hidden="true" />
{t('app.brand')}
</span>
<LangSwitcher />
</div>
<h1 className="page-title">{t('auth.inviteTitle')}</h1>
<p className="page-subtitle">{t('auth.inviteSubtitle')}</p>
{apiError ? (
<div className="banner banner-danger" role="alert">
{apiError}
</div>
) : null}
{token ? (
<form onSubmit={submit} noValidate className="form-grid">
<TextField
label={t('auth.fullName')}
autoComplete="name"
error={errors.fullName?.message}
{...register('fullName')}
/>
<TextField
label={t('auth.password')}
type="password"
autoComplete="new-password"
hint={t('auth.passwordHint')}
error={errors.password?.message}
{...register('password')}
/>
<TextField
label={t('auth.passwordConfirm')}
type="password"
autoComplete="new-password"
error={errors.confirm?.message}
{...register('confirm')}
/>
<Button type="submit" disabled={accept.isPending}>
{accept.isPending ? t('auth.accepting') : t('auth.accept')}
</Button>
</form>
) : (
<div className="banner banner-warn" role="alert">
{t('auth.inviteTokenMissing')}
</div>
)}
<p className="auth-alt">
<Link to="/login">{t('auth.backToLogin')}</Link>
</p>
</div>
</div>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import type { Account, AccountType } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { EmptyState } from '@/components/ui/EmptyState'
import { MoneyField, SelectField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useAccounts, useArchiveAccount, useCreateAccount } from '@/hooks/useAccounts'
import { formatDate } from '@/lib/dates'
import { formatMoney, parseAmountToKopecks, sumKopecks } from '@/lib/money'
import { currentLocale } from '@/lib/locale'
interface AccountFormValues {
name: string
type: AccountType
openedOn: string
openingBalance: string
bankCode: string
maskedNumber: string
}
function AccountForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateAccount()
const schema = useMemoSchema()
const {
register,
handleSubmit,
formState: { errors },
} = useForm<AccountFormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
type: 'debit',
openedOn: new Date().toISOString().slice(0, 10),
openingBalance: '0',
bankCode: '',
maskedNumber: '',
},
})
const submit = handleSubmit((values) => {
create.mutate(
{
name: values.name,
type: values.type,
openedOn: values.openedOn,
openingBalanceKopecks: parseAmountToKopecks(values.openingBalance) ?? 0,
bankCode: values.bankCode || undefined,
maskedNumber: values.maskedNumber || undefined,
},
{ onSuccess: onDone },
)
})
return (
<form onSubmit={submit} noValidate className="form-grid">
<TextField label={t('common.name')} error={errors.name?.message} {...register('name')} />
<SelectField label={t('accounts.type')} error={errors.type?.message} {...register('type')}>
<option value="debit">{t('accounts.type.debit')}</option>
<option value="cash">{t('accounts.type.cash')}</option>
<option value="credit">{t('accounts.type.credit')}</option>
</SelectField>
<MoneyField
label={t('accounts.openingBalance')}
hint={t('accounts.openingBalanceHint')}
error={errors.openingBalance?.message}
{...register('openingBalance')}
/>
<TextField
label={t('accounts.openedOn')}
type="date"
error={errors.openedOn?.message}
{...register('openedOn')}
/>
<TextField
label={t('accounts.bank')}
hint={t('accounts.bankHint')}
error={errors.bankCode?.message}
{...register('bankCode')}
/>
<TextField
label={t('accounts.maskedNumber')}
error={errors.maskedNumber?.message}
{...register('maskedNumber')}
/>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? t('common.saving') : t('common.create')}
</Button>
</form>
)
}
function useMemoSchema() {
const { t } = useTranslation()
return z.object({
name: z.string().min(1, t('common.requiredField')),
type: z.enum(['cash', 'debit', 'credit']),
openedOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('common.requiredField')),
openingBalance: z
.string()
.refine((value) => (parseAmountToKopecks(value) ?? -1) >= 0, t('errors.badAmount')),
bankCode: z.string(),
maskedNumber: z.string(),
})
}
export function AccountsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const accounts = useAccounts()
const archive = useArchiveAccount()
const [createOpen, setCreateOpen] = useState(false)
const [archiveTarget, setArchiveTarget] = useState<Account | null>(null)
const active = accounts.data?.filter((account) => account.status === 'active') ?? []
const assets = sumKopecks(active.filter((a) => a.type !== 'credit').map((a) => a.balanceKopecks))
const liabilities = sumKopecks(
active.filter((a) => a.type === 'credit').map((a) => a.balanceKopecks),
)
return (
<>
<PageHeader
title={t('accounts.title')}
subtitle={t('accounts.subtitle')}
actions={<Button onClick={() => setCreateOpen(true)}>+ {t('accounts.add')}</Button>}
/>
<dl className="stats">
<div className="stat">
<dt>{t('accounts.assets')}</dt>
<dd className="num pos">{formatMoney(assets, locale)}</dd>
</div>
<div className="stat">
<dt>{t('accounts.liabilities')}</dt>
<dd className="num neg">{formatMoney(liabilities, locale)}</dd>
</div>
<div className="stat">
<dt>{t('accounts.netWorth')}</dt>
<dd className="num">{formatMoney(assets - liabilities, locale)}</dd>
</div>
</dl>
<section aria-label={t('accounts.title')}>
<QueryGate
query={accounts}
empty={
<EmptyState title={t('accounts.empty')}>
<Button onClick={() => setCreateOpen(true)}>{t('accounts.add')}</Button>
</EmptyState>
}
>
{(list) => (
<table className="data-table">
<caption className="visually-hidden">{t('accounts.title')}</caption>
<thead>
<tr>
<th scope="col">{t('common.name')}</th>
<th scope="col">{t('accounts.type')}</th>
<th scope="col">{t('accounts.bank')}</th>
<th scope="col">{t('accounts.openedOn')}</th>
<th scope="col">{t('common.status')}</th>
<th scope="col" className="num">
{t('accounts.balance')}
</th>
<th scope="col">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{list.map((account) => (
<tr key={account.id}>
<th scope="row" data-label={t('common.name')}>
{account.name}
{account.maskedNumber ? (
<span className="muted"> · {account.maskedNumber}</span>
) : null}
</th>
<td data-label={t('accounts.type')}>
<Badge tone={account.type === 'credit' ? 'warn' : 'info'}>
{t(`accounts.type.${account.type}`)}
</Badge>
</td>
<td data-label={t('accounts.bank')}>{account.bankCode ?? '—'}</td>
<td data-label={t('accounts.openedOn')}>
{formatDate(account.openedOn, locale)}
</td>
<td data-label={t('common.status')}>
<Badge tone={account.status === 'active' ? 'accent' : 'neutral'}>
{t(`accounts.status.${account.status}`)}
</Badge>
</td>
<td
data-label={t('accounts.balance')}
className={`num ${account.type === 'credit' ? 'neg' : 'pos'}`}
>
{formatMoney(account.balanceKopecks, locale)}
</td>
<td data-label={t('common.actions')}>
{account.status === 'active' ? (
<Button
variant="subtle"
disabled={account.balanceKopecks !== 0}
title={
account.balanceKopecks !== 0
? t('accounts.archiveBlockedHint')
: undefined
}
onClick={() => setArchiveTarget(account)}
>
{t('accounts.archive')}
</Button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
)}
</QueryGate>
</section>
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('accounts.formTitle')}>
<AccountForm onDone={() => setCreateOpen(false)} />
</Modal>
<ConfirmDialog
open={archiveTarget !== null}
danger
title={t('accounts.archiveConfirmTitle')}
body={t('accounts.archiveConfirmBody', { name: archiveTarget?.name ?? '' })}
confirmLabel={t('accounts.archive')}
onClose={() => setArchiveTarget(null)}
onConfirm={() => {
if (archiveTarget) archive.mutate(archiveTarget.id)
}}
/>
</>
)
}
+184
View File
@@ -0,0 +1,184 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ColumnChart } from '@/components/charts/ColumnChart'
import { HorizontalBars } from '@/components/charts/HorizontalBars'
import { PlanFactBars } from '@/components/charts/PlanFactBars'
import { Badge } from '@/components/ui/Badge'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
import {
useAnalyticsBalances,
useAnalyticsCashFlow,
useAnalyticsCategorySpend,
useAnalyticsPlanFact,
} from '@/hooks/useAnalytics'
import { reportItemLabel } from '@/lib/categoryLabel'
import { currentMonth, formatMonthShort, shiftMonth } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney } from '@/lib/money'
const RANGE_CHOICES = [3, 6, 12] as const
export function AnalyticsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const [months, setMonths] = useState<(typeof RANGE_CHOICES)[number]>(6)
const [planMonth, setPlanMonth] = useState(currentMonth())
const end = currentMonth()
const start = shiftMonth(end, -(months - 1))
const spend = useAnalyticsCategorySpend(start, end)
const cashFlow = useAnalyticsCashFlow(start, end)
const balances = useAnalyticsBalances()
const planFact = useAnalyticsPlanFact(planMonth)
const money = (value: number) => formatMoney(value, locale)
return (
<>
<PageHeader
title={t('analytics.title')}
subtitle={t('analytics.subtitle')}
actions={
<label className="inline-label">
<span>{t('analytics.range')}</span>
<select
className="input select"
value={months}
onChange={(event) => setMonths(Number(event.target.value) as (typeof RANGE_CHOICES)[number])}
>
{RANGE_CHOICES.map((choice) => (
<option key={choice} value={choice}>
{t('analytics.months', { count: choice })}
</option>
))}
</select>
</label>
}
/>
<div className="analytics-grid">
<section className="card" aria-label={t('analytics.categorySpend')}>
<h2 className="section-title">{t('analytics.categorySpend')}</h2>
<QueryGate query={spend} empty={<p className="muted">{t('analytics.noData')}</p>}>
{(items) => (
<HorizontalBars
caption={t('analytics.categorySpendTable')}
formatValue={money}
rows={items.map((item) => ({
key: item.categoryId ?? item.name ?? 'unallocated',
label: reportItemLabel(item.name, item.nameKey, t),
value: item.amountKopecks,
}))}
/>
)}
</QueryGate>
</section>
<section className="card" aria-label={t('analytics.planFact')}>
<h2 className="section-title">{t('analytics.planFact')}</h2>
<MonthSwitcher month={planMonth} onChange={setPlanMonth} />
<QueryGate query={planFact} empty={<p className="muted">{t('analytics.noData')}</p>}>
{(report) =>
report.items.length === 0 ? (
<p className="muted">{t('analytics.noData')}</p>
) : (
<PlanFactBars
caption={t('analytics.planFactTable')}
formatValue={money}
legendPlanned={t('budget.planned')}
legendActual={t('budget.spent')}
rows={report.items.map((item) => ({
key: item.categoryId,
label: reportItemLabel(item.name, item.nameKey, t),
planned: item.plannedKopecks,
actual: item.actualKopecks,
}))}
/>
)
}
</QueryGate>
</section>
<section className="card" aria-label={t('analytics.cashFlow')}>
<h2 className="section-title">{t('analytics.cashFlow')}</h2>
<QueryGate query={cashFlow} empty={<p className="muted">{t('analytics.noData')}</p>}>
{(items) =>
items.length === 0 ? (
<p className="muted">{t('analytics.noData')}</p>
) : (
<ColumnChart
caption={t('analytics.cashFlowTable')}
formatValue={money}
legendIncome={t('analytics.income')}
legendExpense={t('analytics.expense')}
columns={items.map((item) => ({
key: item.month,
label: formatMonthShort(item.month, locale),
income: item.incomeKopecks,
expense: item.expenseKopecks,
}))}
extraColumns={{
label: t('analytics.net'),
render: (column) =>
money(items.find((item) => item.month === column.key)?.netKopecks ?? 0),
}}
/>
)
}
</QueryGate>
</section>
<section className="card" aria-label={t('analytics.balances')}>
<h2 className="section-title">{t('analytics.balances')}</h2>
<QueryGate query={balances} empty={<p className="muted">{t('analytics.noData')}</p>}>
{(report) => (
<>
<p className="muted">{t('analytics.asOf', { asOf: report.asOf })}</p>
<dl className="stats stats-compact">
<div className="stat">
<dt>{t('analytics.assets')}</dt>
<dd className="num pos">{money(report.assetsKopecks)}</dd>
</div>
<div className="stat">
<dt>{t('analytics.liabilities')}</dt>
<dd className="num neg">{money(report.liabilitiesKopecks)}</dd>
</div>
<div className="stat">
<dt>{t('accounts.netWorth')}</dt>
<dd className="num">{money(report.netKopecks)}</dd>
</div>
</dl>
<table className="mini-table">
<caption className="visually-hidden">{t('analytics.balancesTable')}</caption>
<thead>
<tr>
<th scope="col">{t('common.name')}</th>
<th scope="col">{t('accounts.type')}</th>
<th scope="col" className="num">
{t('accounts.balance')}
</th>
</tr>
</thead>
<tbody>
{report.items.map((item) => (
<tr key={item.accountId}>
<th scope="row">{item.name}</th>
<td>
<Badge tone={item.type === 'credit' ? 'warn' : 'info'}>
{t(`accounts.type.${item.type}`)}
</Badge>
</td>
<td className={`num ${item.type === 'credit' ? 'neg' : 'pos'}`}>
{money(item.balanceKopecks)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</QueryGate>
</section>
</div>
</>
)
}
+378
View File
@@ -0,0 +1,378 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import type { BudgetCategory } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { MoneyField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import {
useBudgetCategories,
useBudgetSummary,
useCloseMonth,
useCopyPreviousMonth,
useFundCategory,
useReopenMonth,
useUpdatePlan,
} from '@/hooks/useBudget'
import { categoryLabel } from '@/lib/categoryLabel'
import { formatDate, currentMonth, formatMonth } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
function PlanModal({
category,
month,
onClose,
}: {
category: BudgetCategory
month: string
onClose: () => void
}) {
const { t } = useTranslation()
const update = useUpdatePlan(month)
const schema = z.object({
planned: z
.string()
.refine((value) => (parseAmountToKopecks(value) ?? -1) >= 0, t('errors.badAmount')),
})
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{ planned: string }>({
resolver: zodResolver(schema),
defaultValues: { planned: kopecksToInput(Math.max(category.plannedKopecks, 0)) },
})
const submit = handleSubmit((values) => {
update.mutate(
{ categoryId: category.id, plannedKopecks: parseAmountToKopecks(values.planned) ?? 0 },
{ onSuccess: onClose },
)
})
return (
<Modal
open
onClose={onClose}
title={t('budget.planTitle', { name: categoryLabel(category, t) })}
>
<form onSubmit={submit} noValidate className="form-grid">
<MoneyField label={t('budget.planned')} error={errors.planned?.message} {...register('planned')} />
<Button type="submit" disabled={update.isPending}>
{t('common.save')}
</Button>
</form>
</Modal>
)
}
function FundModal({
category,
month,
onClose,
}: {
category: BudgetCategory
month: string
onClose: () => void
}) {
const { t } = useTranslation()
const fund = useFundCategory(month)
const schema = z.object({
amount: z
.string()
.refine((value) => (parseAmountToKopecks(value) ?? 0) > 0, t('errors.badAmount')),
})
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{ amount: string }>({ resolver: zodResolver(schema), defaultValues: { amount: '' } })
const submit = handleSubmit((values) => {
fund.mutate(
{ categoryId: category.id, amountKopecks: parseAmountToKopecks(values.amount) ?? 0 },
{ onSuccess: onClose },
)
})
return (
<Modal
open
onClose={onClose}
title={t('budget.fundTitle', { name: categoryLabel(category, t) })}
>
<form onSubmit={submit} noValidate className="form-grid">
<p className="muted">{t('budget.fundHint')}</p>
<MoneyField label={t('common.amount')} error={errors.amount?.message} {...register('amount')} />
<Button type="submit" disabled={fund.isPending}>
{t('budget.fund')}
</Button>
</form>
</Modal>
)
}
export function BudgetPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const [month, setMonth] = useState(currentMonth())
const summary = useBudgetSummary(month)
const categories = useBudgetCategories(month)
const closeMonth = useCloseMonth()
const reopenMonth = useReopenMonth()
const copyPrevious = useCopyPreviousMonth()
const [planTarget, setPlanTarget] = useState<BudgetCategory | null>(null)
const [fundTarget, setFundTarget] = useState<BudgetCategory | null>(null)
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false)
const [reopenConfirmOpen, setReopenConfirmOpen] = useState(false)
const status = summary.data?.status
const allCategories = categories.data ?? []
const userCategories = allCategories.filter(
(category) => category.kind !== 'credit_reserve' && !category.archived,
)
const reserves = allCategories.filter((category) => category.kind === 'credit_reserve')
const renderCategoryRows = (rows: BudgetCategory[]) =>
rows.map((category) => (
<tr key={category.id}>
<th scope="row" data-label={t('common.name')}>
{categoryLabel(category, t)}
<Badge tone={category.kind === 'savings_envelope' ? 'info' : 'neutral'}>
{t(`budget.category.kind.${category.kind}`)}
</Badge>
</th>
<td data-label={t('budget.planned')}>
<Button variant="subtle" onClick={() => setPlanTarget(category)}>
{formatMoney(category.plannedKopecks, locale)}
</Button>
</td>
<td data-label={t('budget.funded')}>
<Button variant="subtle" onClick={() => setFundTarget(category)}>
{formatMoney(category.fundedKopecks, locale)}
</Button>
</td>
<td data-label={t('budget.spent')} className="num">
{formatMoney(category.spentKopecks, locale)}
</td>
<td
data-label={t('budget.remaining')}
className={`num ${category.availableKopecks < 0 ? 'neg' : 'pos'}`}
>
{formatMoney(category.availableKopecks, locale)}
</td>
</tr>
))
return (
<>
<PageHeader
title={t('budget.title')}
subtitle={t('budget.subtitle')}
actions={
<div className="page-actions-row">
<MonthSwitcher month={month} onChange={setMonth} />
{status === 'open' ? (
<>
<Button
variant="subtle"
disabled={copyPrevious.isPending}
onClick={() => copyPrevious.mutate(month)}
>
{t('budget.copyPrevious')}
</Button>
<Button
variant="primary"
disabled={closeMonth.isPending}
onClick={() => setCloseConfirmOpen(true)}
>
{t('budget.close')}
</Button>
</>
) : null}
{status === 'closed' ? (
<Button
variant="subtle"
disabled={reopenMonth.isPending}
onClick={() => setReopenConfirmOpen(true)}
>
{t('budget.reopen')}
</Button>
) : null}
</div>
}
/>
{status ? (
<Badge tone={status === 'open' ? 'accent' : 'neutral'}>{t(`budget.status.${status}`)}</Badge>
) : null}
{summary.data && summary.data.blockers.length > 0 ? (
<div className="banner banner-danger" role="alert">
<strong>{t('budget.blockersTitle')}</strong>
<ul>
{summary.data.blockers.map((blocker) => (
<li key={blocker.code}>
{t(`budget.blockers.${blocker.code}`, { defaultValue: blocker.message ?? blocker.code })}
</li>
))}
</ul>
</div>
) : null}
{summary.data && summary.data.warnings.length > 0 ? (
<div className="banner banner-warn">
<ul>
{summary.data.warnings.map((warning) => (
<li key={warning.code}>
{t(`budget.warnings.${warning.code}`, { defaultValue: warning.message ?? warning.code })}
</li>
))}
</ul>
</div>
) : null}
<dl className="stats">
<div className="stat">
<dt>{t('budget.projectedPool')}</dt>
<dd className="num">{formatMoney(summary.data?.projectedPoolKopecks ?? 0, locale)}</dd>
</div>
<div className="stat">
<dt>{t('budget.actualPool')}</dt>
<dd className={`num ${(summary.data?.actualPoolKopecks ?? 0) < 0 ? 'neg' : 'pos'}`}>
{formatMoney(summary.data?.actualPoolKopecks ?? 0, locale)}
</dd>
</div>
<div className="stat">
<dt>{t('budget.plannedTotal')}</dt>
<dd className="num">{formatMoney(summary.data?.plannedTotalKopecks ?? 0, locale)}</dd>
</div>
<div className="stat">
<dt>{t('budget.fundedTotal')}</dt>
<dd className="num">{formatMoney(summary.data?.fundedTotalKopecks ?? 0, locale)}</dd>
</div>
<div className="stat">
<dt>{t('budget.spentTotal')}</dt>
<dd className="num">{formatMoney(summary.data?.spentTotalKopecks ?? 0, locale)}</dd>
</div>
<div className="stat">
<dt>{t('budget.available')}</dt>
<dd className="num">{formatMoney(summary.data?.availableKopecks ?? 0, locale)}</dd>
</div>
</dl>
<QueryGate query={categories}>
{() => (
<>
{summary.data && summary.data.expectedIncomes.length > 0 ? (
<section aria-label={t('budget.expectedIncome')}>
<h2 className="section-title">{t('budget.expectedIncome')}</h2>
<table className="data-table">
<caption className="visually-hidden">{t('budget.expectedIncome')}</caption>
<thead>
<tr>
<th scope="col">{t('common.date')}</th>
<th scope="col">{t('budget.incomeSource')}</th>
<th scope="col" className="num">{t('common.amount')}</th>
<th scope="col" className="num">{t('budget.matched')}</th>
<th scope="col">{t('common.status')}</th>
</tr>
</thead>
<tbody>
{summary.data.expectedIncomes.map((income) => (
<tr key={income.id}>
<th scope="row" data-label={t('common.date')}>
{formatDate(income.expectedOn, locale)}
</th>
<td data-label={t('budget.incomeSource')}>
{income.sourceName ?? t('budget.noSource')}
</td>
<td data-label={t('common.amount')} className="num">
{formatMoney(income.amountKopecks, locale)}
</td>
<td data-label={t('budget.matched')} className="num">
{formatMoney(income.matchedKopecks, locale)}
</td>
<td data-label={t('common.status')}>
<Badge tone={income.status === 'matched' ? 'accent' : 'warn'}>
{t(`budget.income.status.${income.status}`)}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
<section aria-label={t('budget.categories')}>
<h2 className="section-title">{t('budget.categories')}</h2>
{userCategories.length === 0 ? (
<p className="muted">{t('budget.noCategories')}</p>
) : (
<table className="data-table">
<caption className="visually-hidden">{t('budget.categories')}</caption>
<thead>
<tr>
<th scope="col">{t('common.name')}</th>
<th scope="col">{t('budget.planned')}</th>
<th scope="col">{t('budget.funded')}</th>
<th scope="col" className="num">{t('budget.spent')}</th>
<th scope="col" className="num">{t('budget.remaining')}</th>
</tr>
</thead>
<tbody>{renderCategoryRows(userCategories)}</tbody>
</table>
)}
</section>
{reserves.length > 0 ? (
<section aria-label={t('budget.reserves')}>
<h2 className="section-title">{t('budget.reserves')}</h2>
<table className="data-table">
<caption className="visually-hidden">{t('budget.reserves')}</caption>
<thead>
<tr>
<th scope="col">{t('common.name')}</th>
<th scope="col">{t('budget.planned')}</th>
<th scope="col">{t('budget.funded')}</th>
<th scope="col" className="num">{t('budget.spent')}</th>
<th scope="col" className="num">{t('budget.remaining')}</th>
</tr>
</thead>
<tbody>{renderCategoryRows(reserves)}</tbody>
</table>
</section>
) : null}
</>
)}
</QueryGate>
{planTarget ? (
<PlanModal category={planTarget} month={month} onClose={() => setPlanTarget(null)} />
) : null}
{fundTarget ? (
<FundModal category={fundTarget} month={month} onClose={() => setFundTarget(null)} />
) : null}
<ConfirmDialog
open={closeConfirmOpen}
danger
title={t('budget.closeConfirmTitle')}
body={t('budget.closeConfirmBody', { month: formatMonth(month, locale) })}
confirmLabel={t('budget.close')}
onClose={() => setCloseConfirmOpen(false)}
onConfirm={() => closeMonth.mutate(month)}
/>
<ConfirmDialog
open={reopenConfirmOpen}
danger
title={t('budget.reopenConfirmTitle')}
body={t('budget.reopenConfirmBody')}
confirmLabel={t('budget.reopen')}
onClose={() => setReopenConfirmOpen(false)}
onConfirm={() => reopenMonth.mutate(month)}
/>
</>
)
}
+329
View File
@@ -0,0 +1,329 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useParams } from 'react-router-dom'
import type { RowDecision } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import {
useConfirmImport,
useImport,
useReparseImport,
useRevertImport,
useSaveRowDecision,
} from '@/hooks/useImports'
import { formatDate, formatDateTime } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney } from '@/lib/money'
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
const DECISIONS: readonly Exclude<RowDecision, 'pending'>[] = [
'create',
'discard',
'duplicate',
'transfer',
]
const STEP_ORDER = ['uploaded', 'queued', 'processing', 'review_required', 'confirmed'] as const
function stepIndexFor(status: string): number {
if (status === 'failed') return STEP_ORDER.length - 1
if (status === 'ready') return STEP_ORDER.indexOf('review_required')
const index = STEP_ORDER.indexOf(status as (typeof STEP_ORDER)[number])
return index === -1 ? 0 : index
}
function ConfirmModal({
importId,
needsConfirmation,
onClose,
}: {
importId: string
needsConfirmation: boolean
onClose: () => void
}) {
const { t } = useTranslation()
const confirm = useConfirmImport(importId)
const [acknowledged, setAcknowledged] = useState(false)
const [reason, setReason] = useState('')
const disabled = needsConfirmation && !acknowledged
return (
<Modal open onClose={onClose} title={t('imports.confirmTitle')}>
<div className="form-grid">
<p className="muted">{t('imports.confirmBody')}</p>
{needsConfirmation ? (
<>
<label className="checkbox-field">
<input
type="checkbox"
checked={acknowledged}
onChange={(event) => setAcknowledged(event.target.checked)}
/>
{t('imports.confirmWithout')}
</label>
<TextField
label={t('imports.confirmReason')}
value={reason}
onChange={(event) => setReason(event.target.value)}
/>
</>
) : null}
<Button
disabled={disabled || confirm.isPending}
onClick={() =>
confirm.mutate(
needsConfirmation ? { confirmedWithoutFullReconciliation: true, reason: reason || undefined } : {},
{ onSuccess: onClose },
)
}
>
{t('imports.confirm')}
</Button>
</div>
</Modal>
)
}
export function ImportDetailPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const { id = '' } = useParams()
const detail = useImport(id)
const reparse = useReparseImport(id)
const revert = useRevertImport(id)
const saveDecision = useSaveRowDecision(id)
const [confirmOpen, setConfirmOpen] = useState(false)
const [revertOpen, setRevertOpen] = useState(false)
const status = detail.data?.status
const needsConfirmation =
detail.data?.reconciliation.status === 'mismatched' ||
detail.data?.reconciliation.status === 'not_available'
return (
<>
<PageHeader
title={detail.data?.fileName ?? t('imports.detailTitle')}
subtitle={detail.data ? `${detail.data.bankAdapter}` : undefined}
actions={
<div className="page-actions-row">
<Link className="btn btn-subtle" to="/imports">
{t('imports.backToList')}
</Link>
{status && ['review_required', 'ready', 'failed'].includes(status) ? (
<Button variant="subtle" disabled={reparse.isPending} onClick={() => reparse.mutate()}>
{t('imports.reparse')}
</Button>
) : null}
{status === 'confirmed' ? (
<Button variant="danger" disabled={revert.isPending} onClick={() => setRevertOpen(true)}>
{t('imports.revert')}
</Button>
) : null}
{status && ['review_required', 'ready'].includes(status) ? (
<Button onClick={() => setConfirmOpen(true)}>{t('imports.confirm')}</Button>
) : null}
</div>
}
/>
{status ? <Badge tone={IMPORT_STATUS_TONES[status]}>{t(`imports.status.${status}`)}</Badge> : null}
<QueryGate query={detail}>
{(data) => (
<>
<ol className="steps" aria-label={t('imports.progress')}>
{STEP_ORDER.map((step, index) => {
const currentIndex = stepIndexFor(data.status)
const state =
index < currentIndex ? 'done' : index === currentIndex ? 'current' : 'todo'
return (
<li key={step} className={`step step-${state}`} aria-current={state === 'current' ? 'step' : undefined}>
{t(`imports.steps.${step === 'review_required' && data.status === 'ready' ? 'ready' : step}`)}
</li>
)
})}
</ol>
{data.status === 'failed' && data.failureReason ? (
<div className="banner banner-danger" role="alert">
{t('imports.failedReason', { reason: data.failureReason })}
</div>
) : null}
{data.blockers.length > 0 ? (
<div className="banner banner-danger" role="alert">
<strong>{t('imports.blockers')}</strong>
<ul>
{data.blockers.map((issue) => (
<li key={`${issue.code}-${issue.rowId ?? ''}`}>
{t(`imports.blockers.${issue.code}`, { defaultValue: issue.message ?? issue.code })}
</li>
))}
</ul>
</div>
) : null}
{data.warnings.length > 0 ? (
<details className="banner banner-warn">
<summary>{t('imports.warnings', { count: data.warnings.length })}</summary>
<ul>
{data.warnings.map((issue, index) => (
<li key={`${issue.code}-${index}`}>
{t(`imports.warnings.${issue.code}`, { defaultValue: issue.message ?? issue.code })}
</li>
))}
</ul>
</details>
) : null}
<section aria-label={t('imports.reconciliationTitle')} className="card">
<h2 className="section-title">{t('imports.reconciliationTitle')}</h2>
<dl className="kv">
<div>
<dt>{t('imports.openingBalance')}</dt>
<dd className="num">
{data.reconciliation.openingBalanceKopecks !== null
? formatMoney(data.reconciliation.openingBalanceKopecks, locale)
: '—'}
</dd>
</div>
<div>
<dt>{t('imports.closingBalance')}</dt>
<dd className="num">
{data.reconciliation.closingBalanceKopecks !== null
? formatMoney(data.reconciliation.closingBalanceKopecks, locale)
: '—'}
</dd>
</div>
<div>
<dt>{t('imports.computedBalance')}</dt>
<dd className="num">
{data.reconciliation.computedClosingKopecks !== null
? formatMoney(data.reconciliation.computedClosingKopecks, locale)
: '—'}
</dd>
</div>
<div>
<dt>{t('common.status')}</dt>
<dd>
<Badge
tone={
data.reconciliation.status === 'matched' ? 'accent' : 'warn'
}
>
{t(`imports.reconciliation.${data.reconciliation.status}`)}
</Badge>
</dd>
</div>
</dl>
<p className="muted">
{t('imports.totals', {
total: data.totals.total,
create: data.totals.create,
discard: data.totals.discard,
duplicate: data.totals.duplicate,
transfer: data.totals.transfer,
})}
</p>
</section>
<section aria-label={t('imports.rowsTitle')}>
<h2 className="section-title">{t('imports.rowsTitle')}</h2>
{data.rows.length === 0 ? (
<p className="muted">{t('imports.noRows')}</p>
) : (
<table className="data-table">
<caption className="visually-hidden">{t('imports.rowsTitle')}</caption>
<thead>
<tr>
<th scope="col">{t('common.date')}</th>
<th scope="col">{t('operations.description')}</th>
<th scope="col" className="num">{t('common.amount')}</th>
<th scope="col">{t('imports.rowWarnings')}</th>
<th scope="col">{t('imports.decision')}</th>
</tr>
</thead>
<tbody>
{data.rows.map((row) => (
<tr key={row.id}>
<th scope="row" data-label={t('common.date')}>
{formatDate(row.occurredOn, locale)}
</th>
<td data-label={t('operations.description')}>
{row.description ?? '—'}
{row.payeeHint ? <span className="muted"> · {row.payeeHint}</span> : null}
</td>
<td
data-label={t('common.amount')}
className={`num ${row.direction === 'in' ? 'pos' : 'neg'}`}
>
{row.direction === 'in' ? '+' : '−'}
{formatMoney(row.amountKopecks, locale)}
</td>
<td data-label={t('imports.rowWarnings')}>
{row.warnings.length === 0 ? (
'—'
) : (
<ul className="chip-list">
{row.warnings.map((warning) => (
<li key={warning} className="chip">
{t(`imports.rowWarnings.${warning}`, { defaultValue: warning })}
</li>
))}
</ul>
)}
</td>
<td data-label={t('imports.decision')}>
<select
className="input select"
aria-label={t('imports.decision')}
value={row.decision === 'pending' ? '' : row.decision}
onChange={(event) =>
saveDecision.mutate({
rowId: row.id,
payload: {
decision: event.target.value as Exclude<RowDecision, 'pending'>,
},
})
}
>
<option value="">{t('imports.decision.pending')}</option>
{DECISIONS.map((decision) => (
<option key={decision} value={decision}>
{t(`imports.decision.${decision}`)}
</option>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
{confirmOpen ? (
<ConfirmModal
importId={data.id}
needsConfirmation={needsConfirmation}
onClose={() => setConfirmOpen(false)}
/>
) : null}
<ConfirmDialog
open={revertOpen}
danger
title={t('imports.revertTitle')}
body={t('imports.revertBody', { fileName: data.fileName })}
confirmLabel={t('imports.revert')}
onClose={() => setRevertOpen(false)}
onConfirm={() => revert.mutate()}
/>
</>
)}
</QueryGate>
<p className="muted">
{detail.data ? t('imports.createdAtLine', { createdAt: formatDateTime(detail.data.createdAt, locale) }) : null}
</p>
</>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { SelectField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useActiveAccounts } from '@/hooks/useAccounts'
import { useImports, useUploadImport } from '@/hooks/useImports'
import { formatDateTime } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
function UploadModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const accounts = useActiveAccounts()
const upload = useUploadImport()
const [accountId, setAccountId] = useState('')
const [file, setFile] = useState<File | null>(null)
const [touched, setTouched] = useState(false)
const canSubmit = file !== null && accountId !== ''
const submit = () => {
setTouched(true)
if (!canSubmit || !file) return
upload.mutate({ file, accountId }, { onSuccess: onClose })
}
return (
<Modal open onClose={onClose} title={t('imports.uploadTitle')}>
<div className="form-grid">
<p className="muted">{t('imports.adapterHint')}</p>
<div className="field">
<label className="field-label" htmlFor="import-file">
{t('imports.file')}
</label>
<input
id="import-file"
className="input"
type="file"
accept=".pdf,.xlsx"
required
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
aria-invalid={touched && !file ? true : undefined}
/>
{touched && !file ? (
<p className="field-error" role="alert">
{t('common.requiredField')}
</p>
) : null}
</div>
<SelectField
label={t('imports.targetAccount')}
value={accountId}
onChange={(event) => setAccountId(event.target.value)}
error={touched && !accountId ? t('common.requiredField') : undefined}
>
<option value="">{t('common.choose')}</option>
{(accounts.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</SelectField>
<Button onClick={submit} disabled={upload.isPending}>
{upload.isPending ? t('imports.uploading') : t('imports.upload')}
</Button>
</div>
</Modal>
)
}
export function ImportsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const imports = useImports()
const [uploadOpen, setUploadOpen] = useState(false)
return (
<>
<PageHeader
title={t('imports.title')}
subtitle={t('imports.subtitle')}
actions={<Button onClick={() => setUploadOpen(true)}>+ {t('imports.upload')}</Button>}
/>
<QueryGate query={imports} empty={<EmptyState title={t('imports.empty')} />}>
{(list) => (
<table className="data-table">
<caption className="visually-hidden">{t('imports.title')}</caption>
<thead>
<tr>
<th scope="col">{t('imports.fileName')}</th>
<th scope="col">{t('imports.bank')}</th>
<th scope="col">{t('imports.targetAccount')}</th>
<th scope="col">{t('imports.period')}</th>
<th scope="col">{t('common.status')}</th>
<th scope="col">{t('imports.createdAt')}</th>
</tr>
</thead>
<tbody>
{list.map((item) => (
<tr key={item.id}>
<th scope="row" data-label={t('imports.fileName')}>
<Link to={`/imports/${item.id}`}>{item.fileName}</Link>
</th>
<td data-label={t('imports.bank')}>{item.bankAdapter}</td>
<td data-label={t('imports.targetAccount')}>
{item.accountName ?? t('imports.unassigned')}
</td>
<td data-label={t('imports.period')}>
{item.periodFrom && item.periodTo ? `${item.periodFrom} — ${item.periodTo}` : '—'}
</td>
<td data-label={t('common.status')}>
<Badge tone={IMPORT_STATUS_TONES[item.status]}>
{t(`imports.status.${item.status}`)}
</Badge>
</td>
<td data-label={t('imports.createdAt')}>
{formatDateTime(item.createdAt, locale)}
</td>
</tr>
))}
</tbody>
</table>
)}
</QueryGate>
{uploadOpen ? <UploadModal onClose={() => setUploadOpen(false)} /> : null}
</>
)
}
+101
View File
@@ -0,0 +1,101 @@
import { useMemo, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { z } from 'zod'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/fields'
import { LangSwitcher } from '@/components/layout/LangSwitcher'
import { useLogin, useMe } from '@/hooks/useAuth'
import { describeApiError } from '@/lib/apiErrors'
interface LoginFormValues {
email: string
password: string
}
export function LoginPage() {
const { t } = useTranslation()
const login = useLogin()
const me = useMe()
const navigate = useNavigate()
const location = useLocation()
const [apiError, setApiError] = useState<string | null>(null)
const from = (location.state as { from?: string } | null)?.from
const schema = useMemo(
() =>
z.object({
email: z.string().min(1, t('common.requiredField')).email(t('auth.invalidEmail')),
password: z.string().min(1, t('common.requiredField')),
}),
[t],
)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
})
if (me.isSuccess) return <Navigate to="/accounts" replace />
const submit = handleSubmit((values) => {
setApiError(null)
login.mutate(values, {
onSuccess: () => navigate(from ?? '/accounts', { replace: true }),
onError: (error) => setApiError(describeApiError(error, t)),
})
})
return (
<div className="auth-page">
<div className="auth-card card">
<div className="auth-top">
<span className="brand">
<span className="brand-mark" aria-hidden="true" />
{t('app.brand')}
</span>
<LangSwitcher />
</div>
<h1 className="page-title">{t('auth.loginTitle')}</h1>
<p className="page-subtitle">{t('auth.loginSubtitle')}</p>
{apiError ? (
<div className="banner banner-danger" role="alert">
{apiError}
</div>
) : null}
<form onSubmit={submit} noValidate className="form-grid">
<TextField
label={t('auth.email')}
type="email"
autoComplete="email"
error={errors.email?.message}
{...register('email')}
/>
<TextField
label={t('auth.password')}
type="password"
autoComplete="current-password"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" disabled={login.isPending}>
{login.isPending ? t('auth.loggingIn') : t('auth.submit')}
</Button>
</form>
<p className="auth-alt">
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
</p>
<p className="auth-alt">
<Link to="/invite/accept">{t('auth.haveInvite')}</Link>
</p>
</div>
</div>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { PageHeader } from '@/components/ui/PageHeader'
export function NotFoundPage() {
const { t } = useTranslation()
return (
<div className="center-page">
<PageHeader title={t('notFound.title')} subtitle={t('notFound.subtitle')} />
<Link className="btn btn-primary" to="/accounts">
{t('notFound.back')}
</Link>
</div>
)
}
+681
View File
@@ -0,0 +1,681 @@
import { useMemo, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm, type UseFormRegisterReturn } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import { z } from 'zod'
import type { Account, Operation, OperationFilters, OperationKind, OperationStatus } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { MoneyField, SelectField, TextField, TextareaField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories } from '@/hooks/useBudget'
import { useActiveAccounts } from '@/hooks/useAccounts'
import {
useCreateOperation,
useOperations,
useVoidOperation,
} from '@/hooks/useOperations'
import { categoryLabel } from '@/lib/categoryLabel'
import { currentMonth, formatDate, todayISO } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney, parseAmountToKopecks } from '@/lib/money'
import { OPERATION_KIND_TONES, operationAmount } from '@/lib/operationFormat'
const OPERATION_KINDS: readonly OperationKind[] = ['expense', 'income', 'transfer', 'refund']
const amountField = (t: TFunction) =>
z.string().refine((value) => (parseAmountToKopecks(value) ?? 0) > 0, t('errors.badAmount'))
const dateField = (t: TFunction) =>
z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('common.requiredField'))
function AccountSelect({
label,
accounts,
error,
registration,
}: {
label: string
accounts: Account[] | undefined
error?: string
registration: UseFormRegisterReturn
}) {
const { t } = useTranslation()
return (
<SelectField label={label} error={error} {...registration}>
<option value="">{t('common.choose')}</option>
{(accounts ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</SelectField>
)
}
function ExpenseForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateOperation()
const accounts = useActiveAccounts()
const categories = useBudgetCategories(currentMonth())
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
amount: amountField(t),
occurredOn: dateField(t),
categoryId: z.string(),
payeeName: z.string(),
description: z.string(),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
accountId: '',
amount: '',
occurredOn: todayISO(),
categoryId: '',
payeeName: '',
description: '',
},
})
const submit = handleSubmit((values) => {
create.mutate(
{
kind: 'expense',
accountId: values.accountId,
amountKopecks: parseAmountToKopecks(values.amount) ?? 0,
occurredOn: values.occurredOn,
categoryId: values.categoryId || undefined,
payeeName: values.payeeName || undefined,
description: values.description || undefined,
},
{ onSuccess: onDone },
)
})
return (
<form onSubmit={submit} noValidate className="form-grid">
<AccountSelect
label={t('operations.account')}
accounts={accounts.data}
error={errors.accountId?.message}
registration={register('accountId')}
/>
<MoneyField label={t('common.amount')} error={errors.amount?.message} {...register('amount')} />
<TextField
label={t('operations.occurredOn')}
type="date"
error={errors.occurredOn?.message}
{...register('occurredOn')}
/>
<SelectField label={t('operations.category')} error={errors.categoryId?.message} {...register('categoryId')}>
<option value="">{t('operations.noCategory')}</option>
{(categories.data ?? [])
.filter((category) => !category.archived)
.map((category) => (
<option key={category.id} value={category.id}>
{categoryLabel(category, t)}
</option>
))}
</SelectField>
<TextField label={t('operations.payee')} error={errors.payeeName?.message} {...register('payeeName')} />
<TextareaField
label={t('operations.description')}
error={errors.description?.message}
{...register('description')}
/>
<Button type="submit" disabled={create.isPending}>
{t('common.create')}
</Button>
</form>
)
}
function IncomeForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateOperation()
const accounts = useActiveAccounts()
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
amount: amountField(t),
occurredOn: dateField(t),
incomeSourceName: z.string(),
description: z.string(),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
accountId: '',
amount: '',
occurredOn: todayISO(),
incomeSourceName: '',
description: '',
},
})
const submit = handleSubmit((values) => {
create.mutate(
{
kind: 'income',
accountId: values.accountId,
amountKopecks: parseAmountToKopecks(values.amount) ?? 0,
occurredOn: values.occurredOn,
incomeSourceName: values.incomeSourceName || undefined,
description: values.description || undefined,
},
{ onSuccess: onDone },
)
})
return (
<form onSubmit={submit} noValidate className="form-grid">
<AccountSelect
label={t('operations.account')}
accounts={accounts.data}
error={errors.accountId?.message}
registration={register('accountId')}
/>
<MoneyField label={t('common.amount')} error={errors.amount?.message} {...register('amount')} />
<TextField
label={t('operations.occurredOn')}
type="date"
error={errors.occurredOn?.message}
{...register('occurredOn')}
/>
<TextField
label={t('operations.incomeSource')}
hint={t('operations.incomeSourceHint')}
error={errors.incomeSourceName?.message}
{...register('incomeSourceName')}
/>
<TextareaField
label={t('operations.description')}
error={errors.description?.message}
{...register('description')}
/>
<Button type="submit" disabled={create.isPending}>
{t('common.create')}
</Button>
</form>
)
}
function TransferForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateOperation()
const accounts = useActiveAccounts()
const schema = z
.object({
fromAccountId: z.string().min(1, t('common.requiredField')),
toAccountId: z.string().min(1, t('common.requiredField')),
amount: amountField(t),
occurredOn: dateField(t),
description: z.string(),
})
.refine((values) => values.fromAccountId !== values.toAccountId, {
path: ['toAccountId'],
message: t('operations.sameAccount'),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
fromAccountId: '',
toAccountId: '',
amount: '',
occurredOn: todayISO(),
description: '',
},
})
const submit = handleSubmit((values) => {
create.mutate(
{
kind: 'transfer',
fromAccountId: values.fromAccountId,
toAccountId: values.toAccountId,
amountKopecks: parseAmountToKopecks(values.amount) ?? 0,
occurredOn: values.occurredOn,
description: values.description || undefined,
},
{ onSuccess: onDone },
)
})
return (
<form onSubmit={submit} noValidate className="form-grid">
<AccountSelect
label={t('operations.fromAccount')}
accounts={accounts.data}
error={errors.fromAccountId?.message}
registration={register('fromAccountId')}
/>
<AccountSelect
label={t('operations.toAccount')}
accounts={accounts.data}
error={errors.toAccountId?.message}
registration={register('toAccountId')}
/>
<MoneyField label={t('common.amount')} error={errors.amount?.message} {...register('amount')} />
<TextField
label={t('operations.occurredOn')}
type="date"
error={errors.occurredOn?.message}
{...register('occurredOn')}
/>
<TextareaField
label={t('operations.description')}
error={errors.description?.message}
{...register('description')}
/>
<Button type="submit" disabled={create.isPending}>
{t('common.create')}
</Button>
</form>
)
}
function RefundForm({ onDone }: { onDone: () => void }) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const create = useCreateOperation()
const accounts = useActiveAccounts()
const recent = useOperations({ kind: 'expense', limit: 20 })
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
amount: amountField(t),
occurredOn: dateField(t),
parentOperationId: z.string(),
description: z.string(),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
accountId: '',
amount: '',
occurredOn: todayISO(),
parentOperationId: '',
description: '',
},
})
const submit = handleSubmit((values) => {
create.mutate(
{
kind: 'refund',
accountId: values.accountId,
amountKopecks: parseAmountToKopecks(values.amount) ?? 0,
occurredOn: values.occurredOn,
parentOperationId: values.parentOperationId || undefined,
description: values.description || undefined,
},
{ onSuccess: onDone },
)
})
return (
<form onSubmit={submit} noValidate className="form-grid">
<AccountSelect
label={t('operations.refundAccount')}
accounts={accounts.data}
error={errors.accountId?.message}
registration={register('accountId')}
/>
<MoneyField label={t('common.amount')} error={errors.amount?.message} {...register('amount')} />
<TextField
label={t('operations.occurredOn')}
type="date"
error={errors.occurredOn?.message}
{...register('occurredOn')}
/>
<SelectField
label={t('operations.parentOperation')}
hint={t('operations.parentOperationHint')}
error={errors.parentOperationId?.message}
{...register('parentOperationId')}
>
<option value="">{t('common.choose')}</option>
{(recent.data?.items ?? []).map((operation) => (
<option key={operation.id} value={operation.id}>
{formatDate(operation.occurredOn, locale)} · {formatMoney(operation.amountKopecks, locale)}
</option>
))}
</SelectField>
<TextareaField
label={t('operations.description')}
error={errors.description?.message}
{...register('description')}
/>
<Button type="submit" disabled={create.isPending}>
{t('common.create')}
</Button>
</form>
)
}
function OperationModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const [kind, setKind] = useState<OperationKind>('expense')
return (
<Modal open onClose={onClose} title={t('operations.new')}>
<div className="kind-tabs" role="group" aria-label={t('operations.kindLabel')}>
{OPERATION_KINDS.map((option) => (
<Button
key={option}
variant={kind === option ? 'primary' : 'subtle'}
aria-pressed={kind === option}
onClick={() => setKind(option)}
>
{t(`operations.kind.${option}`)}
</Button>
))}
</div>
{kind === 'expense' ? <ExpenseForm onDone={onClose} /> : null}
{kind === 'income' ? <IncomeForm onDone={onClose} /> : null}
{kind === 'transfer' ? <TransferForm onDone={onClose} /> : null}
{kind === 'refund' ? <RefundForm onDone={onClose} /> : null}
</Modal>
)
}
function VoidModal({ operation, onClose }: { operation: Operation; onClose: () => void }) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const voidOperation = useVoidOperation()
const schema = z.object({ reason: z.string().min(3, t('operations.voidReasonHint')) })
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { reason: '' },
})
const submit = handleSubmit((values) => {
voidOperation.mutate(
{ id: operation.id, reason: values.reason },
{ onSuccess: onClose },
)
})
return (
<Modal open onClose={onClose} title={t('operations.voidTitle')}>
<form onSubmit={submit} noValidate className="form-grid">
<p className="muted">
{t('operations.voidBody', {
date: formatDate(operation.occurredOn, locale),
amount: formatMoney(operation.amountKopecks, locale),
})}
</p>
<TextareaField
label={t('operations.voidReason')}
error={errors.reason?.message}
{...register('reason')}
/>
<Button type="submit" variant="danger" disabled={voidOperation.isPending}>
{t('operations.void')}
</Button>
</form>
</Modal>
)
}
const PAGE_SIZE = 50
export function OperationsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const accounts = useActiveAccounts()
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [kind, setKind] = useState<'' | OperationKind>('')
const [status, setStatus] = useState<'' | OperationStatus>('')
const [accountId, setAccountId] = useState('')
const [uncategorizedOnly, setUncategorizedOnly] = useState(false)
const [occurredFrom, setOccurredFrom] = useState('')
const [occurredTo, setOccurredTo] = useState('')
const [cursor, setCursor] = useState<string | undefined>(undefined)
const [cursorStack, setCursorStack] = useState<string[]>([])
const filters = useMemo<OperationFilters>(
() => ({
limit: PAGE_SIZE,
cursor,
search: search || undefined,
kind: kind || undefined,
status: status || undefined,
accountId: accountId || undefined,
uncategorized: uncategorizedOnly || undefined,
occurredFrom: occurredFrom || undefined,
occurredTo: occurredTo || undefined,
}),
[cursor, search, kind, status, accountId, uncategorizedOnly, occurredFrom, occurredTo],
)
const operations = useOperations(filters)
const [createOpen, setCreateOpen] = useState(false)
const [voidTarget, setVoidTarget] = useState<Operation | null>(null)
const accountName = (id: string | null) =>
accounts.data?.find((account) => account.id === id)?.name ?? '—'
const goNext = () => {
const next = operations.data?.nextCursor
if (!next) return
setCursorStack((stack) => [...stack, cursor ?? ''])
setCursor(next)
}
const goPrev = () => {
if (cursorStack.length === 0) return
const previous = cursorStack[cursorStack.length - 1]
setCursorStack((stack) => stack.slice(0, -1))
setCursor(previous || undefined)
}
const resetPaging = () => {
setCursor(undefined)
setCursorStack([])
}
return (
<>
<PageHeader
title={t('operations.title')}
subtitle={t('operations.subtitle')}
actions={<Button onClick={() => setCreateOpen(true)}>+ {t('operations.new')}</Button>}
/>
<form
className="filters"
onSubmit={(event) => {
event.preventDefault()
setSearch(searchInput.trim())
resetPaging()
}}
>
<TextField
label={t('operations.search')}
type="search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
/>
<SelectField
label={t('operations.filterAccount')}
value={accountId}
onChange={(event) => {
setAccountId(event.target.value)
resetPaging()
}}
>
<option value="">{t('operations.filterAll')}</option>
{(accounts.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
</option>
))}
</SelectField>
<SelectField
label={t('operations.filterKind')}
value={kind}
onChange={(event) => {
setKind(event.target.value as '' | OperationKind)
resetPaging()
}}
>
<option value="">{t('operations.filterAll')}</option>
{OPERATION_KINDS.map((option) => (
<option key={option} value={option}>
{t(`operations.kind.${option}`)}
</option>
))}
</SelectField>
<SelectField
label={t('common.status')}
value={status}
onChange={(event) => {
setStatus(event.target.value as '' | OperationStatus)
resetPaging()
}}
>
<option value="">{t('operations.filterAll')}</option>
<option value="confirmed">{t('operations.status.confirmed')}</option>
<option value="voided">{t('operations.status.voided')}</option>
</SelectField>
<TextField
label={t('operations.filterFrom')}
type="date"
value={occurredFrom}
onChange={(event) => {
setOccurredFrom(event.target.value)
resetPaging()
}}
/>
<TextField
label={t('operations.filterTo')}
type="date"
value={occurredTo}
onChange={(event) => {
setOccurredTo(event.target.value)
resetPaging()
}}
/>
<label className="checkbox-field">
<input
type="checkbox"
checked={uncategorizedOnly}
onChange={(event) => {
setUncategorizedOnly(event.target.checked)
resetPaging()
}}
/>
{t('operations.uncategorizedOnly')}
</label>
</form>
<QueryGate query={operations} empty={<EmptyState title={t('operations.empty')} />}>
{(page) => (
<>
<table className="data-table">
<caption className="visually-hidden">{t('operations.title')}</caption>
<thead>
<tr>
<th scope="col">{t('common.date')}</th>
<th scope="col">{t('operations.kindLabel')}</th>
<th scope="col">{t('operations.description')}</th>
<th scope="col">{t('operations.account')}</th>
<th scope="col">{t('operations.category')}</th>
<th scope="col" className="num">
{t('common.amount')}
</th>
<th scope="col">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{page.items.map((operation) => {
const amount = operationAmount(operation, locale)
return (
<tr
key={operation.id}
data-voided={operation.status === 'voided' ? 'true' : undefined}
>
<th scope="row" data-label={t('common.date')}>
{formatDate(operation.occurredOn, locale)}
</th>
<td data-label={t('operations.kindLabel')}>
<Badge tone={OPERATION_KIND_TONES[operation.kind]}>
{t(`operations.kind.${operation.kind}`)}
</Badge>
{operation.status === 'voided' ? (
<Badge tone="neutral">{t('operations.status.voided')}</Badge>
) : null}
</td>
<td data-label={t('operations.description')}>
{operation.payeeName ?? operation.description ?? '—'}
</td>
<td data-label={t('operations.account')}>
{accountName(operation.accountId)}
{operation.kind === 'transfer'
? ` → ${accountName(operation.counterpartyAccountId)}`
: ''}
</td>
<td data-label={t('operations.category')}>
{operation.categoryName ?? t('operations.noCategory')}
</td>
<td data-label={t('common.amount')} className={`num ${amount.tone}`}>
{amount.text}
</td>
<td data-label={t('common.actions')}>
{operation.status === 'confirmed' ? (
<Button variant="subtle" onClick={() => setVoidTarget(operation)}>
{t('operations.void')}
</Button>
) : null}
</td>
</tr>
)
})}
</tbody>
</table>
<div className="pagination">
<Button variant="subtle" disabled={cursorStack.length === 0} onClick={goPrev}>
‹ {t('operations.prevPage')}
</Button>
<Button variant="subtle" disabled={!page.nextCursor} onClick={goNext}>
{t('operations.nextPage')} ›
</Button>
</div>
</>
)}
</QueryGate>
{createOpen ? <OperationModal onClose={() => setCreateOpen(false)} /> : null}
{voidTarget ? (
<VoidModal operation={voidTarget} onClose={() => setVoidTarget(null)} />
) : null}
</>
)
}
+136
View File
@@ -0,0 +1,136 @@
import { useMemo, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { z } from 'zod'
import { LangSwitcher } from '@/components/layout/LangSwitcher'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/fields'
import { useConfirmPasswordReset, useRequestPasswordReset } from '@/hooks/useAuth'
import { describeApiError } from '@/lib/apiErrors'
interface ResetValues {
email: string
password: string
confirm: string
}
export function ResetPasswordPage() {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const token = searchParams.get('token') ?? ''
const requestReset = useRequestPasswordReset()
const confirmReset = useConfirmPasswordReset()
const navigate = useNavigate()
const [sent, setSent] = useState(false)
const [apiError, setApiError] = useState<string | null>(null)
const schema = useMemo(
() =>
token
? z
.object({
email: z.string(),
password: z.string().min(12, t('auth.passwordHint')),
confirm: z.string().min(1, t('common.requiredField')),
})
.refine((values) => values.password === values.confirm, {
path: ['confirm'],
message: t('auth.passwordMismatch'),
})
: z.object({
email: z.string().min(1, t('common.requiredField')).email(t('auth.invalidEmail')),
password: z.string(),
confirm: z.string(),
}),
[t, token],
)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ResetValues>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', confirm: '' },
})
const pending = requestReset.isPending || confirmReset.isPending
const submit = handleSubmit((values) => {
setApiError(null)
if (!token) {
requestReset.mutate(values.email, {
onSuccess: () => setSent(true),
onError: (error) => setApiError(describeApiError(error, t)),
})
return
}
confirmReset.mutate(
{ token, password: values.password },
{
onSuccess: () => navigate('/accounts', { replace: true }),
onError: (error) => setApiError(describeApiError(error, t)),
},
)
})
return (
<div className="auth-page">
<div className="auth-card card">
<div className="auth-top">
<span className="brand">
<span className="brand-mark" aria-hidden="true" />
{t('app.brand')}
</span>
<LangSwitcher />
</div>
<h1 className="page-title">{t('auth.resetTitle')}</h1>
<p className="page-subtitle">{t('auth.resetSubtitle')}</p>
{apiError ? (
<div className="banner banner-danger" role="alert">
{apiError}
</div>
) : null}
{sent ? (
<div className="banner banner-success" role="status">
{t('auth.resetSent')}
</div>
) : (
<form onSubmit={submit} noValidate className="form-grid">
{token ? (
<>
<TextField
label={t('auth.password')}
type="password"
autoComplete="new-password"
hint={t('auth.passwordHint')}
error={errors.password?.message}
{...register('password')}
/>
<TextField
label={t('auth.passwordConfirm')}
type="password"
autoComplete="new-password"
error={errors.confirm?.message}
{...register('confirm')}
/>
</>
) : (
<TextField
label={t('auth.email')}
type="email"
autoComplete="email"
error={errors.email?.message}
{...register('email')}
/>
)}
<Button type="submit" disabled={pending}>
{token ? t('auth.resetSubmit') : t('auth.resetSend')}
</Button>
</form>
)}
<p className="auth-alt">
<Link to="/login">{t('auth.backToLogin')}</Link>
</p>
</div>
</div>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import type { BudgetCategory, Operation } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories } from '@/hooks/useBudget'
import { useCategorizeOperation, useOperations } from '@/hooks/useOperations'
import { useImports } from '@/hooks/useImports'
import { categoryLabel } from '@/lib/categoryLabel'
import { currentMonth, formatDateTime } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
function UncategorizedRow({
operation,
categories,
}: {
operation: Operation
categories: BudgetCategory[]
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const [categoryId, setCategoryId] = useState('')
const categorize = useCategorizeOperation()
return (
<tr>
<th scope="row" data-label={t('common.date')}>
{new Date(operation.occurredOn).toLocaleDateString(locale)}
</th>
<td data-label={t('operations.description')}>
{operation.payeeName ?? operation.description ?? '—'}
</td>
<td data-label={t('common.amount')} className="num neg">
−{new Intl.NumberFormat(locale, { style: 'currency', currency: 'RUB' }).format(operation.amountKopecks / 100)}
</td>
<td data-label={t('review.assignCategory')}>
<div className="inline-actions">
<select
className="input select"
aria-label={t('review.assignCategory')}
value={categoryId}
onChange={(event) => setCategoryId(event.target.value)}
>
<option value="">{t('operations.noCategory')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{categoryLabel(category, t)}
</option>
))}
</select>
<Button
variant="subtle"
disabled={!categoryId || categorize.isPending}
onClick={() => categorize.mutate({ id: operation.id, categoryId })}
>
{t('common.save')}
</Button>
</div>
</td>
</tr>
)
}
export function ReviewQueuePage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const imports = useImports()
const uncategorized = useOperations({ uncategorized: true, status: 'confirmed', limit: 50 })
const categories = useBudgetCategories(currentMonth())
const problematicImports = (imports.data ?? []).filter(
(item) => item.status === 'review_required' || item.status === 'failed',
)
const hasWork = problematicImports.length > 0 || (uncategorized.data?.items.length ?? 0) > 0
return (
<>
<PageHeader title={t('review.title')} subtitle={t('review.subtitle')} />
{imports.isPending || uncategorized.isPending ? null : !hasWork ? (
<EmptyState title={t('review.empty')} />
) : null}
{problematicImports.length > 0 ? (
<section aria-label={t('review.sectionImports')}>
<h2 className="section-title">{t('review.sectionImports')}</h2>
<table className="data-table">
<caption className="visually-hidden">{t('review.sectionImports')}</caption>
<thead>
<tr>
<th scope="col">{t('imports.fileName')}</th>
<th scope="col">{t('imports.bank')}</th>
<th scope="col">{t('common.status')}</th>
<th scope="col">{t('imports.createdAt')}</th>
<th scope="col">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{problematicImports.map((item) => (
<tr key={item.id}>
<th scope="row" data-label={t('imports.fileName')}>
{item.fileName}
</th>
<td data-label={t('imports.bank')}>{item.bankAdapter}</td>
<td data-label={t('common.status')}>
<Badge tone={IMPORT_STATUS_TONES[item.status]}>
{t(`imports.status.${item.status}`)}
</Badge>
</td>
<td data-label={t('imports.createdAt')}>
{formatDateTime(item.createdAt, locale)}
</td>
<td data-label={t('common.actions')}>
<Link className="btn btn-subtle" to={`/imports/${item.id}`}>
{t('review.openImport')}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</section>
) : null}
<section aria-label={t('review.sectionUncategorized')}>
<h2 className="section-title">{t('review.sectionUncategorized')}</h2>
<QueryGate
query={uncategorized}
empty={<EmptyState title={t('review.uncategorizedEmpty')} />}
>
{(page) =>
page.items.length === 0 ? (
<EmptyState title={t('review.uncategorizedEmpty')} />
) : (
<table className="data-table">
<caption className="visually-hidden">{t('review.sectionUncategorized')}</caption>
<thead>
<tr>
<th scope="col">{t('common.date')}</th>
<th scope="col">{t('operations.description')}</th>
<th scope="col" className="num">
{t('common.amount')}
</th>
<th scope="col">{t('review.assignCategory')}</th>
</tr>
</thead>
<tbody>
{page.items.map((operation) => (
<UncategorizedRow
key={operation.id}
operation={operation}
categories={categories.data ?? []}
/>
))}
</tbody>
</table>
)
}
</QueryGate>
</section>
</>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/fields'
import { LangSwitcher } from '@/components/layout/LangSwitcher'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useMe } from '@/hooks/useAuth'
import {
useCancelDeletion,
useRequestDeletion,
useRequestExport,
useSpaceDeletion,
} from '@/hooks/useSpaceLifecycle'
interface DeletionFormValues {
phrase: string
}
function DeletionForm() {
const { t } = useTranslation()
const requestDeletion = useRequestDeletion()
const schema = z.object({
phrase: z.string().refine((value) => value === 'DELETE', t('settings.phraseMismatch')),
})
const {
register,
handleSubmit,
formState: { errors },
} = useForm<DeletionFormValues>({
resolver: zodResolver(schema),
defaultValues: { phrase: '' },
})
const submit = handleSubmit((values) => {
requestDeletion.mutate(values.phrase)
})
return (
<form onSubmit={submit} noValidate className="form-grid">
<p className="muted">{t('settings.deletionHint')}</p>
<TextField
label={t('settings.deletionPhrase')}
hint={t('settings.deletionPhraseHint')}
error={errors.phrase?.message}
{...register('phrase')}
/>
<Button type="submit" variant="danger" disabled={requestDeletion.isPending}>
{t('settings.deletionRequest')}
</Button>
</form>
)
}
export function SettingsPage() {
const { t } = useTranslation()
const me = useMe()
const deletion = useSpaceDeletion()
const requestExport = useRequestExport()
const cancelDeletion = useCancelDeletion()
return (
<>
<PageHeader title={t('settings.title')} subtitle={t('settings.subtitle')} />
<section className="card" aria-label={t('settings.profile')}>
<h2 className="section-title">{t('settings.profile')}</h2>
<QueryGate query={me}>
{(user) => (
<dl className="kv">
<div>
<dt>{t('auth.email')}</dt>
<dd>{user.email}</dd>
</div>
<div>
<dt>{t('auth.fullName')}</dt>
<dd>{user.fullName}</dd>
</div>
<div>
<dt>{t('settings.timezone')}</dt>
<dd>{user.timezone}</dd>
</div>
<div>
<dt>{t('lang.label')}</dt>
<dd>
<LangSwitcher />
</dd>
</div>
</dl>
)}
</QueryGate>
</section>
<section className="card" aria-label={t('settings.dataTitle')}>
<h2 className="section-title">{t('settings.dataTitle')}</h2>
<p className="muted">{t('settings.exportHint')}</p>
<Button disabled={requestExport.isPending} onClick={() => requestExport.mutate()}>
{t('settings.export')}
</Button>
</section>
<section className="card card-danger" aria-label={t('settings.deletionTitle')}>
<h2 className="section-title">{t('settings.deletionTitle')}</h2>
<QueryGate query={deletion}>
{(status) =>
status.status === 'pending_deletion' ? (
<>
<div className="banner banner-warn" role="alert">
{t('settings.deletionPending', {
cancelsUntil: status.cancelsUntil ?? '',
})}
</div>
<Button
variant="subtle"
disabled={cancelDeletion.isPending}
onClick={() => cancelDeletion.mutate()}
>
{t('settings.deletionCancel')}
</Button>
</>
) : (
<>
<p>
<Badge tone="accent">{t('settings.deletionStatusActive')}</Badge>
</p>
<DeletionForm />
</>
)
}
</QueryGate>
</section>
</>
)
}
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { api } from '@/api/endpoints'
import type { Account } from '@/api/types'
import { AccountsPage } from '@/pages/AccountsPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
},
},
}))
const listMock = vi.mocked(api.accounts.list)
const createMock = vi.mocked(api.accounts.create)
function account(overrides: Partial<Account> = {}): Account {
return {
id: 'a1',
name: 'Alfa Card',
type: 'debit',
status: 'active',
bankCode: 'alfa',
maskedNumber: '•• 4021',
openedOn: '2026-01-15',
currency: 'RUB',
balanceKopecks: 123456,
...overrides,
}
}
describe('AccountsPage', () => {
beforeEach(() => {
listMock.mockResolvedValue([
account(),
account({
id: 'a2',
name: 'Credit One',
type: 'credit',
bankCode: null,
maskedNumber: null,
balanceKopecks: 450000,
}),
])
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders dense account rows with balances and debt', async () => {
const screen = renderWithProviders(<AccountsPage />)
expect(await screen.findByText('Alfa Card')).toBeInTheDocument()
expect(screen.getByText('Credit One')).toBeInTheDocument()
expect(screen.getAllByText(/1,234\.56/).length).toBeGreaterThan(0)
expect(screen.getAllByText(/4,500\.00/).length).toBeGreaterThan(0)
expect(screen.getByRole('table')).toBeInTheDocument()
})
it('creates an account through the dialog and confirms via toast', async () => {
createMock.mockResolvedValue(account({ id: 'a3', name: 'Cash wallet' }))
const screen = renderWithProviders(<AccountsPage />)
await screen.findByText('Alfa Card')
await userEvent.click(screen.getByRole('button', { name: '+ Add account' }))
const dialog = await screen.findByRole('dialog', { name: 'New account' })
expect(dialog).toBeInTheDocument()
await userEvent.type(screen.getByLabelText('Name'), 'Cash wallet')
await userEvent.clear(screen.getByLabelText('Opening position'))
await userEvent.type(screen.getByLabelText('Opening position'), '1500')
await userEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(createMock).toHaveBeenCalledTimes(1)
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Cash wallet',
type: 'debit',
openingBalanceKopecks: 150000,
openedOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
}),
)
expect(await screen.findByText('Account “Cash wallet” created')).toBeInTheDocument()
})
it('blocks invalid amounts before calling the API', async () => {
const screen = renderWithProviders(<AccountsPage />)
await screen.findByText('Alfa Card')
await userEvent.click(screen.getByRole('button', { name: '+ Add account' }))
await screen.findByRole('dialog', { name: 'New account' })
await userEvent.type(screen.getByLabelText('Name'), 'Broken')
await userEvent.clear(screen.getByLabelText('Opening position'))
await userEvent.type(screen.getByLabelText('Opening position'), '12.345')
await userEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(
await screen.findByText(
/Enter an amount in rubles, greater than zero/i,
),
).toBeInTheDocument()
expect(createMock).not.toHaveBeenCalled()
})
})
+105
View File
@@ -0,0 +1,105 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { api } from '@/api/endpoints'
import type { BudgetCategory, BudgetSummary } from '@/api/types'
import { BudgetPage } from '@/pages/BudgetPage'
import { currentMonth } from '@/lib/dates'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
},
},
}))
const summaryMock = vi.mocked(api.budget.summary)
const categoriesMock = vi.mocked(api.budget.categories)
const updatePlanMock = vi.mocked(api.budget.updatePlan)
const month = currentMonth()
const summary: BudgetSummary = {
month,
status: 'open',
projectedPoolKopecks: 8000000,
actualPoolKopecks: 7000000,
plannedTotalKopecks: 5000000,
fundedTotalKopecks: 4000000,
spentTotalKopecks: 1200000,
availableKopecks: 3000000,
warnings: [{ code: 'plan_exceeds_pool' }],
blockers: [],
expectedIncomes: [],
}
const categories: BudgetCategory[] = [
{
id: 'cat-1',
name: null,
nameKey: 'groceries',
kind: 'monthly',
archived: false,
creditAccountId: null,
plannedKopecks: 200000,
fundedKopecks: 150000,
spentKopecks: 50000,
availableKopecks: 100000,
},
{
id: 'cat-2',
name: 'Buffer',
nameKey: null,
kind: 'savings_envelope',
archived: false,
creditAccountId: null,
plannedKopecks: 0,
fundedKopecks: 0,
spentKopecks: 0,
availableKopecks: 0,
},
]
describe('BudgetPage plan editing', () => {
beforeEach(() => {
summaryMock.mockResolvedValue(summary)
categoriesMock.mockResolvedValue(categories)
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders month status, pool stats and warning banners', async () => {
const screen = renderWithProviders(<BudgetPage />)
expect(await screen.findByText('Groceries')).toBeInTheDocument()
expect(screen.getByText('Open')).toBeInTheDocument()
expect(screen.getByText('The plan exceeds the projected pool.')).toBeInTheDocument()
expect(screen.getAllByRole('table').length).toBeGreaterThanOrEqual(1)
})
it('edits a planned amount with kopeck conversion and scope-scoped invalidation', async () => {
updatePlanMock.mockResolvedValue(categories[0] as BudgetCategory)
const screen = renderWithProviders(<BudgetPage />)
await screen.findByText('Groceries')
await userEvent.click(screen.getByRole('button', { name: /2,000\.00/ }))
const dialog = await screen.findByRole('dialog', { name: 'Plan: Groceries' })
expect(dialog).toBeInTheDocument()
const amountInput = screen.getByLabelText('Planned')
await userEvent.clear(amountInput)
await userEvent.type(amountInput, '2500')
await userEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(updatePlanMock).toHaveBeenCalledTimes(1)
expect(updatePlanMock).toHaveBeenCalledWith('cat-1', expect.stringMatching(/^\d{4}-\d{2}$/), 250000)
})
})
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react'
import { HorizontalBars } from '@/components/charts/HorizontalBars'
import { ColumnChart } from '@/components/charts/ColumnChart'
const rows = [
{ key: 'g', label: 'Groceries', value: 500000 },
{ key: 't', label: 'Transport', value: 200000 },
]
describe('accessible chart alternatives', () => {
it('HorizontalBars always ships a real data table with caption and scoped headers', () => {
const screen = render(
<HorizontalBars
caption="Spending by category"
rows={rows}
formatValue={(value) => `${value / 100} RUB`}
/>,
)
const table = screen.container.querySelector('table')
expect(table).not.toBeNull()
const caption = table?.querySelector('caption')
expect(caption).toHaveTextContent('Spending by category')
const headers = table?.querySelectorAll('thead th[scope="col"]')
expect(headers?.length).toBe(2)
expect(table?.textContent).toContain('Groceries')
expect(table?.textContent).toContain('5000 RUB')
const bars = screen.container.querySelector('.hbars')
expect(bars?.getAttribute('aria-hidden')).toBe('true')
expect(screen.getByText('Show table')).toBeInTheDocument()
})
it('ColumnChart hides its decorative svg and exposes a month table', () => {
const screen = render(
<ColumnChart
caption="Cash flow by month"
formatValue={(value) => `${value / 100} RUB`}
legendIncome="Income"
legendExpense="Expenses"
columns={[
{ key: '2026-08', label: 'Aug', income: 900000, expense: 400000 },
{ key: '2026-09', label: 'Sep', income: 950000, expense: 300000 },
]}
extraColumns={{ label: 'Net', render: (column) => `${(column.income - column.expense) / 100} RUB` }}
/>,
)
const svg = screen.container.querySelector('svg')
expect(svg?.getAttribute('aria-hidden')).toBe('true')
const table = screen.container.querySelector('table')
expect(table?.querySelector('caption')).toHaveTextContent('Cash flow by month')
expect(table?.textContent).toContain('Aug')
expect(table?.textContent).toContain('5000 RUB')
})
})
+29
View File
@@ -0,0 +1,29 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render } from '@testing-library/react'
import type { ReactElement } from 'react'
import { MemoryRouter } from 'react-router-dom'
import { ToastProvider } from '@/components/ui/Toaster'
export function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}
export function renderWithProviders(
ui: ReactElement,
route = '/',
): ReturnType<typeof render> & { queryClient: QueryClient } {
const queryClient = createTestQueryClient()
const view = render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>{ui}</MemoryRouter>
</ToastProvider>
</QueryClientProvider>,
)
return { queryClient, ...view }
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import en from '@/i18n/locales/en.json'
import ru from '@/i18n/locales/ru.json'
const PLURAL_SUFFIX = /_(one|two|few|many|zero|other)$/
function collect(
value: unknown,
prefix = '',
out: Map<string, Set<string>> = new Map(),
): Map<string, Set<string>> {
if (typeof value === 'string') {
const stem = prefix.replace(PLURAL_SUFFIX, '')
const placeholders = out.get(stem) ?? new Set<string>()
for (const match of value.matchAll(/{{(\w+)}}/g)) {
placeholders.add(match[1] ?? '')
}
out.set(stem, placeholders)
return out
}
if (value !== null && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
collect(child, prefix ? `${prefix}.${key}` : key, out)
}
}
return out
}
const ruKeys = collect(ru)
const enKeys = collect(en)
describe('i18n locale parity', () => {
it('ru and en define exactly the same key stems', () => {
expect([...ruKeys.keys()].sort()).toEqual([...enKeys.keys()].sort())
})
it('uses identical interpolation placeholders in both locales', () => {
const mismatches: string[] = []
for (const [key, placeholders] of ruKeys) {
const enPlaceholders = enKeys.get(key) ?? new Set<string>()
if (JSON.stringify([...placeholders].sort()) !== JSON.stringify([...enPlaceholders].sort())) {
mismatches.push(key)
}
}
expect(mismatches).toEqual([])
})
it('defines a large shared vocabulary', () => {
expect(ruKeys.size).toBeGreaterThan(40)
expect(enKeys.size).toBeGreaterThan(40)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/api/endpoints'
import type { ImportDetail, ImportStatus } from '@/api/types'
import { useImport } from '@/hooks/useImports'
import {
IMPORT_POLL_MS,
importListPollInterval,
importPollInterval,
isTerminalImportStatus,
} from '@/lib/importStatus'
vi.mock('@/api/endpoints', () => ({
api: {
imports: {
get: vi.fn(),
},
},
}))
const getImport = vi.mocked(api.imports.get)
function detail(status: ImportStatus): ImportDetail {
return {
id: 'i1',
fileName: 'statement.pdf',
bankAdapter: 'alfa_debit_xlsx',
status,
accountId: 'a1',
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
createdAt: '2026-09-01T10:00:00Z',
failureReason: null,
blockers: [],
warnings: [],
reconciliation: {
status: 'not_available',
openingBalanceKopecks: null,
closingBalanceKopecks: null,
computedClosingKopecks: null,
reason: null,
},
rows: [],
totals: { total: 0, create: 0, discard: 0, duplicate: 0, transfer: 0 },
}
}
function makeWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
return function wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
}
describe('import status helpers', () => {
it('treats only uploaded/queued/processing as active', () => {
expect(isTerminalImportStatus('processing')).toBe(false)
expect(isTerminalImportStatus('queued')).toBe(false)
expect(isTerminalImportStatus('uploaded')).toBe(false)
expect(isTerminalImportStatus('ready')).toBe(true)
expect(isTerminalImportStatus('review_required')).toBe(true)
expect(isTerminalImportStatus('confirmed')).toBe(true)
expect(isTerminalImportStatus('failed')).toBe(true)
})
it('returns a poll interval only for active statuses', () => {
expect(importPollInterval('processing')).toBe(IMPORT_POLL_MS)
expect(importPollInterval('confirmed')).toBe(false)
expect(importPollInterval(undefined)).toBe(false)
expect(importListPollInterval(['confirmed', 'processing'])).toBe(IMPORT_POLL_MS)
expect(importListPollInterval(['confirmed', 'failed'])).toBe(false)
expect(importListPollInterval(undefined)).toBe(false)
})
})
describe('import detail polling', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('stops polling through the v5 refetchInterval callback once terminal', async () => {
getImport
.mockResolvedValueOnce(detail('processing'))
.mockResolvedValue(detail('confirmed'))
const { result } = renderHook(() => useImport('i1'), { wrapper: makeWrapper() })
await act(async () => {})
expect(getImport).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(IMPORT_POLL_MS + 100)
})
expect(getImport).toHaveBeenCalledTimes(2)
expect(result.current.data?.status).toBe('confirmed')
expect(result.current.error).toBeNull()
await act(async () => {
await vi.advanceTimersByTimeAsync(IMPORT_POLL_MS * 6)
})
expect(getImport).toHaveBeenCalledTimes(2)
})
it('keeps polling while the import stays active', async () => {
getImport.mockResolvedValue(detail('queued'))
renderHook(() => useImport('i1'), { wrapper: makeWrapper() })
await act(async () => {})
expect(getImport).toHaveBeenCalledTimes(1)
for (let tick = 0; tick < 3; tick += 1) {
await act(async () => {
await vi.advanceTimersByTimeAsync(IMPORT_POLL_MS + 100)
})
}
expect(getImport.mock.calls.length).toBeGreaterThanOrEqual(4)
})
})
+80
View File
@@ -0,0 +1,80 @@
import { Route, Routes } from 'react-router-dom'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { ApiError } from '@/api/client'
import { api } from '@/api/endpoints'
import type { User } from '@/api/types'
import { LoginPage } from '@/pages/LoginPage'
import { renderWithProviders } from './helpers'
import userEvent from '@testing-library/user-event'
vi.mock('@/api/endpoints', () => ({
api: {
auth: {
me: vi.fn(),
login: vi.fn(),
logout: vi.fn(),
acceptInvite: vi.fn(),
},
},
}))
const meMock = vi.mocked(api.auth.me)
const loginMock = vi.mocked(api.auth.login)
const user: User = {
id: 'u1',
email: 'user@example.com',
fullName: 'Test User',
locale: 'en',
timezone: 'Europe/Moscow',
}
function renderLogin() {
return renderWithProviders(
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/accounts" element={<div>accounts-page-reached</div>} />
</Routes>,
'/login',
)
}
describe('LoginPage', () => {
beforeEach(() => {
meMock.mockRejectedValue(new ApiError(401, 'unauthorized', null))
})
afterEach(() => {
vi.clearAllMocks()
})
it('shows validation errors and does not call the API on empty submit', async () => {
const screen = renderLogin()
await screen.findByRole('button', { name: 'Sign in' })
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(await screen.findAllByText('Required field')).toHaveLength(2)
expect(loginMock).not.toHaveBeenCalled()
})
it('submits credentials and navigates to accounts on success', async () => {
loginMock.mockResolvedValue(user)
const screen = renderLogin()
await screen.findByRole('button', { name: 'Sign in' })
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com')
await userEvent.type(screen.getByLabelText('Password'), 'correct-horse')
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(loginMock).toHaveBeenCalledWith({ email: 'user@example.com', password: 'correct-horse' })
expect(await screen.findByText('accounts-page-reached')).toBeInTheDocument()
})
it('surfaces a translated API error instead of navigating', async () => {
loginMock.mockRejectedValue(new ApiError(401, 'auth_invalid_credentials', null))
const screen = renderLogin()
await screen.findByRole('button', { name: 'Sign in' })
await userEvent.type(screen.getByLabelText('Email'), 'user@example.com')
await userEvent.type(screen.getByLabelText('Password'), 'wrong-password')
await userEvent.click(screen.getByRole('button', { name: 'Sign in' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Wrong email or password.')
expect(screen.queryByText('accounts-page-reached')).not.toBeInTheDocument()
})
})
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
formatMoney,
formatSignedMoney,
kopecksToInput,
parseAmountToKopecks,
sumKopecks,
} from '@/lib/money'
describe('money', () => {
it('parses plain, spaced and comma amounts into kopecks', () => {
expect(parseAmountToKopecks('1234.56')).toBe(123456)
expect(parseAmountToKopecks('1234,56')).toBe(123456)
expect(parseAmountToKopecks('1 234,56')).toBe(123456)
expect(parseAmountToKopecks('1500')).toBe(150000)
expect(parseAmountToKopecks('0.05')).toBe(5)
expect(parseAmountToKopecks('0.5')).toBe(50)
})
it('rejects malformed or over-precise amounts', () => {
expect(parseAmountToKopecks('')).toBeNull()
expect(parseAmountToKopecks('abc')).toBeNull()
expect(parseAmountToKopecks('1.234')).toBeNull()
expect(parseAmountToKopecks('-5')).toBe(-500)
expect(parseAmountToKopecks('12,34,56')).toBeNull()
})
it('round-trips kopecks back to input strings', () => {
expect(kopecksToInput(123456)).toBe('1234.56')
expect(kopecksToInput(150000)).toBe('1500')
expect(kopecksToInput(5)).toBe('0.05')
expect(kopecksToInput(-990)).toBe('-9.90')
})
it('formats RUB for ru and en locales', () => {
expect(formatMoney(123456, 'ru')).toContain('1')
expect(formatMoney(123456, 'ru')).toContain('234')
expect(formatMoney(123456, 'ru')).toContain('₽')
expect(formatMoney(123456, 'en')).toMatch(/1,234\.56/)
expect(formatSignedMoney(500, 'en', -1)).toContain('−')
expect(formatSignedMoney(500, 'en', 1)).toContain('+')
})
it('sums kopecks without float drift', () => {
expect(sumKopecks([1, 2, 3, 100])).toBe(106)
expect(sumKopecks([])).toBe(0)
expect(sumKopecks([3355443, 665544])).toBe(4020987)
})
})
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import {
SESSION_ROOT,
SPACE_ROOT,
invalidationRoots,
queryKeys,
} from '@/api/queryKeys'
import type { OperationFilters } from '@/api/types'
const SAMPLE_FILTERS: OperationFilters = { limit: 50, cursor: 'c1', search: 'coffee' }
const SAMPLE_KEYS: readonly (readonly [string, readonly unknown[]])[] = [
['accounts', queryKeys.accounts()],
['operations', queryKeys.operations(SAMPLE_FILTERS)],
['operations-empty', queryKeys.operations({})],
['budgetSummary', queryKeys.budgetSummary('2026-09')],
['budgetCategories', queryKeys.budgetCategories('2026-09')],
['imports', queryKeys.imports()],
['import', queryKeys.import('i1')],
['categorySpend', queryKeys.analyticsCategorySpend('2026-07', '2026-09')],
['planFact', queryKeys.analyticsPlanFact('2026-09')],
['cashFlow', queryKeys.analyticsCashFlow('2026-07', '2026-09')],
['balances', queryKeys.analyticsBalances()],
['deletion', queryKeys.spaceDeletion()],
] as const
describe('space-scoped query keys', () => {
it('scopes every space-data key under the single space root', () => {
for (const [name, key] of SAMPLE_KEYS) {
expect(key[0], name).toBe(SPACE_ROOT)
}
})
it('keeps the session key outside the space scope', () => {
expect(queryKeys.me()[0]).toBe(SESSION_ROOT)
expect(queryKeys.me()[0]).not.toBe(SPACE_ROOT)
})
it('nests import detail under the imports collection prefix', () => {
expect(queryKeys.import('i1').slice(0, 2)).toEqual([...queryKeys.imports()])
expect(queryKeys.import('i1')).toHaveLength(3)
expect(queryKeys.import('i2')).not.toEqual(queryKeys.import('i1'))
})
it('distinguishes different operation filters', () => {
const plain = JSON.stringify(queryKeys.operations({}))
const filtered = JSON.stringify(queryKeys.operations(SAMPLE_FILTERS))
expect(plain).not.toBe(filtered)
})
it('exposes invalidation roots that prefix their key families', () => {
expect([...queryKeys.accounts()]).toEqual([...invalidationRoots.accounts])
expect(queryKeys.import('i1').slice(0, 2)).toEqual([...invalidationRoots.imports])
expect(queryKeys.budgetSummary('2026-09').slice(0, 2)).toEqual([...invalidationRoots.budget])
expect(queryKeys.operations({}).slice(0, 2)).toEqual([...invalidationRoots.operations])
expect(queryKeys.analyticsBalances().slice(0, 2)).toEqual([...invalidationRoots.analytics])
expect(queryKeys.spaceDeletion()).toEqual([...invalidationRoots.deletion])
expect(invalidationRoots.space).toEqual([SPACE_ROOT])
})
})
+19
View File
@@ -0,0 +1,19 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
import { i18n } from '@/i18n'
afterEach(() => {
cleanup()
})
if (typeof HTMLDialogElement !== 'undefined') {
HTMLDialogElement.prototype.showModal ??= function showModal(this: HTMLDialogElement) {
this.setAttribute('open', '')
}
HTMLDialogElement.prototype.close ??= function closeDialog(this: HTMLDialogElement) {
this.removeAttribute('open')
}
}
await i18n.changeLanguage('en')
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />