Update file metadata in manifest.json and change MinIO image source in docker-compose.yml
This commit is contained in:
@@ -1,2 +1,5 @@
|
||||
packages:
|
||||
- "."
|
||||
|
||||
allowBuilds:
|
||||
esbuild@0.25.12: true
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Жильё",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Locale } from '@/api/types'
|
||||
|
||||
export function currentLocale(language: string | undefined): Locale {
|
||||
return language?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user