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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
+2834
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Прочее"
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user