Update file metadata in manifest.json and change MinIO image source in docker-compose.yml

This commit is contained in:
k1nq
2026-09-23 09:23:13 +05:00
parent 8c2b89c80d
commit db4dd7572e
71 changed files with 31011 additions and 8308 deletions
+3
View File
@@ -1,2 +1,5 @@
packages:
- "."
allowBuilds:
esbuild@0.25.12: true
+24 -1
View File
@@ -2,13 +2,17 @@ import { apiClient } from './client'
import type {
AcceptInvitePayload,
Account,
AdjustBalancePayload,
AdjustBalanceResult,
BalancesReport,
BudgetCategory,
BudgetSummary,
CashFlowMonth,
CategoryDefinition,
CategorySpendItem,
ConfirmImportPayload,
CreateAccountPayload,
CreateCategoryPayload,
CreateOperationPayload,
ExportInfo,
ImportDetail,
@@ -17,8 +21,10 @@ import type {
Operation,
OperationFilters,
Page,
PatchCategoryDefinitionPayload,
PlanFactReport,
PasswordResetPayload,
ResolveHoldPayload,
SaveRowDecisionPayload,
SpaceDeletionStatus,
User,
@@ -42,6 +48,8 @@ export const api = {
apiClient.post<Account>('/accounts', { json: payload }),
archive: (id: string) =>
apiClient.patch<Account>(`/accounts/${id}`, { json: { status: 'archived' } }),
adjustBalance: (id: string, payload: AdjustBalancePayload) =>
apiClient.post<AdjustBalanceResult>(`/accounts/${id}/adjust-balance`, { json: payload }),
},
operations: {
list: (filters: OperationFilters) =>
@@ -52,6 +60,10 @@ export const api = {
apiClient.post<Operation>(`/operations/${id}/void`, { json: { reason } }),
categorize: (id: string, categoryId: string) =>
apiClient.patch<Operation>(`/operations/${id}`, { json: { categoryId } }),
savingsTransfer: (id: string) =>
apiClient.post<Operation>(`/operations/${id}/savings-transfer`),
savingsTransferRevert: (id: string) =>
apiClient.post<Operation>(`/operations/${id}/savings-transfer/revert`),
},
budget: {
summary: (month: string) =>
@@ -70,9 +82,18 @@ export const api = {
reopen: (month: string) => apiClient.post<BudgetSummary>('/budget/reopen', { json: { month } }),
copyPrevious: (month: string) =>
apiClient.post<BudgetSummary>('/budget/copy-previous', { json: { month } }),
categoryDefinitions: (includeArchived: boolean) =>
apiClient.get<CategoryDefinition[]>('/budget/category-definitions', {
query: { includeArchived },
}),
createCategory: (payload: CreateCategoryPayload) =>
apiClient.post<CategoryDefinition>('/budget/categories', { json: payload }),
updateCategoryDefinition: (id: string, payload: PatchCategoryDefinitionPayload) =>
apiClient.patch<CategoryDefinition>(`/budget/category-definitions/${id}`, { json: payload }),
},
imports: {
list: () => apiClient.get<ImportSummary[]>('/imports'),
list: (month?: string) =>
apiClient.get<ImportSummary[]>('/imports', { query: { month } }),
get: (id: string) => apiClient.get<ImportDetail>(`/imports/${id}`),
upload: (file: File, accountId: string) => {
const formData = new FormData()
@@ -82,6 +103,8 @@ export const api = {
},
saveRowDecision: (id: string, rowId: string, payload: SaveRowDecisionPayload) =>
apiClient.patch<ImportDetail>(`/imports/${id}/rows/${rowId}`, { json: payload }),
resolveHold: (id: string, rowId: string, payload: ResolveHoldPayload) =>
apiClient.post<ImportDetail>(`/imports/${id}/rows/${rowId}/resolve`, { json: payload }),
confirm: (id: string, payload: ConfirmImportPayload) =>
apiClient.post<ImportDetail>(`/imports/${id}/confirm`, { json: payload }),
reparse: (id: string) => apiClient.post<ImportDetail>(`/imports/${id}/reparse`),
+555 -8
View File
@@ -89,6 +89,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/auth/password-reset": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Request Password Reset */
post: operations["request_password_reset_api_v1_auth_password_reset_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/password-reset/confirm": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Confirm Password Reset */
post: operations["confirm_password_reset_api_v1_auth_password_reset_confirm_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/me": {
parameters: {
query?: never;
@@ -140,6 +174,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/spaces/current/invitations": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Invite Member */
post: operations["invite_member_api_v1_spaces_current_invitations_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/spaces/current/deletion": {
parameters: {
query?: never;
@@ -264,6 +315,57 @@ export interface paths {
patch: operations["patch_operation_api_v1_operations__operation_id__patch"];
trace?: never;
};
"/api/v1/accounts/{account_id}/adjust-balance": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Adjust Balance */
post: operations["adjust_balance_api_v1_accounts__account_id__adjust_balance_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operations/{operation_id}/savings-transfer": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Savings Transfer */
post: operations["savings_transfer_api_v1_operations__operation_id__savings_transfer_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/operations/{operation_id}/savings-transfer/revert": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Savings Transfer Revert */
post: operations["savings_transfer_revert_api_v1_operations__operation_id__savings_transfer_revert_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/budget/summary": {
parameters: {
query?: never;
@@ -299,6 +401,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/budget/category-definitions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Category Definitions */
get: operations["category_definitions_api_v1_budget_category_definitions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/budget/category-definitions/{category_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/** Patch Category Definition */
patch: operations["patch_category_definition_api_v1_budget_category_definitions__category_id__patch"];
trace?: never;
};
"/api/v1/budget/categories/{category_id}": {
parameters: {
query?: never;
@@ -555,6 +691,23 @@ export interface paths {
patch: operations["decide_row_api_v1_imports__document_id__rows__row_id__patch"];
trace?: never;
};
"/api/v1/imports/{document_id}/rows/{row_id}/resolve": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Resolve Hold */
post: operations["resolve_hold_api_v1_imports__document_id__rows__row_id__resolve_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/imports/{document_id}/confirm": {
parameters: {
query?: never;
@@ -732,10 +885,10 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Download */
post: operations["download_api_v1_exports__export_id__download_post"];
get: operations["download_api_v1_exports__export_id__download_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
@@ -790,6 +943,18 @@ export interface components {
/** Maskednumber */
maskedNumber?: string | null;
};
/** AdjustBalanceBody */
AdjustBalanceBody: {
/** Targetbalancekopecks */
targetBalanceKopecks: number;
/**
* Occurredon
* Format: date
*/
occurredOn: string;
/** Reason */
reason: string;
};
/** AssignmentItem */
AssignmentItem: {
/**
@@ -823,6 +988,13 @@ export interface components {
*/
kind: "monthly" | "savings_envelope";
};
/** CategoryDefinitionPatchBody */
CategoryDefinitionPatchBody: {
/** Name */
name?: string | null;
/** Archived */
archived?: boolean | null;
};
/** CategoryFundingBody */
CategoryFundingBody: {
/** Month */
@@ -889,6 +1061,15 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** HoldResolveBody */
HoldResolveBody: {
/** Action */
action: string;
/** Reason */
reason: string;
/** Accountid */
accountId?: string | null;
};
/** InviteBody */
InviteBody: {
/**
@@ -907,6 +1088,14 @@ export interface components {
/** Password */
password: string;
};
/** MemberInviteBody */
MemberInviteBody: {
/**
* Email
* Format: email
*/
email: string;
};
/** MonthBody */
MonthBody: {
/** Month */
@@ -957,6 +1146,21 @@ export interface components {
/** Plannedkopecks */
plannedKopecks: number;
};
/** ResetBody */
ResetBody: {
/** Token */
token: string;
/** Password */
password: string;
};
/** ResetRequestBody */
ResetRequestBody: {
/**
* Email
* Format: email
*/
email: string;
};
/** ReviewBody */
ReviewBody: {
/**
@@ -1150,6 +1354,76 @@ export interface operations {
};
};
};
request_password_reset_api_v1_auth_password_reset_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ResetRequestBody"];
};
};
responses: {
/** @description Successful Response */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
confirm_password_reset_api_v1_auth_password_reset_confirm_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ResetBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
me_api_v1_auth_me_get: {
parameters: {
query?: never;
@@ -1245,6 +1519,45 @@ export interface operations {
};
};
};
invite_member_api_v1_spaces_current_invitations_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["MemberInviteBody"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
deletion_status_api_v1_spaces_current_deletion_get: {
parameters: {
query?: never;
@@ -1541,6 +1854,7 @@ export interface operations {
occurredFrom?: string | null;
occurredTo?: string | null;
search?: string | null;
uncategorized?: boolean;
limit?: number;
cursor?: string | null;
};
@@ -1696,6 +2010,121 @@ export interface operations {
};
};
};
adjust_balance_api_v1_accounts__account_id__adjust_balance_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
account_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["AdjustBalanceBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
savings_transfer_api_v1_operations__operation_id__savings_transfer_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
operation_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
savings_transfer_revert_api_v1_operations__operation_id__savings_transfer_revert_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
operation_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
summary_api_v1_budget_summary_get: {
parameters: {
query: {
@@ -1805,6 +2234,82 @@ export interface operations {
};
};
};
category_definitions_api_v1_budget_category_definitions_get: {
parameters: {
query?: {
includeArchived?: boolean;
};
header?: never;
path?: never;
cookie?: {
fin_session?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
}[];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
patch_category_definition_api_v1_budget_category_definitions__category_id__patch: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
category_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["CategoryDefinitionPatchBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_plan_api_v1_budget_categories__category_id__patch: {
parameters: {
query?: never;
@@ -2246,7 +2751,9 @@ export interface operations {
};
imports_api_v1_imports_get: {
parameters: {
query?: never;
query?: {
month?: string | null;
};
header?: never;
path?: never;
cookie?: {
@@ -2434,6 +2941,48 @@ export interface operations {
};
};
};
resolve_hold_api_v1_imports__document_id__rows__row_id__resolve_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
document_id: string;
row_id: string;
};
cookie?: {
fin_session?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["HoldResolveBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
confirm_api_v1_imports__document_id__confirm_post: {
parameters: {
query?: never;
@@ -2796,12 +3345,10 @@ export interface operations {
};
};
};
download_api_v1_exports__export_id__download_post: {
download_api_v1_exports__export_id__download_get: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
header?: never;
path: {
export_id: string;
};
+3 -1
View File
@@ -23,8 +23,10 @@ export const queryKeys = {
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,
imports: (month?: string) =>
month ? ([SPACE_ROOT, 'imports', month] as const) : ([SPACE_ROOT, 'imports'] as const),
import: (id: string) => [SPACE_ROOT, 'imports', id] as const,
budgetCategoryDefinitions: () => [SPACE_ROOT, 'budget', 'category-definitions'] 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,
+82 -4
View File
@@ -8,7 +8,7 @@ export interface User {
timezone: string
}
export type AccountType = 'cash' | 'debit' | 'credit'
export type AccountType = 'cash' | 'debit' | 'credit' | 'savings'
export type AccountStatus = 'active' | 'archived'
export interface Account {
@@ -20,9 +20,15 @@ export interface Account {
maskedNumber: string | null
openedOn: string
currency: 'RUB'
systemKey: string | null
nameKey: string | null
balanceKopecks: number
}
export function isSystemAccount(account: Account): boolean {
return account.systemKey !== null
}
export interface CreateAccountPayload {
name: string
type: AccountType
@@ -32,7 +38,7 @@ export interface CreateAccountPayload {
maskedNumber?: string
}
export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund'
export type OperationKind = 'expense' | 'income' | 'transfer' | 'refund' | 'adjustment'
export type OperationStatus = 'confirmed' | 'voided'
export interface Operation {
@@ -51,10 +57,22 @@ export interface Operation {
incomeSourceName: string | null
description: string | null
parentOperationId: string | null
convertedFromExpense: boolean
version: number
createdAt: string
}
export interface AdjustBalancePayload {
targetBalanceKopecks: number
occurredOn: string
reason: string
}
export interface AdjustBalanceResult extends Operation {
oldBalanceKopecks: number
newBalanceKopecks: number
}
export type CreateOperationPayload =
| {
kind: 'expense'
@@ -131,11 +149,25 @@ export interface BudgetWarning {
message?: string
}
export interface BudgetBlocker {
export interface CountBlocker {
code: 'IMPORTS_UNRESOLVED' | 'IMPORT_UNRESOLVED_HOLDS' | 'BUDGET_UNCATEGORIZED_EXPENSES'
count: number
message?: string
}
export interface CategoriesBlocker {
code: 'BUDGET_NEGATIVE_ENVELOPE'
categories: string[]
message?: string
}
export interface UnknownBlocker {
code: string
message?: string
}
export type BudgetBlocker = CountBlocker | CategoriesBlocker | UnknownBlocker
export type ExpectedIncomeStatus = 'expected' | 'partially_matched' | 'matched'
export interface ExpectedIncome {
@@ -162,12 +194,14 @@ export interface BudgetSummary {
}
export type CategoryKind = 'monthly' | 'savings_envelope' | 'credit_reserve'
export type CreatableCategoryKind = 'monthly' | 'savings_envelope'
export interface BudgetCategory {
id: string
name: string | null
nameKey: string | null
kind: CategoryKind
system: boolean
archived: boolean
creditAccountId: string | null
plannedKopecks: number
@@ -176,6 +210,26 @@ export interface BudgetCategory {
availableKopecks: number
}
export interface CategoryDefinition {
id: string
name: string | null
nameKey: string | null
kind: CategoryKind
systemKey: string | null
linkedAccountId: string | null
archived: boolean
}
export interface CreateCategoryPayload {
name: string
kind: CreatableCategoryKind
}
export interface PatchCategoryDefinitionPayload {
name?: string
archived?: boolean
}
export type ImportStatus =
| 'uploaded'
| 'queued'
@@ -194,6 +248,7 @@ export interface ImportSummary {
accountName: string | null
periodFrom: string | null
periodTo: string | null
unresolvedHoldCount: number
createdAt: string
}
@@ -213,7 +268,20 @@ export interface Reconciliation {
reason: string | null
}
export type RowDecision = 'pending' | 'create' | 'discard' | 'duplicate' | 'transfer'
export type RowDecision = 'pending' | 'create' | 'discard' | 'duplicate' | 'transfer' | 'savings_transfer'
export type RowBankStatus = 'posted' | 'hold'
export type HoldResolutionAction = 'confirm_as_posted' | 'discard_as_cancelled'
export interface HoldResolution {
resolution: 'confirmed_as_posted' | 'discarded_as_cancelled'
resolvingSourceRecordId: string | null
operationId: string | null
resolvedByUserId: string | null
reason: string | null
resolvedAt: string | null
}
export interface ImportRow {
id: string
@@ -221,11 +289,13 @@ export interface ImportRow {
occurredOn: string
amountKopecks: number
direction: 'in' | 'out'
bankStatus: RowBankStatus
description: string | null
payeeHint: string | null
warnings: string[]
decision: RowDecision
duplicateOfOperationId: string | null
holdResolution: HoldResolution | null
}
export interface ImportDetail extends ImportSummary {
@@ -240,6 +310,8 @@ export interface ImportDetail extends ImportSummary {
discard: number
duplicate: number
transfer: number
savingsTransfer: number
pending: number
}
}
@@ -248,6 +320,12 @@ export interface SaveRowDecisionPayload {
duplicateOfOperationId?: string
}
export interface ResolveHoldPayload {
action: HoldResolutionAction
reason: string
accountId?: string
}
export interface ConfirmImportPayload {
confirmedWithoutFullReconciliation?: boolean
reason?: string
@@ -0,0 +1,77 @@
import { useTranslation } from 'react-i18next'
import type { TFunction } from 'i18next'
import { Link } from 'react-router-dom'
import type { BudgetBlocker } from '@/api/types'
interface Cta {
to: string
label: string
}
function blockerText(blocker: BudgetBlocker, t: TFunction): string {
if (blocker.code === 'IMPORTS_UNRESOLVED' && 'count' in blocker) {
return t('budget.blockers.IMPORTS_UNRESOLVED', { count: blocker.count })
}
if (blocker.code === 'IMPORT_UNRESOLVED_HOLDS' && 'count' in blocker) {
return t('budget.blockers.IMPORT_UNRESOLVED_HOLDS', { count: blocker.count })
}
if (blocker.code === 'BUDGET_UNCATEGORIZED_EXPENSES' && 'count' in blocker) {
return t('budget.blockers.BUDGET_UNCATEGORIZED_EXPENSES', { count: blocker.count })
}
if (blocker.code === 'BUDGET_NEGATIVE_ENVELOPE' && 'categories' in blocker) {
return t('budget.blockers.BUDGET_NEGATIVE_ENVELOPE', {
categories: blocker.categories.join(', '),
})
}
console.warn('[budget] unknown blocker code', blocker.code)
return t('budget.blockers.unknown')
}
function blockerCta(blocker: BudgetBlocker, month: string, t: TFunction): Cta | null {
if (
blocker.code === 'IMPORTS_UNRESOLVED' ||
blocker.code === 'IMPORT_UNRESOLVED_HOLDS' ||
blocker.code === 'BUDGET_UNCATEGORIZED_EXPENSES'
) {
const ctaKey = {
IMPORTS_UNRESOLVED: 'budget.blockers.cta.imports',
IMPORT_UNRESOLVED_HOLDS: 'budget.blockers.cta.holds',
BUDGET_UNCATEGORIZED_EXPENSES: 'budget.blockers.cta.uncategorized',
} as const
const to =
blocker.code === 'IMPORT_UNRESOLVED_HOLDS'
? `/review?month=${month}#holds`
: `/review?month=${month}`
return { to, label: t(ctaKey[blocker.code]) }
}
if (blocker.code === 'BUDGET_NEGATIVE_ENVELOPE') {
return { to: '/budget#categories', label: t('budget.blockers.cta.envelope') }
}
return null
}
interface BudgetBlockersProps {
blockers: readonly BudgetBlocker[]
month: string
}
export function BudgetBlockers({ blockers, month }: BudgetBlockersProps) {
const { t } = useTranslation()
return (
<ul className="blocker-list">
{blockers.map((blocker) => {
const cta = blockerCta(blocker, month, t)
return (
<li key={blocker.code} className="blocker-item">
<span>{blockerText(blocker, t)}</span>
{cta ? (
<Link className="blocker-link" to={cta.to}>
{cta.label}
</Link>
) : null}
</li>
)
})}
</ul>
)
}
@@ -0,0 +1,222 @@
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 { CategoryDefinition, CreatableCategoryKind } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { SelectField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { QueryGate } from '@/components/ui/QueryGate'
import {
useCategoryDefinitions,
useCreateCategory,
useUpdateCategoryDefinition,
} from '@/hooks/useBudget'
import { categoryLabel } from '@/lib/categoryLabel'
const CREATE_KINDS: readonly CreatableCategoryKind[] = ['monthly', 'savings_envelope']
function CreateCategoryForm() {
const { t } = useTranslation()
const create = useCreateCategory()
const schema = z.object({
name: z.string().trim().min(1, t('common.requiredField')),
kind: z.enum(['monthly', 'savings_envelope']),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { name: '', kind: 'monthly' },
})
const submit = handleSubmit((values) => {
create.mutate(values, { onSuccess: () => reset({ name: '', kind: values.kind }) })
})
return (
<form className="manager-form" onSubmit={submit} noValidate>
<TextField
label={t('common.name')}
error={errors.name?.message}
{...register('name')}
/>
<SelectField label={t('accounts.type')} error={errors.kind?.message} {...register('kind')}>
{CREATE_KINDS.map((kind) => (
<option key={kind} value={kind}>
{t(`budget.category.kind.${kind}`)}
</option>
))}
</SelectField>
<Button type="submit" disabled={create.isPending}>
{create.isPending ? t('common.saving') : t('common.create')}
</Button>
</form>
)
}
function RenameModal({
definition,
onClose,
}: {
definition: CategoryDefinition
onClose: () => void
}) {
const { t } = useTranslation()
const update = useUpdateCategoryDefinition()
const schema = z.object({
name: z.string().trim().min(1, t('common.requiredField')),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { name: definition.name ?? '' },
})
const submit = handleSubmit((values) => {
update.mutate(
{ id: definition.id, payload: { name: values.name } },
{ onSuccess: onClose },
)
})
return (
<Modal open onClose={onClose} title={t('budget.manager.renameTitle')}>
<form onSubmit={submit} noValidate className="form-grid">
<TextField label={t('common.name')} error={errors.name?.message} {...register('name')} />
<Button type="submit" disabled={update.isPending}>
{update.isPending ? t('common.saving') : t('common.save')}
</Button>
</form>
</Modal>
)
}
export function CategoryManagerModal({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const definitions = useCategoryDefinitions()
const update = useUpdateCategoryDefinition()
const [tab, setTab] = useState<'active' | 'archived'>('active')
const [renameTarget, setRenameTarget] = useState<CategoryDefinition | null>(null)
const [archiveTarget, setArchiveTarget] = useState<CategoryDefinition | null>(null)
return (
<Modal open onClose={onClose} title={t('budget.manager.title')}>
<CreateCategoryForm />
<div className="manager-tabs" role="group" aria-label={t('budget.manager.tabsLabel')}>
<Button
variant={tab === 'active' ? 'primary' : 'subtle'}
aria-pressed={tab === 'active'}
onClick={() => setTab('active')}
>
{t('budget.manager.tabActive')}
</Button>
<Button
variant={tab === 'archived' ? 'primary' : 'subtle'}
aria-pressed={tab === 'archived'}
onClick={() => setTab('archived')}
>
{t('budget.manager.tabArchive')}
</Button>
</div>
<QueryGate query={definitions}>
{(all) => {
const list = all.filter((item) => (tab === 'active' ? !item.archived : item.archived))
if (list.length === 0) {
return (
<p className="muted">
{tab === 'active'
? t('budget.manager.emptyActive')
: t('budget.manager.emptyArchive')}
</p>
)
}
return (
<ul className="manager-list">
{list.map((definition) => {
const isSystem = definition.systemKey !== null
return (
<li key={definition.id} className="manager-row">
<div className="manager-row-main">
<span className="manager-row-name">
{categoryLabel(definition, t)}
{isSystem ? (
<Badge tone="info">{t('budget.manager.systemBadge')}</Badge>
) : null}
{definition.archived ? (
<Badge tone="neutral">{t('accounts.status.archived')}</Badge>
) : null}
</span>
<span className="manager-row-meta">
{t(`budget.category.kind.${definition.kind}`)}
{isSystem ? ` · ${t('budget.manager.systemProtected')}` : ''}
</span>
</div>
{!isSystem ? (
<div className="inline-actions manager-row-actions">
{!definition.archived ? (
<>
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setRenameTarget(definition)}
>
{t('budget.manager.rename')}
</Button>
<Button
variant="subtle"
disabled={update.isPending}
onClick={() => setArchiveTarget(definition)}
>
{t('budget.manager.archive')}
</Button>
</>
) : (
<Button
variant="subtle"
disabled={update.isPending}
onClick={() =>
update.mutate({ id: definition.id, payload: { archived: false } })
}
>
{t('budget.manager.restore')}
</Button>
)}
</div>
) : null}
</li>
)
})}
</ul>
)
}}
</QueryGate>
{renameTarget ? (
<RenameModal definition={renameTarget} onClose={() => setRenameTarget(null)} />
) : null}
<ConfirmDialog
open={archiveTarget !== null}
danger
title={t('budget.manager.archiveTitle')}
body={t('budget.manager.archiveBody', { name: archiveTarget?.name ?? '' })}
confirmLabel={t('budget.manager.archive')}
onClose={() => setArchiveTarget(null)}
onConfirm={() => {
if (archiveTarget) {
update.mutate({ id: archiveTarget.id, payload: { archived: true } })
}
}}
/>
</Modal>
)
}
+1 -1
View File
@@ -16,7 +16,7 @@ const NAV_ITEMS = [
] as const
const MOBILE_NAV_ITEMS = NAV_ITEMS.filter((item) =>
['/', '/operations', '/imports', '/analytics', '/settings'].includes(item.to),
['/', '/budget', '/operations', '/imports', '/review', '/accounts'].includes(item.to),
)
export function AppLayout() {
+27 -1
View File
@@ -1,7 +1,8 @@
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 type { AdjustBalancePayload, CreateAccountPayload } from '@/api/types'
import { isSystemAccount } from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
@@ -15,6 +16,13 @@ export function useActiveAccounts() {
return { ...accounts, data: accounts.data?.filter((account) => account.status === 'active') }
}
/** Active non-system accounts: the savings system account is not a valid
* target for statements, expenses, incomes or refunds. */
export function useUserAccounts() {
const accounts = useActiveAccounts()
return { ...accounts, data: accounts.data?.filter((account) => !isSystemAccount(account)) }
}
export function useCreateAccount() {
const queryClient = useQueryClient()
const { t } = useTranslation()
@@ -44,3 +52,21 @@ export function useArchiveAccount() {
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useAdjustBalance() {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: AdjustBalancePayload }) =>
api.accounts.adjustBalance(id, payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
toast.push('success', t('toast.balanceAdjusted'))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
+59
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from '@/api/endpoints'
import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type { CreateCategoryPayload, PatchCategoryDefinitionPayload } from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
@@ -16,6 +17,64 @@ export function useBudgetCategories(month: string) {
})
}
export function useCategoryDefinitions() {
return useQuery({
queryKey: queryKeys.budgetCategoryDefinitions(),
queryFn: () => api.budget.categoryDefinitions(true),
})
}
function useCategoryDefinitionInvalidation() {
const queryClient = useQueryClient()
return () => {
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.analytics })
}
}
export function useCreateCategory() {
const invalidate = useCategoryDefinitionInvalidation()
const { t } = useTranslation()
const toast = useToast()
const onError = useBudgetErrorToaster()
return useMutation({
mutationFn: (payload: CreateCategoryPayload) => api.budget.createCategory(payload),
onSuccess: (definition) => {
invalidate()
toast.push('success', t('toast.categoryCreated', { name: definition.name ?? '' }))
},
onError,
})
}
export function useUpdateCategoryDefinition() {
const invalidate = useCategoryDefinitionInvalidation()
const { t } = useTranslation()
const toast = useToast()
const onError = useBudgetErrorToaster()
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string
payload: PatchCategoryDefinitionPayload
}) => api.budget.updateCategoryDefinition(id, payload),
onSuccess: (definition, variables) => {
invalidate()
if (variables.payload.archived === true) {
toast.push('success', t('toast.categoryArchived'))
} else if (variables.payload.archived === false) {
toast.push('success', t('toast.categoryRestored'))
} else {
toast.push('success', t('toast.categoryRenamed', { name: definition.name ?? '' }))
}
},
onError,
})
}
function useBudgetErrorToaster() {
const { t } = useTranslation()
const toast = useToast()
+23 -3
View File
@@ -4,6 +4,7 @@ import { invalidationRoots, queryKeys } from '@/api/queryKeys'
import type {
ConfirmImportPayload,
ImportStatus,
ResolveHoldPayload,
SaveRowDecisionPayload,
} from '@/api/types'
import { useToast } from '@/components/ui/Toaster'
@@ -11,10 +12,10 @@ import { useTranslation } from 'react-i18next'
import { describeApiError } from '@/lib/apiErrors'
import { importListPollInterval, importPollInterval } from '@/lib/importStatus'
export function useImports() {
export function useImports(month?: string) {
return useQuery({
queryKey: queryKeys.imports(),
queryFn: api.imports.list,
queryKey: queryKeys.imports(month),
queryFn: () => api.imports.list(month),
refetchInterval: (query) => {
const statuses = query.state.data?.map((item) => item.status as ImportStatus)
return importListPollInterval(statuses)
@@ -67,6 +68,25 @@ export function useSaveRowDecision(importId: string) {
})
}
export function useResolveHold(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
const toast = useToast()
return useMutation({
mutationFn: ({ rowId, payload }: { rowId: string; payload: ResolveHoldPayload }) =>
api.imports.resolveHold(importId, rowId, payload),
onSuccess: (updated) => {
queryClient.setQueryData(queryKeys.import(importId), updated)
void queryClient.invalidateQueries({ queryKey: invalidationRoots.imports })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.operations })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.accounts })
void queryClient.invalidateQueries({ queryKey: invalidationRoots.budget })
toast.push('success', t('toast.holdResolved'))
},
onError: (error) => toast.push('error', describeApiError(error, t)),
})
}
export function useConfirmImport(importId: string) {
const queryClient = useQueryClient()
const { t } = useTranslation()
+14
View File
@@ -55,3 +55,17 @@ export function useCategorizeOperation() {
'toast.categoryAssigned',
)
}
export function useConvertToSavings() {
return useLedgerMutation(
({ id }: { id: string }) => api.operations.savingsTransfer(id),
'toast.convertedToSavings',
)
}
export function useRevertSavingsConversion() {
return useLedgerMutation(
({ id }: { id: string }) => api.operations.savingsTransferRevert(id),
'toast.savingsConversionReverted',
)
}
+103 -8
View File
@@ -88,7 +88,23 @@
"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."
"session_expired": "Your session has expired. Please sign in again.",
"LEDGER_ADJUST_NOOP": "The balance already equals the target — nothing to adjust.",
"LEDGER_ADJUST_TARGET_NEGATIVE": "The target balance cannot be negative.",
"LEDGER_ADJUST_REASON_REQUIRED": "An adjustment reason is required.",
"LEDGER_ACCOUNT_ARCHIVED": "The account is archived — restore it first.",
"LEDGER_SAVINGS_CONVERT_INVALID": "Only a confirmed expense in an open month can be converted to savings.",
"LEDGER_SAVINGS_REVERT_INVALID": "Only a previously converted transfer can be reverted to an expense.",
"LEDGER_SAVINGS_CONFLICT": "A system savings resource conflict was detected.",
"BUDGET_PERIOD_CLOSED": "The month is closed. Reopen it first.",
"BUDGET_CATEGORY_SYSTEM": "System categories cannot be renamed or archived.",
"BUDGET_CATEGORY_ARCHIVED": "The category is archived — restore it first.",
"BUDGET_CATEGORY_NAME_REQUIRED": "A category name is required.",
"BUDGET_CATEGORY_KIND_INVALID": "Unknown category kind.",
"IMPORT_HOLD_REASON_REQUIRED": "A resolution reason is required.",
"IMPORT_HOLD_ALREADY_RESOLVED": "This operation already has a resolution.",
"IMPORT_HOLD_DOCUMENT_NOT_CONFIRMED": "Holds can be resolved only after the import is applied.",
"IMPORT_HOLD_ACCOUNT_REQUIRED": "The statement account mapping is missing."
},
"auth": {
"loginTitle": "Sign in",
@@ -120,7 +136,7 @@
},
"accounts": {
"title": "Accounts",
"subtitle": "Debit, cash and credit accounts of the space.",
"subtitle": "Debit, cash, credit accounts and the system savings account.",
"add": "Add account",
"empty": "No accounts yet. Create your first account to start tracking.",
"formTitle": "New account",
@@ -128,6 +144,8 @@
"type.cash": "Cash",
"type.debit": "Debit",
"type.credit": "Credit card",
"type.savings": "Savings",
"systemBadge": "System",
"bank": "Bank",
"bankHint": "Optional, e.g. “alfa”",
"maskedNumber": "Masked number",
@@ -143,7 +161,15 @@
"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."
"archiveConfirmBody": "Account “{{name}}” will leave working lists but remain in history.",
"adjust": "Adjust",
"adjustTitle": "Adjust balance",
"adjustCurrentBalance": "Current balance",
"adjustTargetBalance": "New balance",
"adjustDate": "Adjustment date",
"adjustReason": "Reason",
"adjustReasonHint": "Required; kept in the audit journal",
"adjustNotice": "Adjusting changes the account balance directly: it is not income or expense. It never enters income, expenses, cash flow or the budget pool."
},
"operations": {
"title": "Ledger",
@@ -162,6 +188,7 @@
"kind.income": "Income",
"kind.transfer": "Transfer",
"kind.refund": "Refund",
"kind.adjustment": "Adjustment",
"status.confirmed": "Confirmed",
"status.voided": "Voided",
"noCategory": "Uncategorized",
@@ -183,6 +210,12 @@
"voidBody": "Operation of {{date}} for {{amount}} will be marked voided.",
"voidReason": "Void reason",
"voidReasonHint": "At least 3 characters",
"convert": "To savings",
"convertTitle": "Convert to savings",
"convertBody": "The expense of {{date}} for {{amount}} becomes a transfer to Savings: it leaves the month's expenses, grows the Savings account and reduces the common budget pool. You can revert this later.",
"revert": "Back to expense",
"revertTitle": "Revert to expense",
"revertBody": "The transfer of {{date}} for {{amount}} becomes a regular expense in the original category again; the common budget pool recalculates.",
"prevPage": "Back",
"nextPage": "Next"
},
@@ -208,9 +241,27 @@
"categories": "Categories",
"reserves": "Credit reserves",
"noCategories": "No categories this month yet. Copy the previous month or create a plan.",
"manageCategories": "Manage categories",
"savingsEnvelopeHint": "The Savings envelope is fed by transfers and account adjustments; it has no plan.",
"category.kind.monthly": "Monthly",
"category.kind.savings_envelope": "Envelope",
"category.kind.credit_reserve": "Credit reserve",
"manager": {
"title": "Manage categories",
"tabsLabel": "Category lists",
"tabActive": "Active",
"tabArchive": "Archive",
"systemBadge": "System",
"systemProtected": "renaming and archiving are unavailable",
"rename": "Rename",
"archive": "Archive",
"restore": "Restore",
"renameTitle": "Rename category",
"archiveTitle": "Archive category",
"archiveBody": "Category “{{name}}” will leave new plans and categorization, history remains.",
"emptyActive": "No user categories yet — create the first one in the form above.",
"emptyArchive": "The category archive is empty."
},
"planTitle": "Plan: {{name}}",
"fundTitle": "Fund: {{name}}",
"fund": "Fund",
@@ -223,13 +274,25 @@
"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.",
"blockers": {
"IMPORTS_UNRESOLVED": "Statement imports are unfinished: {{count}}",
"IMPORT_UNRESOLVED_HOLDS": "Unconfirmed operations without a resolution: {{count}}",
"BUDGET_UNCATEGORIZED_EXPENSES": "Expenses without a category: {{count}}",
"BUDGET_NEGATIVE_ENVELOPE": "Negative envelope balance: {{categories}}",
"unknown": "Close is blocked by an unresolved reason.",
"cta": {
"imports": "Open review queue",
"holds": "Resolve operations",
"uncategorized": "Assign categories",
"envelope": "Open categories"
}
},
"warnings.plan_imbalance": "The plan exceeds the projected pool.",
"warnings.plan_exceeds_pool": "The plan exceeds the projected pool.",
"warnings.underfunded_credit_debt": "Credit card debt is not fully funded.",
"warnings.underfunded_credit": "Credit card debt is not fully funded.",
"warnings.expected_income_variance": "Expected income deviates from actual.",
"warnings.unknown": "The month has another issue that needs review.",
"close": "Close month",
"closeConfirmTitle": "Close month",
"closeConfirmBody": "{{month}} will be closed and monthly category leftovers will roll into the general pool.",
@@ -256,7 +319,13 @@
"uploading": "Uploading…",
"adapterHint": "Supported: Alfa debit/credit XLSX, Ozon debit/credit PDF, Rocketbank debit PDF.",
"file": "Statement file",
"fileName": "Statement file",
"targetAccount": "Target account",
"adapters.alfa_debit_xlsx": "Alfa debit",
"adapters.alfa_credit_xlsx": "Alfa credit",
"adapters.ozon_debit_pdf": "Ozon debit",
"adapters.ozon_credit_pdf": "Ozon credit",
"adapters.rocketbank_debit_pdf": "Rocketbank debit",
"unassigned": "Unassigned",
"empty": "No imports yet.",
"bank": "Bank / contract",
@@ -289,6 +358,19 @@
"blockers.unresolved_duplicate": "There are unresolved duplicates.",
"blockers.unresolved_transfer": "There are unresolved transfers.",
"blockers.account_conflict": "Account fingerprint conflicts with an existing one.",
"unresolvedHolds": "On hold",
"unresolvedHoldsBadge": "On-hold operations: {{count}}",
"holdBadge": "Hold",
"holdPosted": "Operation posted",
"holdPostedTitle": "Operation posted",
"holdPostedBody": "The held operation of {{date}} for {{amount}} will be posted to the statement account as a regular operation.",
"holdCancelled": "Operation cancelled",
"holdCancelledTitle": "Operation cancelled",
"holdCancelledBody": "The held operation of {{date}} for {{amount}} will be marked cancelled and never enters the ledger.",
"holdReason": "Resolution reason",
"holdReasonHint": "Required; kept in the audit journal",
"holdResolution.confirmed_as_posted": "Posted",
"holdResolution.discarded_as_cancelled": "Cancelled",
"warnings": "Warnings ({{count}})",
"warnings.amount_warning": "Unusual amount.",
"warnings.date_warning": "Unusual date.",
@@ -303,7 +385,7 @@
"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}}",
"totals": "Rows: {{total}} · create: {{create}} · discard: {{discard}} · duplicates: {{duplicate}} · transfers: {{transfer}} · savings: {{savings}} · pending: {{pending}}",
"rowsTitle": "Statement rows",
"noRows": "No rows.",
"rowWarnings": "Notes",
@@ -318,6 +400,7 @@
"decision.discard": "Discard",
"decision.duplicate": "Duplicate",
"decision.transfer": "Transfer",
"decision.savings_transfer": "Savings transfer",
"confirm": "Apply import",
"confirmTitle": "Apply import",
"confirmBody": "Operations will be created from the selected rows. Applying is atomic and can be reverted.",
@@ -372,9 +455,16 @@
"toast": {
"accountCreated": "Account “{{name}}” created",
"accountArchived": "Account archived",
"balanceAdjusted": "Account balance adjusted",
"operationCreated": "Operation created",
"operationVoided": "Operation voided",
"categoryAssigned": "Category assigned",
"convertedToSavings": "Expense converted to savings",
"savingsConversionReverted": "Transfer reverted to expense",
"categoryCreated": "Category “{{name}}” created",
"categoryRenamed": "Category renamed to “{{name}}”",
"categoryArchived": "Category archived",
"categoryRestored": "Category restored",
"planUpdated": "Plan updated",
"categoryFunded": "Category funded",
"monthClosed": "Month closed",
@@ -383,6 +473,7 @@
"importUploaded": "Statement uploaded, parsing started",
"importConfirmed": "Import applied",
"importReverted": "Import reverted",
"holdResolved": "Hold resolution saved",
"reparseQueued": "Reparse queued",
"exportQueued": "Export queued",
"exportReady": "Export is ready to download",
@@ -394,7 +485,11 @@
"subtitle": "This page does not exist. Check the address or go back to accounts.",
"back": "To accounts"
},
"account": {
"savings": "Savings"
},
"categories": {
"uncategorized": "Uncategorized",
"groceries": "Groceries",
"transport": "Transport",
"housing": "Housing",
+103 -8
View File
@@ -88,7 +88,23 @@
"validation_error": "Проверьте правильность заполнения полей.",
"conflict": "Данные изменены кем-то другим. Обновите страницу и повторите.",
"rate_limited": "Слишком много попыток. Подождите немного.",
"session_expired": "Сессия истекла. Войдите снова."
"session_expired": "Сессия истекла. Войдите снова.",
"LEDGER_ADJUST_NOOP": "Остаток уже равен целевому значению — менять нечего.",
"LEDGER_ADJUST_TARGET_NEGATIVE": "Целевой остаток не может быть отрицательным.",
"LEDGER_ADJUST_REASON_REQUIRED": "Укажите причину корректировки.",
"LEDGER_ACCOUNT_ARCHIVED": "Счёт в архиве — сначала восстановите его.",
"LEDGER_SAVINGS_CONVERT_INVALID": "Преобразовать в накопления можно только подтверждённый расход в открытом месяце.",
"LEDGER_SAVINGS_REVERT_INVALID": "Вернуть в расход можно только преобразованный ранее перевод.",
"LEDGER_SAVINGS_CONFLICT": "Обнаружен конфликт системных ресурсов накоплений.",
"BUDGET_PERIOD_CLOSED": "Месяц закрыт. Сначала откройте его заново.",
"BUDGET_CATEGORY_SYSTEM": "Системную категорию нельзя переименовать или архивировать.",
"BUDGET_CATEGORY_ARCHIVED": "Категория в архиве — сначала восстановите её.",
"BUDGET_CATEGORY_NAME_REQUIRED": "Укажите название категории.",
"BUDGET_CATEGORY_KIND_INVALID": "Неизвестный тип категории.",
"IMPORT_HOLD_REASON_REQUIRED": "Укажите причину решения по замороженной операции.",
"IMPORT_HOLD_ALREADY_RESOLVED": "По этой операции решение уже сохранено.",
"IMPORT_HOLD_DOCUMENT_NOT_CONFIRMED": "Решать замороженные операции можно только после применения импорта.",
"IMPORT_HOLD_ACCOUNT_REQUIRED": "Не указан счёт привязки выписки."
},
"auth": {
"loginTitle": "Вход",
@@ -120,7 +136,7 @@
},
"accounts": {
"title": "Счета",
"subtitle": "Дебетовые, наличные и кредитные счета пространства.",
"subtitle": "Дебетовые, наличные, кредитные и системный счёт накоплений.",
"add": "Добавить счёт",
"empty": "Счетов пока нет. Создайте первый счёт, чтобы начать учёт.",
"formTitle": "Новый счёт",
@@ -128,6 +144,8 @@
"type.cash": "Наличные",
"type.debit": "Дебетовый",
"type.credit": "Кредитная карта",
"type.savings": "Накопительный",
"systemBadge": "Системный",
"bank": "Банк",
"bankHint": "Необязательно, например «alfa»",
"maskedNumber": "Маскированный номер",
@@ -143,7 +161,15 @@
"archive": "В архив",
"archiveBlockedHint": "Архивировать можно только счёт с нулевым остатком",
"archiveConfirmTitle": "Архивировать счёт",
"archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории."
"archiveConfirmBody": "Счёт «{{name}}» скроется из рабочих списков, но останется в истории.",
"adjust": "Скорректировать",
"adjustTitle": "Корректировка остатка",
"adjustCurrentBalance": "Текущий остаток",
"adjustTargetBalance": "Новый остаток",
"adjustDate": "Дата корректировки",
"adjustReason": "Причина",
"adjustReasonHint": "Обязательно; попадёт в журнал аудита",
"adjustNotice": "Корректировка меняет остаток счёта напрямую: это не доход и не расход. Она не попадёт в доходы, расходы, денежный поток и общий пул бюджета."
},
"operations": {
"title": "Гроссбух",
@@ -162,6 +188,7 @@
"kind.income": "Доход",
"kind.transfer": "Перевод",
"kind.refund": "Возврат",
"kind.adjustment": "Корректировка",
"status.confirmed": "Подтверждена",
"status.voided": "Отменена",
"noCategory": "Без категории",
@@ -183,6 +210,12 @@
"voidBody": "Операция от {{date}} на {{amount}} будет помечена отменённой.",
"voidReason": "Причина отмены",
"voidReasonHint": "Минимум 3 символа",
"convert": "В накопления",
"convertTitle": "Перевести в накопления",
"convertBody": "Расход от {{date}} на {{amount}} станет переводом в «Накопления»: он уйдёт из расходов месяца, увеличит счёт «Накопления» и уменьшит общий бюджетный пул. Позже это можно вернуть.",
"revert": "Вернуть в расходы",
"revertTitle": "Вернуть в расходы",
"revertBody": "Перевод от {{date}} на {{amount}} снова станет обычным расходом исходной категории; общий бюджетный пул пересчитается.",
"prevPage": "Назад",
"nextPage": "Дальше"
},
@@ -208,9 +241,27 @@
"categories": "Категории",
"reserves": "Резервы кредиток",
"noCategories": "В этом месяце ещё нет категорий. Скопируйте прошлый месяц или создайте план.",
"manageCategories": "Управлять категориями",
"savingsEnvelopeHint": "Конверт «Накопления» наполняется переводами и корректировками счёта; план не задаётся.",
"category.kind.monthly": "Месячная",
"category.kind.savings_envelope": "Конверт",
"category.kind.credit_reserve": "Резерв кредитки",
"manager": {
"title": "Управление категориями",
"tabsLabel": "Списки категорий",
"tabActive": "Активные",
"tabArchive": "Архив",
"systemBadge": "Системная",
"systemProtected": "переименование и архивирование недоступны",
"rename": "Переименовать",
"archive": "В архив",
"restore": "Восстановить",
"renameTitle": "Переименовать категорию",
"archiveTitle": "Архивировать категорию",
"archiveBody": "Категория «{{name}}» скроется из новых планов и разметки, но история останется.",
"emptyActive": "Пользовательских категорий пока нет — создайте первую в форме выше.",
"emptyArchive": "Архив категорий пуст."
},
"planTitle": "План: {{name}}",
"fundTitle": "Обеспечение: {{name}}",
"fund": "Обеспечить",
@@ -223,13 +274,25 @@
"income.status.partially_matched": "Частично",
"income.status.matched": "Поступил",
"blockersTitle": "Закрытие заблокировано",
"blockers.uncategorized_expenses": "Есть расходы без категории.",
"blockers.negative_envelope": "Отрицательный баланс накопительного конверта.",
"blockers.unresolved_imports": "Есть незавершённые импорты.",
"blockers.open_decisions": "Есть неразобранные решения по импорту.",
"blockers": {
"IMPORTS_UNRESOLVED": "Не завершены импорты выписок: {{count}}",
"IMPORT_UNRESOLVED_HOLDS": "Неподтверждённые операции без решения: {{count}}",
"BUDGET_UNCATEGORIZED_EXPENSES": "Расходы без категории: {{count}}",
"BUDGET_NEGATIVE_ENVELOPE": "Отрицательный остаток конверта: {{categories}}",
"unknown": "Закрытие заблокировано необработанной причиной.",
"cta": {
"imports": "Открыть очередь разбора",
"holds": "Разобрать операции",
"uncategorized": "Назначить категории",
"envelope": "Открыть категории"
}
},
"warnings.plan_imbalance": "План превышает прогнозный пул.",
"warnings.plan_exceeds_pool": "План превышает прогнозный пул.",
"warnings.underfunded_credit_debt": "Долг по кредитке обеспечен не полностью.",
"warnings.underfunded_credit": "Долг по кредитке обеспечен не полностью.",
"warnings.expected_income_variance": "Ожидаемый доход расходится с фактическим.",
"warnings.unknown": "В месяце есть другая проблема, требующая проверки.",
"close": "Закрыть месяц",
"closeConfirmTitle": "Закрыть месяц",
"closeConfirmBody": "Месяц {{month}} будет закрыт, остатки месячных категорий свернутся в общий пул.",
@@ -256,7 +319,13 @@
"uploading": "Загрузка…",
"adapterHint": "Поддерживаются: Альфа debit/credit XLSX, Ozon debit/credit PDF, Рокетбанк debit PDF.",
"file": "Файл выписки",
"fileName": "Файл выписки",
"targetAccount": "Счёт привязки",
"adapters.alfa_debit_xlsx": "Альфа debit",
"adapters.alfa_credit_xlsx": "Альфа credit",
"adapters.ozon_debit_pdf": "Ozon debit",
"adapters.ozon_credit_pdf": "Ozon credit",
"adapters.rocketbank_debit_pdf": "Рокетбанк debit",
"unassigned": "Не привязан",
"empty": "Импортов пока нет.",
"bank": "Банк / контракт",
@@ -289,6 +358,19 @@
"blockers.unresolved_duplicate": "Есть неразобранные дубликаты.",
"blockers.unresolved_transfer": "Есть неразобранные переводы.",
"blockers.account_conflict": "Отпечаток счёта конфликтует с существующим.",
"unresolvedHolds": "Замороженные",
"unresolvedHoldsBadge": "Замороженных операций: {{count}}",
"holdBadge": "Заморожена",
"holdPosted": "Операция списалась",
"holdPostedTitle": "Операция списалась",
"holdPostedBody": "Замороженная операция от {{date}} на {{amount}} будет проведена по счёту выписки как обычная операция.",
"holdCancelled": "Операция отменена",
"holdCancelledTitle": "Операция отменена",
"holdCancelledBody": "Замороженная операция от {{date}} на {{amount}} будет помечена отменённой и не попадёт в операции.",
"holdReason": "Причина решения",
"holdReasonHint": "Обязательно; попадёт в журнал аудита",
"holdResolution.confirmed_as_posted": "Проведена",
"holdResolution.discarded_as_cancelled": "Отменена",
"warnings": "Предупреждения ({{count}})",
"warnings.amount_warning": "Нестандартная сумма.",
"warnings.date_warning": "Необычная дата.",
@@ -303,7 +385,7 @@
"reconciliation.mismatched": "Расхождение",
"reconciliation.not_available": "Данных для сверки нет",
"reconciliation.confirmed_without": "Подтверждено без сверки",
"totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}}",
"totals": "Строк: {{total}} · создать: {{create}} · пропустить: {{discard}} · дубликатов: {{duplicate}} · переводов: {{transfer}} · в накопления: {{savings}} · без решения: {{pending}}",
"rowsTitle": "Строки выписки",
"noRows": "Строк нет.",
"rowWarnings": "Замечания",
@@ -318,6 +400,7 @@
"decision.discard": "Пропустить",
"decision.duplicate": "Это дубликат",
"decision.transfer": "Это перевод",
"decision.savings_transfer": "Перевод в накопления",
"confirm": "Применить импорт",
"confirmTitle": "Применить импорт",
"confirmBody": "Будут созданы операции по выбранным строкам. Применение атомарно, его можно отменить.",
@@ -374,9 +457,16 @@
"toast": {
"accountCreated": "Счёт «{{name}}» создан",
"accountArchived": "Счёт отправлен в архив",
"balanceAdjusted": "Остаток счёта скорректирован",
"operationCreated": "Операция создана",
"operationVoided": "Операция отменена",
"categoryAssigned": "Категория назначена",
"convertedToSavings": "Расход переведён в накопления",
"savingsConversionReverted": "Перевод возвращён в расходы",
"categoryCreated": "Категория «{{name}}» создана",
"categoryRenamed": "Категория переименована в «{{name}}»",
"categoryArchived": "Категория отправлена в архив",
"categoryRestored": "Категория восстановлена",
"planUpdated": "План обновлён",
"categoryFunded": "Категория обеспечена",
"monthClosed": "Месяц закрыт",
@@ -385,6 +475,7 @@
"importUploaded": "Выписка загружена, разбор запущен",
"importConfirmed": "Импорт применён",
"importReverted": "Импорт отменён",
"holdResolved": "Решение по замороженной операции сохранено",
"reparseQueued": "Переразбор запущен",
"exportQueued": "Экспорт поставлен в очередь",
"exportReady": "Экспорт готов к скачиванию",
@@ -396,7 +487,11 @@
"subtitle": "Такой страницы нет. Проверьте адрес или вернитесь к счетам.",
"back": "К счетам"
},
"account": {
"savings": "Накопления"
},
"categories": {
"uncategorized": "Без категории",
"groceries": "Продукты",
"transport": "Транспорт",
"housing": "Жильё",
+9
View File
@@ -0,0 +1,9 @@
import type { TFunction } from 'i18next'
import type { Account } from '@/api/types'
export function accountLabel(account: Pick<Account, 'name' | 'nameKey'>, t: TFunction): string {
if (account.nameKey) {
return t(account.nameKey, { defaultValue: account.name })
}
return account.name
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiError, isApiError } from '@/api/client'
import { i18n } from '@/i18n'
import type { TFunction } from 'i18next'
export function describeApiError(error: unknown, t: TFunction): string {
if (isApiError(error)) {
const key = `errors.${error.code}`
if (i18n.exists(key)) return t(key)
if (error.detail) return error.detail
return t('errors.http', { status: error.status })
}
return t('errors.generic')
}
export function apiErrorCode(error: unknown): string | null {
return isApiError(error) ? error.code : null
}
export { ApiError, isApiError }
+24
View File
@@ -0,0 +1,24 @@
import type { TFunction } from 'i18next'
interface NamedCategory {
name: string | null
nameKey: string | null
}
export function categoryLabel(category: NamedCategory, t: TFunction): string {
if (category.nameKey) {
return t(`categories.${category.nameKey}`, { defaultValue: category.name ?? category.nameKey })
}
return category.name ?? t('common.dash')
}
export function reportItemLabel(
name: string | null,
nameKey: string | null,
t: TFunction,
): string {
if (nameKey) {
return t(`categories.${nameKey}`, { defaultValue: name ?? nameKey })
}
return name ?? t('analytics.unallocated')
}
+101
View File
@@ -0,0 +1,101 @@
import type { Locale } from '@/api/types'
const INTL_LOCALES: Record<Locale, string> = {
ru: 'ru-RU',
en: 'en-US',
}
const MONTH_RE = /^(\d{4})-(\d{2})$/
function parseMonth(month: string): { year: number; monthIndex: number } | null {
const match = MONTH_RE.exec(month)
if (!match) return null
const year = Number(match[1])
const monthIndex = Number(match[2]) - 1
if (!Number.isInteger(year) || monthIndex < 0 || monthIndex > 11) return null
return { year, monthIndex }
}
export function todayISO(): string {
const now = new Date()
const year = String(now.getFullYear()).padStart(4, '0')
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function monthOf(dateISO: string): string {
return dateISO.slice(0, 7)
}
export function currentMonth(): string {
return monthOf(todayISO())
}
export function shiftMonth(month: string, delta: number): string {
const parsed = parseMonth(month)
if (!parsed) return month
const date = new Date(Date.UTC(parsed.year, parsed.monthIndex + delta, 1))
const year = date.getUTCFullYear()
const m = String(date.getUTCMonth() + 1).padStart(2, '0')
return `${year}-${m}`
}
export function isValidMonth(month: string): boolean {
return parseMonth(month) !== null
}
export function formatMonth(month: string, locale: Locale): string {
const parsed = parseMonth(month)
if (!parsed) return month
const label = new Intl.DateTimeFormat(INTL_LOCALES[locale], {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(new Date(Date.UTC(parsed.year, parsed.monthIndex, 1)))
return locale === 'ru' ? label.replace(' г.', '') : label
}
export function formatMonthShort(month: string, locale: Locale): string {
const parsed = parseMonth(month)
if (!parsed) return month
return new Intl.DateTimeFormat(INTL_LOCALES[locale], {
month: 'short',
timeZone: 'UTC',
}).format(new Date(Date.UTC(parsed.year, parsed.monthIndex, 1)))
}
export function formatDate(dateISO: string, locale: Locale): string {
const parsed = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateISO)
if (!parsed) return dateISO
const date = new Date(Date.UTC(Number(parsed[1]), Number(parsed[2]) - 1, Number(parsed[3])))
return new Intl.DateTimeFormat(INTL_LOCALES[locale], {
day: 'numeric',
month: 'short',
year: '2-digit',
timeZone: 'UTC',
}).format(date)
}
export function formatDateTime(iso: string, locale: Locale): string {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return new Intl.DateTimeFormat(INTL_LOCALES[locale], {
dateStyle: 'short',
timeStyle: 'short',
}).format(date)
}
export function lastNMonths(count: number, endMonth = currentMonth()): string[] {
const months: string[] = []
for (let i = count - 1; i >= 0; i -= 1) months.push(shiftMonth(endMonth, -i))
return months
}
export function monthBounds(month: string): { start: string; end: string } {
const parsed = parseMonth(month)
if (!parsed) return { start: month, end: month }
const lastDay = new Date(Date.UTC(parsed.year, parsed.monthIndex + 1, 0)).getUTCDate()
const end = `${parsed.year}-${String(parsed.monthIndex + 1).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
return { start: `${month}-01`, end }
}
+21
View File
@@ -0,0 +1,21 @@
import type { ImportStatus } from '@/api/types'
export const IMPORT_POLL_MS = 2000
const ACTIVE_STATUSES = new Set<ImportStatus>(['uploaded', 'queued', 'processing'])
export function isTerminalImportStatus(status: ImportStatus): boolean {
return !ACTIVE_STATUSES.has(status)
}
export function importPollInterval(status: ImportStatus | undefined): number | false {
if (!status || isTerminalImportStatus(status)) return false
return IMPORT_POLL_MS
}
export function importListPollInterval(
statuses: ImportStatus[] | undefined,
): number | false {
if (!statuses) return false
return statuses.some((status) => !isTerminalImportStatus(status)) ? IMPORT_POLL_MS : false
}
+5
View File
@@ -0,0 +1,5 @@
import type { Locale } from '@/api/types'
export function currentLocale(language: string | undefined): Locale {
return language?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
}
+46
View File
@@ -0,0 +1,46 @@
import type { Locale } from '@/api/types'
const INTL_LOCALES: Record<Locale, string> = {
ru: 'ru-RU',
en: 'en-US',
}
export function formatMoney(kopecks: number, locale: Locale): string {
return new Intl.NumberFormat(INTL_LOCALES[locale], {
style: 'currency',
currency: 'RUB',
currencyDisplay: 'narrowSymbol',
maximumFractionDigits: 2,
}).format(kopecks / 100)
}
export function formatSignedMoney(kopecks: number, locale: Locale, sign: -1 | 1): string {
const formatted = formatMoney(Math.abs(kopecks), locale)
return sign === -1 ? `−${formatted}` : `+${formatted}`
}
export function kopecksToInput(kopecks: number): string {
const sign = kopecks < 0 ? '-' : ''
const abs = Math.abs(kopecks)
const rubles = Math.trunc(abs / 100)
const frac = String(abs % 100).padStart(2, '0')
return frac === '00' ? `${sign}${rubles}` : `${sign}${rubles}.${frac}`
}
export function parseAmountToKopecks(raw: string): number | null {
const cleaned = raw.replace(/[\s\u00A0\u202F']/g, '').replace(',', '.')
const match = /^([+-]?)(\d+)(?:\.(\d{1,2}))?$/.exec(cleaned)
if (!match) return null
const sign = match[1] ?? ''
const rubles = match[2] ?? '0'
const frac = match[3] ?? ''
const kopecks = Number(rubles) * 100 + (frac === '' ? 0 : Number(frac.padEnd(2, '0')))
if (!Number.isSafeInteger(kopecks)) return null
return sign === '-' ? -kopecks : kopecks
}
export function sumKopecks(values: Iterable<number>): number {
let total = 0
for (const value of values) total += value
return total
}
+41
View File
@@ -0,0 +1,41 @@
import type { Locale, Operation } from '@/api/types'
import { formatMoney } from '@/lib/money'
export interface OperationAmountView {
text: string
tone: 'neg' | 'pos' | 'transfer'
}
export function operationAmount(operation: Operation, locale: Locale): OperationAmountView {
const amount = formatMoney(operation.amountKopecks, locale)
switch (operation.kind) {
case 'expense':
return { text: `−${amount}`, tone: 'neg' }
case 'income':
case 'refund':
return { text: `+${amount}`, tone: 'pos' }
case 'transfer':
case 'adjustment':
// Transfers move money between own accounts and an adjustment carries the
// sign in its single posting, so both render without an income/expense sign.
return { text: amount, tone: 'transfer' }
}
}
export const OPERATION_KIND_TONES = {
expense: 'danger',
income: 'accent',
refund: 'info',
transfer: 'neutral',
adjustment: 'neutral',
} as const
export const IMPORT_STATUS_TONES = {
uploaded: 'info',
queued: 'info',
processing: 'info',
review_required: 'warn',
ready: 'accent',
confirmed: 'accent',
failed: 'danger',
} as const
+155 -51
View File
@@ -3,7 +3,8 @@ 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 type { Account } from '@/api/types'
import { isSystemAccount } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
@@ -12,14 +13,20 @@ 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 {
useAccounts,
useAdjustBalance,
useArchiveAccount,
useCreateAccount,
} from '@/hooks/useAccounts'
import { accountLabel } from '@/lib/accountLabel'
import { formatDate, todayISO } from '@/lib/dates'
import { formatMoney, kopecksToInput, parseAmountToKopecks, sumKopecks } from '@/lib/money'
import { currentLocale } from '@/lib/locale'
interface AccountFormValues {
name: string
type: AccountType
type: 'cash' | 'debit' | 'credit'
openedOn: string
openingBalance: string
bankCode: string
@@ -113,6 +120,84 @@ function useMemoSchema() {
})
}
function AdjustBalanceModal({
account,
onClose,
}: {
account: Account
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const adjust = useAdjustBalance()
const schema = z.object({
targetBalance: z
.string()
.refine((value) => (parseAmountToKopecks(value) ?? -1) >= 0, t('errors.badAmount')),
occurredOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, t('common.requiredField')),
reason: z.string().trim().min(1, t('common.requiredField')),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
targetBalance: kopecksToInput(account.balanceKopecks),
occurredOn: todayISO(),
reason: '',
},
})
const submit = handleSubmit((values) => {
adjust.mutate(
{
id: account.id,
payload: {
targetBalanceKopecks: parseAmountToKopecks(values.targetBalance) ?? 0,
occurredOn: values.occurredOn,
reason: values.reason,
},
},
{ onSuccess: onClose },
)
})
return (
<Modal open onClose={onClose} title={t('accounts.adjustTitle')}>
<form onSubmit={submit} noValidate className="form-grid">
<p className="adjust-notice">{t('accounts.adjustNotice')}</p>
<div className="adjust-current">
<span>{t('accounts.adjustCurrentBalance')}</span>
<strong className="num">{formatMoney(account.balanceKopecks, locale)}</strong>
</div>
<MoneyField
label={t('accounts.adjustTargetBalance')}
error={errors.targetBalance?.message}
{...register('targetBalance')}
/>
<TextField
label={t('accounts.adjustDate')}
type="date"
error={errors.occurredOn?.message}
{...register('occurredOn')}
/>
<TextField
label={t('accounts.adjustReason')}
hint={t('accounts.adjustReasonHint')}
error={errors.reason?.message}
{...register('reason')}
/>
<Button type="submit" disabled={adjust.isPending}>
{adjust.isPending ? t('common.saving') : t('accounts.adjust')}
</Button>
</form>
</Modal>
)
}
export function AccountsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
@@ -126,6 +211,7 @@ export function AccountsPage() {
const liabilities = sumKopecks(
active.filter((a) => a.type === 'credit').map((a) => a.balanceKopecks),
)
const [adjustTarget, setAdjustTarget] = useState<Account | null>(null)
return (
<>
@@ -174,52 +260,67 @@ export function AccountsPage() {
</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>
))}
{list.map((account) => {
const system = isSystemAccount(account)
return (
<tr key={account.id}>
<th scope="row" data-label={t('common.name')}>
{accountLabel(account, t)}
{system ? <Badge tone="info">{t('accounts.systemBadge')}</Badge> : null}
{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')}>
<span className="inline-actions">
{account.status === 'active' ? (
<>
{account.type !== 'credit' ? (
<Button variant="subtle" onClick={() => setAdjustTarget(account)}>
{t('accounts.adjust')}
</Button>
) : null}
{!system ? (
<Button
variant="subtle"
disabled={account.balanceKopecks !== 0}
title={
account.balanceKopecks !== 0
? t('accounts.archiveBlockedHint')
: undefined
}
onClick={() => setArchiveTarget(account)}
>
{t('accounts.archive')}
</Button>
) : null}
</>
) : null}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
)}
@@ -228,6 +329,9 @@ export function AccountsPage() {
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={t('accounts.formTitle')}>
<AccountForm onDone={() => setCreateOpen(false)} />
</Modal>
{adjustTarget ? (
<AdjustBalanceModal account={adjustTarget} onClose={() => setAdjustTarget(null)} />
) : null}
<ConfirmDialog
open={archiveTarget !== null}
danger
+73 -41
View File
@@ -7,6 +7,8 @@ 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 { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { CategoryManagerModal } from '@/components/budget/CategoryManagerModal'
import { MoneyField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
@@ -26,6 +28,8 @@ import { formatDate, currentMonth, formatMonth } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { formatMoney, kopecksToInput, parseAmountToKopecks } from '@/lib/money'
const BLOCKER_WARNING_CODES = new Set(['uncategorized_expenses', 'negative_envelopes'])
function PlanModal({
category,
month,
@@ -134,6 +138,7 @@ export function BudgetPage() {
const [fundTarget, setFundTarget] = useState<BudgetCategory | null>(null)
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false)
const [reopenConfirmOpen, setReopenConfirmOpen] = useState(false)
const [managerOpen, setManagerOpen] = useState(false)
const status = summary.data?.status
const allCategories = categories.data ?? []
@@ -141,37 +146,62 @@ export function BudgetPage() {
(category) => category.kind !== 'credit_reserve' && !category.archived,
)
const reserves = allCategories.filter((category) => category.kind === 'credit_reserve')
const supplementalWarnings =
summary.data?.warnings.filter((warning) => !BLOCKER_WARNING_CODES.has(warning.code)) ?? []
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>
))
rows.map((category) => {
const isSystemCategory = category.system
const systemHint =
category.nameKey === 'savings'
? t('budget.savingsEnvelopeHint')
: t('budget.manager.systemNote')
return (
<tr key={category.id}>
<th scope="row" data-label={t('common.name')}>
{categoryLabel(category, t)}
{isSystemCategory ? (
<Badge tone="info">{t('budget.manager.systemBadge')}</Badge>
) : (
<Badge tone={category.kind === 'savings_envelope' ? 'info' : 'neutral'}>
{t(`budget.category.kind.${category.kind}`)}
</Badge>
)}
</th>
<td data-label={t('budget.planned')}>
{isSystemCategory ? (
<span className="num" title={systemHint}>
{formatMoney(category.plannedKopecks, locale)}
</span>
) : (
<Button variant="subtle" onClick={() => setPlanTarget(category)}>
{formatMoney(category.plannedKopecks, locale)}
</Button>
)}
</td>
<td data-label={t('budget.funded')}>
{isSystemCategory ? (
<span className="num" title={systemHint}>
{formatMoney(category.fundedKopecks, locale)}
</span>
) : (
<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 (
<>
@@ -217,21 +247,17 @@ export function BudgetPage() {
{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>
<BudgetBlockers blockers={summary.data.blockers} month={month} />
</div>
) : null}
{summary.data && summary.data.warnings.length > 0 ? (
{supplementalWarnings.length > 0 ? (
<div className="banner banner-warn">
<ul>
{summary.data.warnings.map((warning) => (
{supplementalWarnings.map((warning) => (
<li key={warning.code}>
{t(`budget.warnings.${warning.code}`, { defaultValue: warning.message ?? warning.code })}
{t(`budget.warnings.${warning.code}`, {
defaultValue: t('budget.warnings.unknown'),
})}
</li>
))}
</ul>
@@ -308,8 +334,13 @@ export function BudgetPage() {
</table>
</section>
) : null}
<section aria-label={t('budget.categories')}>
<h2 className="section-title">{t('budget.categories')}</h2>
<section aria-label={t('budget.categories')} id="categories">
<div className="section-heading categories-heading">
<h2 className="section-title">{t('budget.categories')}</h2>
<Button variant="subtle" onClick={() => setManagerOpen(true)}>
{t('budget.manageCategories')}
</Button>
</div>
{userCategories.length === 0 ? (
<p className="muted">{t('budget.noCategories')}</p>
) : (
@@ -355,6 +386,7 @@ export function BudgetPage() {
{fundTarget ? (
<FundModal category={fundTarget} month={month} onClose={() => setFundTarget(null)} />
) : null}
{managerOpen ? <CategoryManagerModal onClose={() => setManagerOpen(false)} /> : null}
<ConfirmDialog
open={closeConfirmOpen}
danger
+159 -23
View File
@@ -1,11 +1,14 @@
import { useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { Link, useParams } from 'react-router-dom'
import type { RowDecision } from '@/api/types'
import { z } from 'zod'
import type { HoldResolutionAction, ImportRow, 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 { TextareaField, TextField } from '@/components/ui/fields'
import { Modal } from '@/components/ui/Modal'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
@@ -13,6 +16,7 @@ import {
useConfirmImport,
useImport,
useReparseImport,
useResolveHold,
useRevertImport,
useSaveRowDecision,
} from '@/hooks/useImports'
@@ -26,6 +30,7 @@ const DECISIONS: readonly Exclude<RowDecision, 'pending'>[] = [
'discard',
'duplicate',
'transfer',
'savings_transfer',
]
const STEP_ORDER = ['uploaded', 'queued', 'processing', 'review_required', 'confirmed'] as const
@@ -37,6 +42,76 @@ function stepIndexFor(status: string): number {
return index === -1 ? 0 : index
}
function HoldResolveModal({
importId,
row,
action,
onClose,
}: {
importId: string
row: ImportRow
action: HoldResolutionAction
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const resolve = useResolveHold(importId)
const schema = z.object({
reason: z.string().trim().min(1, t('common.requiredField')),
})
type Values = z.infer<typeof schema>
const {
register,
handleSubmit,
formState: { errors },
} = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { reason: '' },
})
const submit = handleSubmit((values) => {
resolve.mutate(
{ rowId: row.id, payload: { action, reason: values.reason } },
{ onSuccess: onClose },
)
})
return (
<Modal
open
onClose={onClose}
title={
action === 'confirm_as_posted'
? t('imports.holdPostedTitle')
: t('imports.holdCancelledTitle')
}
>
<form onSubmit={submit} noValidate className="form-grid">
<p className="muted">
{action === 'confirm_as_posted'
? t('imports.holdPostedBody', {
date: formatDate(row.occurredOn, locale),
amount: formatMoney(row.amountKopecks, locale),
})
: t('imports.holdCancelledBody', {
date: formatDate(row.occurredOn, locale),
amount: formatMoney(row.amountKopecks, locale),
})}
</p>
<TextareaField
label={t('imports.holdReason')}
hint={t('imports.holdReasonHint')}
error={errors.reason?.message}
{...register('reason')}
/>
<Button type="submit" variant="primary" disabled={resolve.isPending}>
{resolve.isPending ? t('common.saving') : t('common.confirm')}
</Button>
</form>
</Modal>
)
}
function ConfirmModal({
importId,
needsConfirmation,
@@ -101,6 +176,7 @@ export function ImportDetailPage() {
const [confirmOpen, setConfirmOpen] = useState(false)
const [revertOpen, setRevertOpen] = useState(false)
const [holdTarget, setHoldTarget] = useState<{ row: ImportRow; action: HoldResolutionAction } | null>(null)
const status = detail.data?.status
const needsConfirmation =
@@ -134,6 +210,9 @@ export function ImportDetailPage() {
}
/>
{status ? <Badge tone={IMPORT_STATUS_TONES[status]}>{t(`imports.status.${status}`)}</Badge> : null}
{detail.data && detail.data.unresolvedHoldCount > 0 ? (
<Badge tone="warn">{t('imports.unresolvedHoldsBadge', { count: detail.data.unresolvedHoldCount })}</Badge>
) : null}
<QueryGate query={detail}>
{(data) => (
<>
@@ -225,6 +304,8 @@ export function ImportDetailPage() {
discard: data.totals.discard,
duplicate: data.totals.duplicate,
transfer: data.totals.transfer,
savings: data.totals.savingsTransfer,
pending: data.totals.pending,
})}
</p>
</section>
@@ -262,7 +343,9 @@ export function ImportDetailPage() {
{formatMoney(row.amountKopecks, locale)}
</td>
<td data-label={t('imports.rowWarnings')} className="statement-warnings">
{row.warnings.length === 0 ? (
{row.bankStatus === 'hold' ? (
<span className="chip">{t('imports.holdBadge')}</span>
) : row.warnings.length === 0 ? (
'—'
) : (
<ul className="chip-list">
@@ -275,26 +358,71 @@ export function ImportDetailPage() {
)}
</td>
<td data-label={t('imports.decision')} className="statement-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>
{row.bankStatus === 'hold' ? (
row.holdResolution ? (
<div className="hold-history">
<Badge
tone={
row.holdResolution.resolution === 'confirmed_as_posted'
? 'accent'
: 'neutral'
}
>
{t(
`imports.holdResolution.${row.holdResolution.resolution}`,
)}
</Badge>
{row.holdResolution.reason ? (
<span className="muted">{row.holdResolution.reason}</span>
) : null}
{row.holdResolution.resolvedAt ? (
<span className="muted">
{formatDateTime(row.holdResolution.resolvedAt, locale)}
</span>
) : null}
</div>
) : (
<div className="inline-actions">
<Button
variant="subtle"
onClick={() =>
setHoldTarget({ row, action: 'confirm_as_posted' })
}
>
{t('imports.holdPosted')}
</Button>
<Button
variant="subtle"
onClick={() =>
setHoldTarget({ row, action: 'discard_as_cancelled' })
}
>
{t('imports.holdCancelled')}
</Button>
</div>
)
) : (
<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>
))}
@@ -302,6 +430,14 @@ export function ImportDetailPage() {
</table>
)}
</section>
{holdTarget ? (
<HoldResolveModal
importId={data.id}
row={holdTarget.row}
action={holdTarget.action}
onClose={() => setHoldTarget(null)}
/>
) : null}
{confirmOpen ? (
<ConfirmModal
importId={data.id}
+4 -3
View File
@@ -8,15 +8,16 @@ 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 { useUserAccounts } from '@/hooks/useAccounts'
import { useImports, useUploadImport } from '@/hooks/useImports'
import { accountLabel } from '@/lib/accountLabel'
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 accounts = useUserAccounts()
const upload = useUploadImport()
const [accountId, setAccountId] = useState('')
const [file, setFile] = useState<File | null>(null)
@@ -62,7 +63,7 @@ function UploadModal({ onClose }: { onClose: () => void }) {
<option value="">{t('common.choose')}</option>
{(accounts.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{accountLabel(account, t)}
</option>
))}
</SelectField>
+130 -12
View File
@@ -5,6 +5,7 @@ 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 { isSystemAccount } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
@@ -13,12 +14,15 @@ 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 { useActiveAccounts, useUserAccounts } from '@/hooks/useAccounts'
import {
useConvertToSavings,
useCreateOperation,
useOperations,
useRevertSavingsConversion,
useVoidOperation,
} from '@/hooks/useOperations'
import { accountLabel } from '@/lib/accountLabel'
import { categoryLabel } from '@/lib/categoryLabel'
import { currentMonth, formatDate, todayISO } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
@@ -50,7 +54,7 @@ function AccountSelect({
<option value="">{t('common.choose')}</option>
{(accounts ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{accountLabel(account, t)}
</option>
))}
</SelectField>
@@ -60,7 +64,7 @@ function AccountSelect({
function ExpenseForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateOperation()
const accounts = useActiveAccounts()
const accounts = useUserAccounts()
const categories = useBudgetCategories(currentMonth())
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
@@ -143,7 +147,7 @@ function ExpenseForm({ onDone }: { onDone: () => void }) {
function IncomeForm({ onDone }: { onDone: () => void }) {
const { t } = useTranslation()
const create = useCreateOperation()
const accounts = useActiveAccounts()
const accounts = useUserAccounts()
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
amount: amountField(t),
@@ -297,7 +301,7 @@ function RefundForm({ onDone }: { onDone: () => void }) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const create = useCreateOperation()
const accounts = useActiveAccounts()
const accounts = useUserAccounts()
const recent = useOperations({ kind: 'expense', limit: 20 })
const schema = z.object({
accountId: z.string().min(1, t('common.requiredField')),
@@ -447,6 +451,76 @@ function VoidModal({ operation, onClose }: { operation: Operation; onClose: () =
const PAGE_SIZE = 50
function SavingsConvertModal({
operation,
onClose,
}: {
operation: Operation
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const convert = useConvertToSavings()
return (
<Modal open onClose={onClose} title={t('operations.convertTitle')}>
<div className="form-grid">
<p className="muted">
{t('operations.convertBody', {
date: formatDate(operation.occurredOn, locale),
amount: formatMoney(operation.amountKopecks, locale),
})}
</p>
<div className="modal-actions">
<Button variant="subtle" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
disabled={convert.isPending}
onClick={() => convert.mutate({ id: operation.id }, { onSuccess: onClose })}
>
{t('operations.convert')}
</Button>
</div>
</div>
</Modal>
)
}
function SavingsRevertModal({
operation,
onClose,
}: {
operation: Operation
onClose: () => void
}) {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
const revert = useRevertSavingsConversion()
return (
<Modal open onClose={onClose} title={t('operations.revertTitle')}>
<div className="form-grid">
<p className="muted">
{t('operations.revertBody', {
date: formatDate(operation.occurredOn, locale),
amount: formatMoney(operation.amountKopecks, locale),
})}
</p>
<div className="modal-actions">
<Button variant="subtle" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
disabled={revert.isPending}
onClick={() => revert.mutate({ id: operation.id }, { onSuccess: onClose })}
>
{t('operations.revert')}
</Button>
</div>
</div>
</Modal>
)
}
export function OperationsPage() {
const { t, i18n } = useTranslation()
const locale = currentLocale(i18n.language)
@@ -481,9 +555,13 @@ export function OperationsPage() {
const operations = useOperations(filters)
const [createOpen, setCreateOpen] = useState(false)
const [voidTarget, setVoidTarget] = useState<Operation | null>(null)
const [convertTarget, setConvertTarget] = useState<Operation | null>(null)
const [revertTarget, setRevertTarget] = useState<Operation | null>(null)
const accountName = (id: string | null) =>
accounts.data?.find((account) => account.id === id)?.name ?? '—'
const accountName = (id: string | null) => {
const account = accounts.data?.find((item) => item.id === id)
return account ? accountLabel(account, t) : '—'
}
const goNext = () => {
const next = operations.data?.nextCursor
@@ -536,7 +614,7 @@ export function OperationsPage() {
<option value="">{t('operations.filterAll')}</option>
{(accounts.data ?? []).map((account) => (
<option key={account.id} value={account.id}>
{account.name}
{accountLabel(account, t)}
</option>
))}
</SelectField>
@@ -618,6 +696,22 @@ export function OperationsPage() {
<tbody>
{page.items.map((operation) => {
const amount = operationAmount(operation, locale)
const isConvertedTransfer =
operation.kind === 'transfer' && operation.convertedFromExpense
const sourceAccount =
accounts.data?.find((item) => item.id === operation.accountId) ?? null
const canConvert =
operation.kind === 'expense' &&
operation.status === 'confirmed' &&
!(sourceAccount && isSystemAccount(sourceAccount))
const categoryText =
operation.kind === 'adjustment'
? '—'
: operation.categoryKey
? t(`categories.${operation.categoryKey}`, {
defaultValue: operation.categoryName ?? operation.categoryKey,
})
: operation.categoryName ?? t('operations.noCategory')
return (
<tr
key={operation.id}
@@ -644,16 +738,34 @@ export function OperationsPage() {
: ''}
</td>
<td data-label={t('operations.category')} className="operation-category">
{operation.categoryName ?? t('operations.noCategory')}
{categoryText}
</td>
<td data-label={t('common.amount')} className={`operation-amount num ${amount.tone}`}>
{amount.text}
</td>
<td data-label={t('common.actions')} className="operation-actions">
{operation.status === 'confirmed' ? (
<Button variant="subtle" onClick={() => setVoidTarget(operation)}>
{t('operations.void')}
</Button>
<span className="inline-actions">
{canConvert ? (
<Button
variant="subtle"
onClick={() => setConvertTarget(operation)}
>
{t('operations.convert')}
</Button>
) : null}
{isConvertedTransfer ? (
<Button
variant="subtle"
onClick={() => setRevertTarget(operation)}
>
{t('operations.revert')}
</Button>
) : null}
<Button variant="subtle" onClick={() => setVoidTarget(operation)}>
{t('operations.void')}
</Button>
</span>
) : null}
</td>
</tr>
@@ -676,6 +788,12 @@ export function OperationsPage() {
{voidTarget ? (
<VoidModal operation={voidTarget} onClose={() => setVoidTarget(null)} />
) : null}
{convertTarget ? (
<SavingsConvertModal operation={convertTarget} onClose={() => setConvertTarget(null)} />
) : null}
{revertTarget ? (
<SavingsRevertModal operation={revertTarget} onClose={() => setRevertTarget(null)} />
) : null}
</>
)
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { PageHeader } from '@/components/ui/PageHeader'
import { QueryGate } from '@/components/ui/QueryGate'
import { useBudgetCategories, useBudgetSummary } from '@/hooks/useBudget'
@@ -61,7 +62,7 @@ export function OverviewPage() {
<aside className="decision-panel">
<div className="section-heading"><h2>{t('overview.closeReadiness')}</h2><span>{data.blockers.length} {t('overview.blockers')}</span></div>
{data.blockers.length > 0 ? (
<ul>{data.blockers.map((blocker) => <li key={blocker.code}>{t(`budget.blockers.${blocker.code}`, { defaultValue: blocker.message ?? blocker.code })}</li>)}</ul>
<BudgetBlockers blockers={data.blockers} month={month} />
) : <p>{t('overview.readyToClose')}</p>}
{attentionImports.length > 0 ? <p>{t('overview.importsNeedReview', { count: attentionImports.length })}</p> : null}
<Link className="btn btn-subtle" to="/review">{t('overview.reviewBlockers')}</Link>
+47 -12
View File
@@ -1,20 +1,25 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import type { BudgetCategory, Operation } from '@/api/types'
import { Link, useSearchParams } from 'react-router-dom'
import type { BudgetCategory, ImportSummary, Operation } from '@/api/types'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { MonthSwitcher } from '@/components/ui/MonthSwitcher'
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 { currentMonth, formatDateTime, isValidMonth, monthBounds } from '@/lib/dates'
import { currentLocale } from '@/lib/locale'
import { IMPORT_STATUS_TONES } from '@/lib/operationFormat'
function needsAttention(item: ImportSummary): boolean {
return item.status === 'review_required' || item.status === 'failed' || item.unresolvedHoldCount > 0
}
function UncategorizedRow({
operation,
categories,
@@ -68,23 +73,41 @@ function UncategorizedRow({
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 [searchParams, setSearchParams] = useSearchParams()
const problematicImports = (imports.data ?? []).filter(
(item) => item.status === 'review_required' || item.status === 'failed',
)
const monthParam = searchParams.get('month')
const month = monthParam && isValidMonth(monthParam) ? monthParam : currentMonth()
const bounds = monthBounds(month)
const setMonth = (next: string) => {
setSearchParams(next === currentMonth() ? {} : { month: next })
}
const imports = useImports(month)
const uncategorized = useOperations({
uncategorized: true,
status: 'confirmed',
limit: 50,
occurredFrom: bounds.start,
occurredTo: bounds.end,
})
const categories = useBudgetCategories(month)
const problematicImports = (imports.data ?? []).filter(needsAttention)
const hasWork = problematicImports.length > 0 || (uncategorized.data?.items.length ?? 0) > 0
return (
<>
<PageHeader title={t('review.title')} subtitle={t('review.subtitle')} />
<PageHeader
title={t('review.title')}
subtitle={t('review.subtitle')}
actions={<MonthSwitcher month={month} onChange={setMonth} />}
/>
{imports.isPending || uncategorized.isPending ? null : !hasWork ? (
<EmptyState title={t('review.empty')} />
) : null}
{problematicImports.length > 0 ? (
<section aria-label={t('review.sectionImports')}>
<section id="holds" 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>
@@ -93,6 +116,7 @@ export function ReviewQueuePage() {
<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.unresolvedHolds')}</th>
<th scope="col">{t('imports.createdAt')}</th>
<th scope="col">{t('common.actions')}</th>
</tr>
@@ -103,12 +127,23 @@ export function ReviewQueuePage() {
<th scope="row" data-label={t('imports.fileName')}>
{item.fileName}
</th>
<td data-label={t('imports.bank')}>{item.bankAdapter}</td>
<td data-label={t('imports.bank')}>
{t(`imports.adapters.${item.bankAdapter}`, {
defaultValue: 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.unresolvedHolds')} className="num">
{item.unresolvedHoldCount > 0 ? (
<Badge tone="warn">{item.unresolvedHoldCount}</Badge>
) : (
'0'
)}
</td>
<td data-label={t('imports.createdAt')}>
{formatDateTime(item.createdAt, locale)}
</td>
+37 -1
View File
@@ -545,6 +545,16 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.banner-success { color: var(--green); background: var(--green-soft); border-color: #82c7a1; }
.banner summary { cursor: pointer; font-weight: 600; }
.blocker-list { display: grid; gap: 8px; margin: 8px 0 0; padding: 0; list-style: none; }
.blocker-item { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.blocker-link { color: var(--blue); font-weight: 600; white-space: nowrap; }
.adjust-notice { margin: 0; padding: 10px 12px; color: var(--amber); background: var(--amber-soft); border: 1px solid #e5c16a; font-size: 12px; }
.adjust-current { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; color: #9d9d98; font-family: var(--font-data); font-size: 10px; text-transform: uppercase; }
.adjust-current strong { color: var(--white); font-size: 20px; text-transform: none; }
.hold-history { display: grid; gap: 4px; justify-items: start; font-size: 12px; }
.modal {
width: min(430px, 100vw);
max-width: none;
@@ -580,6 +590,22 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.month-switch { display: inline-flex; align-items: center; }
.month-label { min-width: 145px; text-align: center; font-family: var(--font-data); font-size: 11px; font-weight: 600; }
.categories-heading { margin: 34px 0 14px; }
.categories-heading .section-title { margin: 0; }
.manager-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, auto) auto; gap: 10px; align-items: end; margin-bottom: 18px; }
.manager-form .btn { min-height: 44px; }
.manager-tabs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; margin-bottom: 14px; border: 1px solid #3d3d3a; }
.manager-tabs .btn { min-width: 0; color: #858580; border: 0; border-right: 1px solid #3d3d3a; font-family: var(--font-data); font-size: 10px; }
.manager-tabs .btn:last-child { border-right: 0; }
.manager-tabs .btn-primary { color: var(--white); background: var(--blue); }
.manager-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; border-top: 1px solid #3d3d3a; }
.manager-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 10px 0; border-bottom: 1px solid #3d3d3a; }
.manager-row-main { display: grid; gap: 3px; min-width: 0; }
.manager-row-name { font-weight: 500; }
.manager-row-meta { color: #858580; font-family: var(--font-data); font-size: 10px; }
.manager-row-actions .btn { min-height: 30px; padding: 5px 8px; font-size: 11px; }
.steps {
display: grid;
grid-template-columns: repeat(5, 1fr);
@@ -665,6 +691,7 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.decision-panel li { padding: 10px 0; color: #d5d5d1; border-bottom: 1px solid #323230; font-size: 12px; }
.decision-panel p { color: #b9b9b5; font-size: 12px; }
.decision-panel .btn { width: 100%; color: #a8a8a3; background: #2e2e2c; border: 0; }
.decision-panel .blocker-link { color: #a9c4ff; }
.category-pulse { padding-top: 4px; }
.pulse-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--line); border-left: 1px solid var(--line); }
.pulse-item { display: grid; grid-template-columns: 1fr auto; gap: 5px 12px; min-width: 0; padding: 16px; background: var(--white); border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); }
@@ -727,7 +754,7 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
inset: auto 0 0;
z-index: 50;
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
min-height: 62px;
background: var(--white);
border-top: 1px solid var(--line);
@@ -743,6 +770,11 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.page-actions { justify-content: flex-end; }
.page-actions-row { justify-content: flex-end; }
.page-actions .btn { min-height: 34px; padding-inline: 10px; font-size: 11px; }
.view-budget .page-header,
.view-review .page-header { flex-direction: column; gap: 14px; }
.view-budget .page-actions,
.view-budget .page-actions-row,
.view-review .page-actions { width: 100%; justify-content: flex-start; }
.stats { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-inline: -14px; }
.stat { padding: 14px 10px; }
.stat:nth-child(n + 4) { display: none; }
@@ -765,6 +797,10 @@ input[type='file']::file-selector-button { min-height: 30px; margin-right: 10px;
.decision-panel li { color: var(--ink); }
.decision-panel li { border-color: #e0c57f; }
.decision-panel .btn { color: var(--ink); background: transparent; border: 1px solid var(--ink); }
.decision-panel .blocker-link { color: var(--blue); }
.manager-form { grid-template-columns: 1fr; }
.manager-row-actions { width: 100%; }
.manager-row-actions .btn { flex: 1; }
.pulse-grid { display: block; border: 0; }
.pulse-item { padding: 13px 0; border-width: 0 0 1px; background: transparent; }
.mobile-primary-action { position: fixed; right: 12px; bottom: 72px; z-index: 45; display: block; }
@@ -0,0 +1,125 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { Account, AdjustBalanceResult } 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(),
adjustBalance: vi.fn(),
},
},
}))
const listMock = vi.mocked(api.accounts.list)
const adjustBalanceMock = vi.mocked(api.accounts.adjustBalance)
function account(overrides: Partial<Account> = {}): Account {
return {
id: 'a1',
name: 'Alfa Card',
type: 'debit',
status: 'active',
bankCode: 'alfa',
maskedNumber: null,
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
}
describe('AccountsPage balance adjustment', () => {
beforeEach(() => {
listMock.mockResolvedValue([
account(),
account({
id: 'a-sys',
name: 'Накопления',
nameKey: 'account.savings',
type: 'savings',
systemKey: 'savings',
balanceKopecks: 4500000,
}),
])
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders the savings system account with a system badge and without archive', async () => {
const screen = renderWithProviders(<AccountsPage />)
const savingsNameCell = (
await screen.findAllByText('Savings')
).find((element) => element.tagName === 'TH')
expect(savingsNameCell).toBeDefined()
expect(screen.getByText('System')).toBeInTheDocument()
const savingsRow = savingsNameCell?.closest('tr')
expect(savingsRow?.textContent).toContain('45,000.00')
const archiveButtons = screen.getAllByRole('button', { name: 'Archive' })
expect(archiveButtons).toHaveLength(1)
})
it('adjusts the balance through the drawer with kopeck conversion and reason', async () => {
adjustBalanceMock.mockResolvedValue({ id: 'op-1' } as unknown as AdjustBalanceResult)
const screen = renderWithProviders(<AccountsPage />)
const savingsNameCell = (
await screen.findAllByText('Savings')
).find((element) => element.tagName === 'TH')
const savingsRow = savingsNameCell?.closest('tr')
const adjustButtonsInRow = Array.from(savingsRow?.querySelectorAll('button') ?? []).filter(
(button) => button.textContent === 'Adjust',
)
await userEvent.click(adjustButtonsInRow[0] as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Adjust balance' })
expect(dialog).toBeInTheDocument()
expect(screen.getByText('Current balance')).toBeInTheDocument()
expect(screen.getByText(/not income or expense/i)).toBeInTheDocument()
const targetInput = screen.getByLabelText('New balance')
expect(targetInput).toHaveValue('45000')
await userEvent.clear(targetInput)
await userEvent.type(targetInput, '50000')
await userEvent.type(screen.getByLabelText('Reason'), 'Bank interest payout')
await userEvent.click(within(dialog).getByRole('button', { name: 'Adjust' }))
expect(adjustBalanceMock).toHaveBeenCalledTimes(1)
expect(adjustBalanceMock).toHaveBeenCalledWith('a-sys', {
targetBalanceKopecks: 5000000,
occurredOn: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
reason: 'Bank interest payout',
})
expect(await screen.findByText('Account balance adjusted')).toBeInTheDocument()
})
it('requires a non-empty reason before submitting', async () => {
const screen = renderWithProviders(<AccountsPage />)
const savingsNameCell = (
await screen.findAllByText('Savings')
).find((element) => element.tagName === 'TH')
const savingsRow = savingsNameCell?.closest('tr')
const adjustButtonsInRow = Array.from(savingsRow?.querySelectorAll('button') ?? []).filter(
(button) => button.textContent === 'Adjust',
)
await userEvent.click(adjustButtonsInRow[0] as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Adjust balance' })
await userEvent.click(within(dialog).getByRole('button', { name: 'Adjust' }))
expect(await screen.findByText('Required field')).toBeInTheDocument()
expect(adjustBalanceMock).not.toHaveBeenCalled()
})
})
+2
View File
@@ -28,6 +28,8 @@ function account(overrides: Partial<Account> = {}): Account {
maskedNumber: '•• 4021',
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
@@ -0,0 +1,64 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { screen, within } from '@testing-library/react'
import { i18n } from '@/i18n'
import type { BudgetBlocker } from '@/api/types'
import { BudgetBlockers } from '@/components/budget/BudgetBlockers'
import { renderWithProviders } from './helpers'
describe('BudgetBlockers rendering', () => {
afterEach(async () => {
await i18n.changeLanguage('en')
vi.restoreAllMocks()
})
it('renders known blockers with counts and targeted CTAs', () => {
const blockers: BudgetBlocker[] = [
{ code: 'IMPORTS_UNRESOLVED', count: 2 },
{ code: 'IMPORT_UNRESOLVED_HOLDS', count: 3 },
{ code: 'BUDGET_UNCATEGORIZED_EXPENSES', count: 5 },
{ code: 'BUDGET_NEGATIVE_ENVELOPE', categories: ['Savings', 'Car fund'] },
]
renderWithProviders(<BudgetBlockers blockers={blockers} month="2026-02" />)
expect(
screen.getByText('Statement imports are unfinished: 2'),
).toBeInTheDocument()
expect(
screen.getByText('Unconfirmed operations without a resolution: 3'),
).toBeInTheDocument()
expect(screen.getByText('Expenses without a category: 5')).toBeInTheDocument()
expect(screen.getByText('Negative envelope balance: Savings, Car fund')).toBeInTheDocument()
const links = screen.getAllByRole('link')
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/review?month=2026-02',
'/review?month=2026-02#holds',
'/review?month=2026-02',
'/budget#categories',
])
expect(links[0]).toHaveTextContent('Open review queue')
expect(links[1]).toHaveTextContent('Resolve operations')
expect(links[2]).toHaveTextContent('Assign categories')
expect(links[3]).toHaveTextContent('Open categories')
})
it('localizes unknown blocker codes instead of showing the raw enum', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
renderWithProviders(
<BudgetBlockers blockers={[{ code: 'SOMETHING_NEW' }]} month="2026-02" />,
)
expect(screen.getByText('Close is blocked by an unresolved reason.')).toBeInTheDocument()
expect(screen.queryByText('SOMETHING_NEW')).not.toBeInTheDocument()
expect(screen.queryByRole('link')).not.toBeInTheDocument()
expect(warnSpy).toHaveBeenCalledWith('[budget] unknown blocker code', 'SOMETHING_NEW')
await i18n.changeLanguage('ru')
expect(
await screen.findByText('Закрытие заблокировано необработанной причиной.'),
).toBeInTheDocument()
expect(
within(screen.getByRole('list')).queryByText(/SOMETHING_NEW/),
).not.toBeInTheDocument()
})
})
+2
View File
@@ -46,6 +46,7 @@ const categories: BudgetCategory[] = [
name: null,
nameKey: 'groceries',
kind: 'monthly',
system: false,
archived: false,
creditAccountId: null,
plannedKopecks: 200000,
@@ -58,6 +59,7 @@ const categories: BudgetCategory[] = [
name: 'Buffer',
nameKey: null,
kind: 'savings_envelope',
system: false,
archived: false,
creditAccountId: null,
plannedKopecks: 0,
+191
View File
@@ -0,0 +1,191 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { BudgetCategory, BudgetSummary, CategoryDefinition } from '@/api/types'
import { BudgetPage } from '@/pages/BudgetPage'
import { currentMonth } from '@/lib/dates'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
},
}))
const summaryMock = vi.mocked(api.budget.summary)
const categoriesMock = vi.mocked(api.budget.categories)
const definitionsMock = vi.mocked(api.budget.categoryDefinitions)
const createCategoryMock = vi.mocked(api.budget.createCategory)
const updateDefinitionMock = vi.mocked(api.budget.updateCategoryDefinition)
const month = currentMonth()
const summary: BudgetSummary = {
month,
status: 'open',
projectedPoolKopecks: 8000000,
actualPoolKopecks: 7000000,
plannedTotalKopecks: 5000000,
fundedTotalKopecks: 4000000,
spentTotalKopecks: 1200000,
availableKopecks: 3000000,
warnings: [],
blockers: [],
expectedIncomes: [],
}
const monthCategories: BudgetCategory[] = []
const diningDefinition: CategoryDefinition = {
id: 'def-dining',
name: 'Dining',
nameKey: null,
kind: 'monthly',
systemKey: null,
linkedAccountId: null,
archived: false,
}
const definitions: CategoryDefinition[] = [
diningDefinition,
{
id: 'def-savings',
name: 'Накопления',
nameKey: 'savings',
kind: 'savings_envelope',
systemKey: 'savings',
linkedAccountId: 'a-sys',
archived: false,
},
{
id: 'def-old-gym',
name: 'Old Gym',
nameKey: null,
kind: 'monthly',
systemKey: null,
linkedAccountId: null,
archived: true,
},
]
function definition(overrides: Partial<CategoryDefinition>): CategoryDefinition {
return { ...diningDefinition, ...overrides }
}
describe('BudgetPage category manager lifecycle', () => {
beforeEach(() => {
summaryMock.mockResolvedValue(summary)
categoriesMock.mockResolvedValue(monthCategories)
definitionsMock.mockResolvedValue(definitions)
})
afterEach(() => {
vi.clearAllMocks()
})
async function openManager() {
const screen = renderWithProviders(<BudgetPage />)
await screen.findByText('Manage categories')
await userEvent.click(screen.getByRole('button', { name: 'Manage categories' }))
const dialog = await screen.findByRole('dialog', { name: 'Manage categories' })
return { screen, dialog }
}
it('creates a monthly category from the compact form', async () => {
createCategoryMock.mockResolvedValue(definition({ id: 'def-new', name: 'Dining' }))
const { screen, dialog } = await openManager()
await userEvent.type(within(dialog).getByLabelText('Name'), 'Dining')
await userEvent.click(within(dialog).getByRole('button', { name: 'Create' }))
expect(createCategoryMock).toHaveBeenCalledTimes(1)
expect(createCategoryMock).toHaveBeenCalledWith({ name: 'Dining', kind: 'monthly' })
expect(await screen.findByText('Category “Dining” created')).toBeInTheDocument()
})
it('renames and archives a user category through inline actions', async () => {
updateDefinitionMock.mockResolvedValue(definition({ name: 'Eating out' }))
const { screen, dialog } = await openManager()
const diningRow = (await within(dialog).findByText('Dining')).closest('li')
expect(diningRow).not.toBeNull()
await userEvent.click(
Array.from(diningRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Rename',
) as HTMLButtonElement,
)
const renameDialog = await screen.findByRole('dialog', { name: 'Rename category' })
const nameInput = within(renameDialog).getByLabelText('Name')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'Eating out')
await userEvent.click(within(renameDialog).getByRole('button', { name: 'Save' }))
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', { name: 'Eating out' })
await userEvent.click(
Array.from(diningRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Archive',
) as HTMLButtonElement,
)
const archiveDialog = await screen.findByRole('dialog', { name: 'Archive category' })
await userEvent.click(
Array.from(archiveDialog.querySelectorAll('button')).find(
(button) => button.textContent === 'Archive',
) as HTMLButtonElement,
)
expect(updateDefinitionMock).toHaveBeenCalledWith('def-dining', { archived: true })
expect(await screen.findByText('Category archived')).toBeInTheDocument()
})
it('protects the system savings category and restores archived user categories', async () => {
updateDefinitionMock.mockResolvedValue(definition({ id: 'def-old-gym', archived: false }))
const { screen, dialog } = await openManager()
const savingsRow = (await within(dialog).findByText('Savings')).closest('li')
expect(savingsRow).not.toBeNull()
expect(savingsRow?.textContent).toContain('System')
expect(savingsRow?.textContent).toContain('renaming and archiving are unavailable')
expect(within(savingsRow as HTMLElement).queryByRole('button', { name: 'Rename' })).toBeNull()
expect(within(savingsRow as HTMLElement).queryByRole('button', { name: 'Archive' })).toBeNull()
const tabs = dialog.querySelector('.manager-tabs') as HTMLElement
await userEvent.click(within(tabs).getByRole('button', { name: 'Archive' }))
const oldGymRow = (await within(dialog).findByText('Old Gym')).closest('li')
expect(oldGymRow).not.toBeNull()
await userEvent.click(
Array.from(oldGymRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Restore',
) as HTMLButtonElement,
)
expect(updateDefinitionMock).toHaveBeenCalledWith('def-old-gym', { archived: false })
expect(await screen.findByText('Category restored')).toBeInTheDocument()
})
})
@@ -0,0 +1,155 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { ImportDetail, ImportRow } from '@/api/types'
import { ImportDetailPage } from '@/pages/ImportDetailPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
imports: {
list: vi.fn(),
get: vi.fn(),
upload: vi.fn(),
saveRowDecision: vi.fn(),
resolveHold: vi.fn(),
confirm: vi.fn(),
reparse: vi.fn(),
revert: vi.fn(),
},
},
}))
const getImportMock = vi.mocked(api.imports.get)
const resolveHoldMock = vi.mocked(api.imports.resolveHold)
function row(overrides: Partial<ImportRow>): ImportRow {
return {
id: 'row-1',
externalId: 'ext-1',
occurredOn: '2026-09-10',
amountKopecks: 150000,
direction: 'out',
bankStatus: 'posted',
description: 'Coffee beans',
payeeHint: null,
warnings: [],
decision: 'create',
duplicateOfOperationId: null,
holdResolution: null,
...overrides,
}
}
const unresolvedHold = row({
id: 'row-hold',
bankStatus: 'hold',
decision: 'pending',
description: 'Card freeze at cafe',
})
const resolvedHold = row({
id: 'row-hold-done',
externalId: 'ext-2',
bankStatus: 'hold',
decision: 'pending',
description: 'Frozen taxi charge',
holdResolution: {
resolution: 'confirmed_as_posted',
resolvingSourceRecordId: null,
operationId: 'op-1',
resolvedByUserId: 'u1',
reason: 'Money left the account later',
resolvedAt: '2026-09-21T12:00:00Z',
},
})
function detailFixture(rows: ImportRow[]): ImportDetail {
return {
id: 'imp-1',
fileName: 'alfa.xlsx',
bankAdapter: 'alfa_debit_xlsx',
status: 'confirmed',
accountId: 'a1',
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
unresolvedHoldCount: rows.filter((item) => item.bankStatus === 'hold' && !item.holdResolution)
.length,
createdAt: '2026-09-01T10:00:00Z',
failureReason: null,
blockers: [],
warnings: [],
reconciliation: {
status: 'matched',
openingBalanceKopecks: null,
closingBalanceKopecks: null,
computedClosingKopecks: null,
reason: null,
},
rows,
totals: {
total: rows.length,
create: 1,
discard: 0,
duplicate: 0,
transfer: 0,
savingsTransfer: 0,
pending: rows.filter((item) => item.decision === 'pending').length,
},
}
}
describe('ImportDetailPage HOLD resolution', () => {
beforeEach(() => {
getImportMock.mockResolvedValue(
detailFixture([row({}), unresolvedHold, resolvedHold]),
)
})
afterEach(() => {
vi.clearAllMocks()
})
it('shows resolution actions instead of the decision select for unresolved holds', async () => {
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByText('alfa.xlsx')
const selects = screen.getAllByRole('combobox')
expect(selects).toHaveLength(1)
expect(screen.getAllByText('Hold')).toHaveLength(2)
expect(screen.getByRole('button', { name: 'Operation posted' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Operation cancelled' })).toBeInTheDocument()
})
it('requires a reason and resolves a hold as posted', async () => {
resolveHoldMock.mockResolvedValue(detailFixture([row({}), resolvedHold]))
const screen = renderWithProviders(<ImportDetailPage />)
await screen.findByRole('button', { name: 'Operation posted' })
await userEvent.click(screen.getByRole('button', { name: 'Operation posted' }))
const dialog = await screen.findByRole('dialog', { name: 'Operation posted' })
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(await screen.findByText('Required field')).toBeInTheDocument()
expect(resolveHoldMock).not.toHaveBeenCalled()
await userEvent.type(screen.getByLabelText('Resolution reason'), 'Charge cleared at the bank')
await userEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }))
expect(resolveHoldMock).toHaveBeenCalledTimes(1)
expect(resolveHoldMock).toHaveBeenCalledWith('imp-1', 'row-hold', {
action: 'confirm_as_posted',
reason: 'Charge cleared at the bank',
})
})
it('displays the resolved history of settled holds', async () => {
const screen = renderWithProviders(<ImportDetailPage />)
const historyRow = (await screen.findByText('Money left the account later')).closest('tr')
expect(historyRow?.textContent).toContain('Posted')
expect(historyRow?.querySelectorAll('button')).toHaveLength(0)
})
})
+10 -1
View File
@@ -32,6 +32,7 @@ function detail(status: ImportStatus): ImportDetail {
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
unresolvedHoldCount: 0,
createdAt: '2026-09-01T10:00:00Z',
failureReason: null,
blockers: [],
@@ -44,7 +45,15 @@ function detail(status: ImportStatus): ImportDetail {
reason: null,
},
rows: [],
totals: { total: 0, create: 0, discard: 0, duplicate: 0, transfer: 0 },
totals: {
total: 0,
create: 0,
discard: 0,
duplicate: 0,
transfer: 0,
savingsTransfer: 0,
pending: 0,
},
}
}
@@ -0,0 +1,110 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { api } from '@/api/endpoints'
import type { BudgetCategory, ImportSummary, Page, Operation } from '@/api/types'
import { ReviewQueuePage } from '@/pages/ReviewQueuePage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
imports: {
list: vi.fn(),
get: vi.fn(),
upload: vi.fn(),
saveRowDecision: vi.fn(),
resolveHold: vi.fn(),
confirm: vi.fn(),
reparse: vi.fn(),
revert: vi.fn(),
},
},
}))
const importsListMock = vi.mocked(api.imports.list)
const operationsListMock = vi.mocked(api.operations.list)
const categoriesMock = vi.mocked(api.budget.categories)
const importSummary: ImportSummary = {
id: 'imp-hold',
fileName: 'alfa-february.xlsx',
bankAdapter: 'alfa_debit_xlsx',
status: 'confirmed',
accountId: 'a1',
accountName: 'Alfa Card',
periodFrom: null,
periodTo: null,
unresolvedHoldCount: 2,
createdAt: '2026-03-01T10:00:00Z',
}
describe('ReviewQueuePage month awareness', () => {
beforeEach(() => {
importsListMock.mockResolvedValue([importSummary])
operationsListMock.mockResolvedValue({ items: [], nextCursor: null } as Page<Operation>)
categoriesMock.mockResolvedValue([] as BudgetCategory[])
})
afterEach(() => {
vi.clearAllMocks()
})
it('scopes imports, categories and uncategorized operations to the ?month= param', async () => {
const screen = renderWithProviders(<ReviewQueuePage />, '/review?month=2026-02')
await screen.findByText('alfa-february.xlsx')
expect(importsListMock).toHaveBeenCalledWith('2026-02')
expect(categoriesMock).toHaveBeenCalledWith('2026-02')
expect(operationsListMock).toHaveBeenCalledWith(
expect.objectContaining({
uncategorized: true,
status: 'confirmed',
occurredFrom: '2026-02-01',
occurredTo: '2026-02-28',
}),
)
})
it('keeps confirmed imports with unresolved holds visible for the month', async () => {
const screen = renderWithProviders(<ReviewQueuePage />, '/review?month=2026-02')
const row = (await screen.findByText('alfa-february.xlsx')).closest('tr')
expect(row?.textContent).toContain('2')
const link = screen.getByRole('link', { name: 'Open import' })
expect(link).toHaveAttribute('href', '/imports/imp-hold')
})
it('falls back to the current month without a query param', async () => {
const screen = renderWithProviders(<ReviewQueuePage />)
await screen.findByText('alfa-february.xlsx')
expect(importsListMock).toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}$/))
expect(importsListMock.mock.calls[0]?.[0]).not.toBe('2026-02')
})
})
@@ -0,0 +1,183 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { api } from '@/api/endpoints'
import type { Account, BudgetCategory, Operation } from '@/api/types'
import { OperationsPage } from '@/pages/OperationsPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
},
}))
const listAccountsMock = vi.mocked(api.accounts.list)
const listOperationsMock = vi.mocked(api.operations.list)
const savingsTransferMock = vi.mocked(api.operations.savingsTransfer)
const revertMock = vi.mocked(api.operations.savingsTransferRevert)
const budgetCategoriesMock = vi.mocked(api.budget.categories)
function account(overrides: Partial<Account> = {}): Account {
return {
id: 'a1',
name: 'Alfa Card',
type: 'debit',
status: 'active',
bankCode: null,
maskedNumber: null,
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
}
function operation(overrides: Partial<Operation> & { id: string }): Operation {
return {
kind: 'expense',
status: 'confirmed',
amountKopecks: 500000,
occurredOn: '2026-09-05',
postedOn: null,
accountId: 'a1',
counterpartyAccountId: null,
categoryId: null,
categoryName: null,
categoryKey: null,
payeeName: 'Coffee shop',
incomeSourceName: null,
description: null,
parentOperationId: null,
convertedFromExpense: false,
version: 1,
createdAt: '2026-09-05T10:00:00Z',
...overrides,
}
}
const operations: Operation[] = [
operation({ id: 'op-expense' }),
operation({
id: 'op-converted',
kind: 'transfer',
counterpartyAccountId: 'a-sys',
categoryKey: 'savings',
payeeName: null,
convertedFromExpense: true,
}),
operation({
id: 'op-adjust',
kind: 'adjustment',
amountKopecks: 25000,
payeeName: null,
description: 'Cash delivery',
}),
]
describe('OperationsPage savings convert/revert and adjustment rendering', () => {
beforeEach(() => {
listAccountsMock.mockResolvedValue([
account(),
account({
id: 'a-sys',
name: 'Накопления',
nameKey: 'account.savings',
type: 'savings',
systemKey: 'savings',
balanceKopecks: 0,
}),
])
listOperationsMock.mockResolvedValue({ items: operations, nextCursor: null })
budgetCategoriesMock.mockResolvedValue([] as BudgetCategory[])
})
afterEach(() => {
vi.clearAllMocks()
})
it('converts a confirmed expense into a savings transfer after explicit confirmation', async () => {
savingsTransferMock.mockResolvedValue(operation({ id: 'op-expense', kind: 'transfer' }))
const screen = renderWithProviders(<OperationsPage />)
const row = (await screen.findByText('Coffee shop')).closest('tr') as HTMLTableRowElement
const convertButton = Array.from(row.querySelectorAll('button')).find(
(button) => button.textContent === 'To savings',
)
expect(convertButton).toBeDefined()
await userEvent.click(convertButton as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Convert to savings' })
expect(dialog.textContent).toContain('leaves the month')
expect(dialog.textContent).toContain('grows the Savings account')
await userEvent.click(
withinDialogButton(dialog, 'To savings') as HTMLButtonElement,
)
expect(savingsTransferMock).toHaveBeenCalledTimes(1)
expect(savingsTransferMock).toHaveBeenCalledWith('op-expense')
})
it('reverts a converted savings transfer back to an expense', async () => {
revertMock.mockResolvedValue(operation({ id: 'op-converted', kind: 'expense' }))
const screen = renderWithProviders(<OperationsPage />)
expect(await screen.findByText('Coffee shop')).toBeInTheDocument()
const convertedRow = screen
.getAllByRole('row')
.find((row) => row.textContent?.includes('Savings →') || row.textContent?.includes('→ Savings'))
const revertButton = Array.from(convertedRow?.querySelectorAll('button') ?? []).find(
(button) => button.textContent === 'Back to expense',
)
expect(revertButton).toBeDefined()
await userEvent.click(revertButton as HTMLButtonElement)
const dialog = await screen.findByRole('dialog', { name: 'Revert to expense' })
await userEvent.click(withinDialogButton(dialog, 'Back to expense') as HTMLButtonElement)
expect(revertMock).toHaveBeenCalledTimes(1)
expect(revertMock).toHaveBeenCalledWith('op-converted')
})
it('renders adjustments with a neutral amount, adjustment badge and reason', async () => {
const screen = renderWithProviders(<OperationsPage />)
const row = (await screen.findByText('Cash delivery')).closest('tr') as HTMLTableRowElement
expect(row.textContent).toContain('Adjustment')
const amountCell = row.querySelector('.operation-amount')
expect(amountCell?.textContent).toBe('₽250.00')
expect(amountCell?.textContent?.startsWith('−')).toBe(false)
expect(amountCell?.textContent?.startsWith('+')).toBe(false)
})
})
function withinDialogButton(dialog: HTMLElement, label: string): HTMLElement | undefined {
return Array.from(dialog.querySelectorAll('button')).find(
(button) => button.textContent === label && !button.hasAttribute('aria-label'),
)
}
@@ -0,0 +1,134 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import userEvent from '@testing-library/user-event'
import { within } from '@testing-library/react'
import { api } from '@/api/endpoints'
import type { Account, BudgetCategory, Page, Operation } from '@/api/types'
import { ImportsPage } from '@/pages/ImportsPage'
import { OperationsPage } from '@/pages/OperationsPage'
import { renderWithProviders } from './helpers'
vi.mock('@/api/endpoints', () => ({
api: {
accounts: {
list: vi.fn(),
create: vi.fn(),
archive: vi.fn(),
adjustBalance: vi.fn(),
},
operations: {
list: vi.fn(),
create: vi.fn(),
void: vi.fn(),
categorize: vi.fn(),
savingsTransfer: vi.fn(),
savingsTransferRevert: vi.fn(),
},
budget: {
summary: vi.fn(),
categories: vi.fn(),
updatePlan: vi.fn(),
fund: vi.fn(),
close: vi.fn(),
reopen: vi.fn(),
copyPrevious: vi.fn(),
categoryDefinitions: vi.fn(),
createCategory: vi.fn(),
updateCategoryDefinition: vi.fn(),
},
imports: {
list: vi.fn(),
get: vi.fn(),
upload: vi.fn(),
saveRowDecision: vi.fn(),
resolveHold: vi.fn(),
confirm: vi.fn(),
reparse: vi.fn(),
revert: vi.fn(),
},
},
}))
const listAccountsMock = vi.mocked(api.accounts.list)
const operationsListMock = vi.mocked(api.operations.list)
const budgetCategoriesMock = vi.mocked(api.budget.categories)
const importsListMock = vi.mocked(api.imports.list)
function account(overrides: Partial<Account> = {}): Account {
return {
id: 'a1',
name: 'Alfa Card',
type: 'debit',
status: 'active',
bankCode: null,
maskedNumber: null,
openedOn: '2026-01-15',
currency: 'RUB',
systemKey: null,
nameKey: null,
balanceKopecks: 123456,
...overrides,
}
}
describe('system savings account picker exclusion', () => {
beforeEach(() => {
listAccountsMock.mockResolvedValue([
account(),
account({ id: 'a2', name: 'Cash wallet', type: 'cash' }),
account({
id: 'a-sys',
name: 'Накопления',
nameKey: 'account.savings',
type: 'savings',
systemKey: 'savings',
balanceKopecks: 0,
}),
])
operationsListMock.mockResolvedValue({ items: [], nextCursor: null } as Page<Operation>)
budgetCategoriesMock.mockResolvedValue([] as BudgetCategory[])
importsListMock.mockResolvedValue([])
})
afterEach(() => {
vi.clearAllMocks()
})
it('hides the savings account in the expense form but keeps it in the transfer form and filters', async () => {
const screen = renderWithProviders(<OperationsPage />)
await userEvent.click(
await screen.findByRole('button', { name: '+ New operation' }),
)
const dialog = await screen.findByRole('dialog', { name: 'New operation' })
const expenseSelect = within(dialog).getByLabelText('Account') as HTMLSelectElement
const expenseOptions = Array.from(expenseSelect.options).map((option) => option.textContent)
expect(expenseOptions).toContain('Alfa Card')
expect(expenseOptions).not.toContain('Savings')
await userEvent.click(within(dialog).getByRole('button', { name: 'Transfer' }))
const transferSelects = within(dialog)
.getAllByRole('combobox')
.filter((select) => (select as HTMLSelectElement).options.length > 2) as HTMLSelectElement[]
const transferOptions = transferSelects.flatMap((select) =>
Array.from(select.options).map((option) => option.textContent),
)
expect(transferOptions).toContain('Savings')
const filterSelect = screen.getByLabelText('Account') as HTMLSelectElement
const filterOptions = Array.from(filterSelect.options).map((option) => option.textContent)
expect(filterOptions).toContain('Savings')
})
it('hides the savings account in the statement upload target selector', async () => {
const screen = renderWithProviders(<ImportsPage />)
await userEvent.click(await screen.findByRole('button', { name: '+ Upload statement' }))
const dialog = await screen.findByRole('dialog', { name: 'New import' })
const select = within(dialog).getByLabelText('Target account') as HTMLSelectElement
const options = Array.from(select.options).map((option) => option.textContent)
expect(options).toContain('Alfa Card')
expect(options).toContain('Cash wallet')
expect(options).not.toContain('Savings')
})
})