first commit
All checks were successful
Deploy Production / deploy (push) Successful in 41s

This commit is contained in:
Заид Омар Медхат 2025-12-26 12:13:34 +05:00
commit 06c5887fd8
118 changed files with 12396 additions and 0 deletions

2
.env Normal file
View File

@ -0,0 +1,2 @@
VITE_API_HOST=http://localhost:3000
NODE_ENV=development

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# API Host URL (used during build)
VITE_API_HOST=https://api-finance.ai-assistant-bot.xyz

1
.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_HOST=https://api-finance.ai-assistant-bot.xyz

View File

@ -0,0 +1,24 @@
name: Deploy Production
on:
workflow_dispatch:
push:
branches:
- main
jobs:
deploy:
runs-on: docker
container:
image: docker:latest
steps:
- name: Install git
run: apk add --no-cache git
- name: Clone repository
run: git clone --depth 1 https://git.ai-assistant-bot.xyz/root/finance-front.git .
- name: Build & Deploy
run: |
docker compose -f docker-compose.server.yml build
docker compose -f docker-compose.server.yml up -d

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

37
Dockerfile Normal file
View File

@ -0,0 +1,37 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build argument for API URL
ARG VITE_API_HOST=https://api-finance.ai-assistant-bot.xyz
# Set environment variable for build
ENV VITE_API_HOST=$VITE_API_HOST
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine AS production
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

241
FRONTEND_FUNCTIONS.md Normal file
View File

@ -0,0 +1,241 @@
# Recommended Frontend Functions (API Client)
This document lists recommended frontend functions to implement against the backend REST API.
Base URL:
- Development: `http://localhost:3000/api/v1`
Notes:
- Authentication uses **HTTP-only cookies**. The frontend should use `fetch`/Axios with `credentials: 'include'`.
- All endpoints below are assumed to require auth unless explicitly public (e.g. register/login).
- Suggested return types are illustrative; align with the backend DTO/response shapes.
## Shared HTTP Helpers
- **`apiFetch<T>(path: string, options?: RequestInit): Promise<T>`**
- Sets `baseUrl`, JSON headers, `credentials: 'include'`, error normalization.
- **`parseApiError(e: unknown): { messageRu: string; status?: number; details?: any }`**
- Normalizes backend error responses.
- **`toQueryString(params: Record<string, any>): string`**
- For query endpoints (transactions search, analytics ranges, etc.).
## Auth (Session/Cookies)
- **`authRegister(payload: { email: string; password: string; firstName?: string; lastName?: string; phone?: string }): Promise<User>`**
- `POST /auth/register`
- **`authLogin(payload: { email: string; password: string }): Promise<User>`**
- `POST /auth/login`
- **`authRefresh(): Promise<void>`**
- `POST /auth/refresh`
- **`authLogout(): Promise<void>`**
- `POST /auth/logout`
- **`authLogoutAll(): Promise<void>`**
- `POST /auth/logout-all`
- **`authGetProfile(): Promise<User>`**
- `GET /auth/profile`
- **`authUpdateProfile(payload: { firstName?: string; lastName?: string; phone?: string }): Promise<User>`**
- `PUT /auth/profile`
- **`authChangePassword(payload: { currentPassword: string; newPassword: string; confirmPassword: string }): Promise<void>`**
- `POST /auth/change-password`
Suggested UI helpers:
- **`useAuth()` hook**
- `user`, `isAuthenticated`, `login()`, `logout()`, `refresh()`, `loadProfile()`
## Categories
- **`categoriesList(params?: { type?: 'INCOME' | 'EXPENSE' }): Promise<Category[]>`**
- `GET /categories?type=...`
- **`categoriesGetGrouped(): Promise<{ ESSENTIAL: Category[]; PERSONAL: Category[]; SAVINGS: Category[]; UNCATEGORIZED: Category[] }>`**
- `GET /categories/grouped`
- **`categoriesCreate(payload: { nameRu: string; nameEn?: string; type: 'INCOME' | 'EXPENSE'; groupType?: 'ESSENTIAL' | 'PERSONAL' | 'SAVINGS'; icon?: string; color?: string; parentId?: string }): Promise<Category>`**
- `POST /categories`
- **`categoriesUpdate(id: string, payload: Partial<{ nameRu: string; nameEn?: string; type: 'INCOME' | 'EXPENSE'; groupType?: 'ESSENTIAL' | 'PERSONAL' | 'SAVINGS'; icon?: string; color?: string; parentId?: string }>): Promise<Category>`**
- `PUT /categories/:id`
- **`categoriesDelete(id: string): Promise<void>`**
- `DELETE /categories/:id`
Suggested UI helpers:
- **`getCategoryDisplayName(cat: Category, locale: 'ru' | 'en'): string`**
- **`getCategoryBadgeStyle(cat: Category): { color: string; icon: string }`**
## Transactions
Core CRUD:
- **`transactionsList(params?: { page?: number; limit?: number; type?: 'INCOME' | 'EXPENSE'; startDate?: string; endDate?: string; categoryId?: string; search?: string; minAmount?: number; maxAmount?: number; paymentMethod?: 'CASH' | 'CARD' | 'BANK_TRANSFER' }): Promise<{ data: Transaction[]; meta: PaginationMeta }>`**
- `GET /transactions?...`
- **`transactionsGet(id: string): Promise<Transaction>`**
- `GET /transactions/:id`
- **`transactionsCreate(payload: { amount: number; currency?: string; type: 'INCOME' | 'EXPENSE'; categoryId?: string; description?: string; transactionDate: string; paymentMethod?: 'CASH' | 'CARD' | 'BANK_TRANSFER'; receiptUrl?: string; isRecurring?: boolean; recurringPattern?: any; isPlanned?: boolean }): Promise<Transaction>`**
- `POST /transactions`
- **`transactionsUpdate(id: string, payload: Partial<{ amount: number; categoryId?: string; description?: string; transactionDate: string; paymentMethod?: 'CASH' | 'CARD' | 'BANK_TRANSFER'; receiptUrl?: string; isRecurring?: boolean; recurringPattern?: any; isPlanned?: boolean }>): Promise<Transaction>`**
- `PUT /transactions/:id`
- **`transactionsDelete(id: string): Promise<void>`**
- `DELETE /transactions/:id`
Bulk + reports:
- **`transactionsBulkImport(payload: { items: Array<CreateTransactionPayload> }): Promise<{ created: number; skipped: number; errors: any[] }>`**
- `POST /transactions/bulk-import`
- **`transactionsSummary(params: { startDate: string; endDate: string }): Promise<{ totalIncome: number; totalExpense: number; net: number }>`**
- `GET /transactions/summary?startDate=...&endDate=...`
- **`transactionsSpendingByCategory(params: { startDate: string; endDate: string }): Promise<Array<{ categoryId: string; nameRu: string; amount: number; percentage: number }>>`**
- `GET /transactions/spending-by-category?startDate=...&endDate=...`
Suggested UI helpers:
- **`formatMoneyRu(amount: number, currency: string = 'RUB'): string`**
- **`groupTransactionsByDay(transactions: Transaction[]): Record<string, Transaction[]>`**
## Budgets (50/30/20)
- **`budgetsList(): Promise<Budget[]>`**
- `GET /budgets`
- **`budgetsGetCurrent(): Promise<Budget | null>`**
- `GET /budgets/current`
- **`budgetsGetByMonth(month: string /* YYYY-MM-01 */): Promise<Budget>`**
- `GET /budgets/month?month=...`
- **`budgetsCreate(payload: { month: string; totalIncome: number; essentialsPercent?: number; personalPercent?: number; savingsPercent?: number }): Promise<Budget>`**
- `POST /budgets`
- **`budgetsUpdate(month: string, payload: Partial<{ totalIncome: number; essentialsPercent?: number; personalPercent?: number; savingsPercent?: number }>): Promise<Budget>`**
- `PUT /budgets?month=...`
- **`budgetsDelete(month: string): Promise<void>`**
- `DELETE /budgets?month=...`
- **`budgetsProgress(month: string): Promise<{ essentials: Progress; personal: Progress; savings: Progress; total: TotalProgress }>`**
- `GET /budgets/progress?month=...`
Suggested UI helpers:
- **`budgetProgressColor(pct: number): 'green' | 'yellow' | 'red'`**
## Goals
- **`goalsList(params?: { status?: 'ACTIVE' | 'COMPLETED' | 'PAUSED' | 'CANCELLED' }): Promise<Goal[]>`**
- `GET /goals?status=...`
- **`goalsGet(id: string): Promise<Goal>`**
- `GET /goals/:id`
- **`goalsCreate(payload: { titleRu: string; descriptionRu?: string; targetAmount: number; currentAmount?: number; targetDate?: string; priority?: 'LOW' | 'MEDIUM' | 'HIGH'; icon?: string; color?: string; autoSaveEnabled?: boolean; autoSaveAmount?: number; autoSaveFrequency?: 'DAILY' | 'WEEKLY' | 'MONTHLY' }): Promise<Goal>`**
- `POST /goals`
- **`goalsUpdate(id: string, payload: Partial<...same as create...>): Promise<Goal>`**
- `PUT /goals/:id`
- **`goalsDelete(id: string): Promise<void>`**
- `DELETE /goals/:id`
- **`goalsAddFunds(id: string, payload: { amount: number; note?: string }): Promise<Goal>`**
- `POST /goals/:id/add-funds`
- **`goalsWithdraw(id: string, payload: { amount: number; note?: string }): Promise<Goal>`**
- `POST /goals/:id/withdraw`
- **`goalsSummary(): Promise<{ totalGoals: number; activeGoals: number; completedGoals: number; totalTargetAmount: number; totalCurrentAmount: number; overallProgress: number }>`**
- `GET /goals/summary`
- **`goalsUpcomingDeadlines(days?: number): Promise<Goal[]>`**
- `GET /goals/upcoming?days=...`
Suggested UI helpers:
- **`goalProgress(goal: Goal): { percent: number; remaining: number }`**
- **`goalStatusLabelRu(status: string): string`**
## Recommendations
- **`recommendationsList(params?: { type?: 'SAVING' | 'SPENDING' | 'INVESTMENT' | 'TAX' | 'DEBT' | 'BUDGET' | 'GOAL' }): Promise<Recommendation[]>`**
- `GET /recommendations?type=...`
- **`recommendationsGet(id: string): Promise<Recommendation>`**
- `GET /recommendations/:id`
- **`recommendationsGenerate(): Promise<Recommendation[]>`**
- `POST /recommendations/generate`
- **`recommendationsMarkViewed(id: string): Promise<Recommendation>`**
- `POST /recommendations/:id/view`
- **`recommendationsApply(id: string): Promise<Recommendation>`**
- `POST /recommendations/:id/apply`
- **`recommendationsDismiss(id: string): Promise<Recommendation>`**
- `POST /recommendations/:id/dismiss`
- **`recommendationsStats(): Promise<{ total: number; new: number; viewed: number; applied: number; dismissed: number; potentialSavings: number }>`**
- `GET /recommendations/stats`
Suggested UI helpers:
- **`recommendationBadge(type: string): { labelRu: string; color: string }`**
## Analytics
- **`analyticsMonthlyOverview(month?: string /* YYYY-MM */): Promise<MonthlyOverview>`**
- `GET /analytics/overview?month=...`
- **`analyticsTrends(months?: number): Promise<SpendingTrend[]>`**
- `GET /analytics/trends?months=...`
- **`analyticsCategoryBreakdown(params: { startDate: string; endDate: string; type?: 'INCOME' | 'EXPENSE' }): Promise<CategoryBreakdown[]>`**
- `GET /analytics/categories?startDate=...&endDate=...&type=...`
- **`analyticsIncomeVsExpenses(months?: number): Promise<Array<{ period: string; income: number; expenses: number; savings: number }>>`**
- `GET /analytics/income-vs-expenses?months=...`
- **`analyticsFinancialHealth(): Promise<FinancialHealth>`**
- `GET /analytics/health`
- **`analyticsYearly(year?: number): Promise<YearlySummary>`**
- `GET /analytics/yearly?year=...`
Suggested UI helpers:
- **`financialHealthBadge(grade: 'A'|'B'|'C'|'D'|'F'): { labelRu: string; color: string }`**
## Suggested Frontend Architecture
- `src/api/`:
- `client.ts` (`apiFetch`, interceptors, base URL)
- `auth.ts`, `categories.ts`, `transactions.ts`, `budgets.ts`, `goals.ts`, `recommendations.ts`, `analytics.ts`
- `src/types/`:
- shared types mirrored from backend DTOs/entities
- `src/hooks/`:
- `useAuth`, `useTransactions`, `useBudgets`, `useGoals`, `useRecommendations`, `useAnalytics`
## Minimal Types (Suggested)
- `User`, `Category`, `Transaction`, `Budget`, `Goal`, `Recommendation`
- `PaginationMeta`
(Define based on Swagger at `/api/docs` once backend is running.)

73
README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is currently not compatible with SWC. See [this issue](https://github.com/vitejs/vite-plugin-react/issues/428) for tracking the progress.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

24
docker-compose.server.yml Normal file
View File

@ -0,0 +1,24 @@
version: "3.8"
services:
frontend:
build:
context: .
dockerfile: Dockerfile
args:
VITE_API_HOST: https://api-finance.ai-assistant-bot.xyz
container_name: finance_front
networks:
- proxy
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.finance.rule=Host(`finance.ai-assistant-bot.xyz`)
- traefik.http.routers.finance.entrypoints=web,websecure
- traefik.http.routers.finance.tls.certresolver=le
- traefik.http.services.finance.loadbalancer.server.port=80
networks:
proxy:
external: true

23
eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>finance-frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

29
nginx.conf Normal file
View File

@ -0,0 +1,29 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Handle SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

4326
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "finance-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@reduxjs/toolkit": "^2.11.2",
"@tailwindcss/vite": "^4.1.18",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.69.0",
"react-redux": "^9.2.0",
"react-router": "^7.11.0",
"redux": "^5.0.1",
"tailwindcss": "^4.1.18",
"zod": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vite-plugin-mkcert": "^1.17.9"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

31
src/api/analytics.ts Normal file
View File

@ -0,0 +1,31 @@
import {
type AnalyticsOverviewApi,
type AnalyticsTrendsApi,
type AnalyticsCategoryBreakdownApi,
type AnalyticsIncomeVsExpensesApi,
type AnalyticsFinancialHealthApi,
type AnalyticsYearlySummaryApi,
} from "../interfaces/analytics";
import { requestInstance } from "../request-instance";
export const analyticsOverviewApi: AnalyticsOverviewApi = async (params) =>
(await requestInstance.get("analytics/overview", { params })).data;
export const analyticsTrendsApi: AnalyticsTrendsApi = async (params) =>
(await requestInstance.get("analytics/trends", { params })).data;
export const analyticsCategoryBreakdownApi: AnalyticsCategoryBreakdownApi =
async (params) =>
(await requestInstance.get("analytics/categories", { params })).data;
export const analyticsIncomeVsExpensesApi: AnalyticsIncomeVsExpensesApi =
async (params) =>
(await requestInstance.get("analytics/income-vs-expenses", { params }))
.data;
export const analyticsFinancialHealthApi: AnalyticsFinancialHealthApi =
async () => (await requestInstance.get("analytics/health")).data;
export const analyticsYearlySummaryApi: AnalyticsYearlySummaryApi = async (
params,
) => (await requestInstance.get("analytics/yearly", { params })).data;

18
src/api/auth.ts Normal file
View File

@ -0,0 +1,18 @@
import {
type AuthLoginApi,
type AuthRegisterApi,
type ReApi,
} from "../interfaces/auth";
import { requestInstance } from "../request-instance";
export const authLoginApi: AuthLoginApi = async (body) =>
(await requestInstance.post("auth/login", body)).data;
export const authRegisterApi: AuthRegisterApi = async (body) =>
(await requestInstance.post("auth/register", body)).data;
// export const authLogoutApi: LogoutApi = async () =>
// (await requestInstance.post("auth/logout")).data;
export const authReApi: ReApi = async () =>
(await requestInstance.post("auth/refresh")).data;

31
src/api/budgets.ts Normal file
View File

@ -0,0 +1,31 @@
import {
type BudgetsListApi,
type BudgetsGetCurrentApi,
type BudgetsGetByMonthApi,
type BudgetsCreateApi,
type BudgetsUpdateApi,
type BudgetsDeleteApi,
type BudgetsProgressApi,
} from "../interfaces/budgets";
import { requestInstance } from "../request-instance";
export const budgetsListApi: BudgetsListApi = async () =>
(await requestInstance.get("budgets")).data;
export const budgetsGetCurrentApi: BudgetsGetCurrentApi = async () =>
(await requestInstance.get("budgets/current")).data;
export const budgetsGetByMonthApi: BudgetsGetByMonthApi = async (month) =>
(await requestInstance.get("budgets/month", { params: { month } })).data;
export const budgetsCreateApi: BudgetsCreateApi = async (body) =>
(await requestInstance.post("budgets", body)).data;
export const budgetsUpdateApi: BudgetsUpdateApi = async ({ month, body }) =>
(await requestInstance.put("budgets", body, { params: { month } })).data;
export const budgetsDeleteApi: BudgetsDeleteApi = async (month) =>
(await requestInstance.delete("budgets", { params: { month } })).data;
export const budgetsProgressApi: BudgetsProgressApi = async (month) =>
(await requestInstance.get("budgets/progress", { params: { month } })).data;

27
src/api/categories.ts Normal file
View File

@ -0,0 +1,27 @@
import {
type CategoriesListApi,
type CategoriesGetApi,
type CategoriesGetGroupedApi,
type CategoriesCreateApi,
type CategoriesUpdateApi,
type CategoriesDeleteApi,
} from "../interfaces/categories";
import { requestInstance } from "../request-instance";
export const categoriesListApi: CategoriesListApi = async (params) =>
(await requestInstance.get("categories", { params })).data;
export const categoriesGetApi: CategoriesGetApi = async (id) =>
(await requestInstance.get(`categories/${id}`)).data;
export const categoriesGetGroupedApi: CategoriesGetGroupedApi = async () =>
(await requestInstance.get("categories/grouped")).data;
export const categoriesCreateApi: CategoriesCreateApi = async (body) =>
(await requestInstance.post("categories", body)).data;
export const categoriesUpdateApi: CategoriesUpdateApi = async ({ id, body }) =>
(await requestInstance.put(`categories/${id}`, body)).data;
export const categoriesDeleteApi: CategoriesDeleteApi = async (id) =>
(await requestInstance.delete(`categories/${id}`)).data;

43
src/api/goals.ts Normal file
View File

@ -0,0 +1,43 @@
import {
type GoalsListApi,
type GoalsGetApi,
type GoalsCreateApi,
type GoalsUpdateApi,
type GoalsDeleteApi,
type GoalsAddFundsApi,
type GoalsWithdrawApi,
type GoalsSummaryApi,
type GoalsUpcomingApi,
} from "../interfaces/goals";
import { requestInstance } from "../request-instance";
export const goalsListApi: GoalsListApi = async (params) =>
(await requestInstance.get("goals", { params })).data;
export const goalsGetApi: GoalsGetApi = async (id) =>
(await requestInstance.get(`goals/${id}`)).data;
export const goalsCreateApi: GoalsCreateApi = async (body) =>
(await requestInstance.post("goals", body)).data;
export const goalsUpdateApi: GoalsUpdateApi = async ({ id, body }) =>
(await requestInstance.put(`goals/${id}`, body)).data;
export const goalsDeleteApi: GoalsDeleteApi = async (id) =>
(await requestInstance.delete(`goals/${id}`)).data;
export const goalsAddFundsApi: GoalsAddFundsApi = async ({ id, body }) =>
(await requestInstance.post(`goals/${id}/add-funds`, body)).data;
export const goalsWithdrawApi: GoalsWithdrawApi = async ({ id, body }) =>
(await requestInstance.post(`goals/${id}/withdraw`, body)).data;
export const goalsSummaryApi: GoalsSummaryApi = async () =>
(await requestInstance.get("goals/summary")).data;
export const goalsUpcomingApi: GoalsUpcomingApi = async (days) =>
(
await requestInstance.get("goals/upcoming", {
params: days ? { days } : undefined,
})
).data;

View File

@ -0,0 +1,39 @@
export type ParsedApiError = {
statusCode?: number;
messages: string[];
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
export const parseApiError = (error: unknown): ParsedApiError => {
const defaultResult: ParsedApiError = {
messages: ["Произошла ошибка. Попробуйте ещё раз."],
};
if (!isRecord(error)) return defaultResult;
const response = error.response;
if (!isRecord(response)) return defaultResult;
const data = response.data;
if (!isRecord(data)) return defaultResult;
const statusCode =
typeof data.statusCode === "number" ? data.statusCode : undefined;
const message = data.message;
if (typeof message === "string") {
return { statusCode, messages: [message] };
}
if (Array.isArray(message) && message.every((m) => typeof m === "string")) {
return { statusCode, messages: message };
}
if (typeof data.message === "string") {
return { statusCode, messages: [data.message] };
}
return { ...defaultResult, statusCode };
};

32
src/api/profile.ts Normal file
View File

@ -0,0 +1,32 @@
import { requestInstance } from "../request-instance";
import type {
ChangePasswordPayload,
UpdateProfilePayload,
UserProfile,
} from "../interfaces/auth";
export const authGetProfile = async (): Promise<UserProfile> => {
const response = await requestInstance.get("auth/profile");
return response.data;
};
export const authUpdateProfile = async (
payload: UpdateProfilePayload,
): Promise<UserProfile> => {
const response = await requestInstance.put("auth/profile", payload);
return response.data;
};
export const authChangePassword = async (
payload: ChangePasswordPayload,
): Promise<void> => {
await requestInstance.post("auth/change-password", payload);
};
export const authLogout = async (): Promise<void> => {
await requestInstance.post("auth/logout");
};
export const authLogoutAll = async (): Promise<void> => {
await requestInstance.post("auth/logout-all");
};

View File

@ -0,0 +1,32 @@
import {
type RecommendationsListApi,
type RecommendationsGetApi,
type RecommendationsGenerateApi,
type RecommendationsMarkViewedApi,
type RecommendationsApplyApi,
type RecommendationsDismissApi,
type RecommendationsStatsApi,
} from "../interfaces/recommendations";
import { requestInstance } from "../request-instance";
export const recommendationsListApi: RecommendationsListApi = async (params) =>
(await requestInstance.get("recommendations", { params })).data;
export const recommendationsGetApi: RecommendationsGetApi = async (id) =>
(await requestInstance.get(`recommendations/${id}`)).data;
export const recommendationsGenerateApi: RecommendationsGenerateApi =
async () => (await requestInstance.post("recommendations/generate")).data;
export const recommendationsMarkViewedApi: RecommendationsMarkViewedApi =
async (id) => (await requestInstance.post(`recommendations/${id}/view`)).data;
export const recommendationsApplyApi: RecommendationsApplyApi = async (id) =>
(await requestInstance.post(`recommendations/${id}/apply`)).data;
export const recommendationsDismissApi: RecommendationsDismissApi = async (
id,
) => (await requestInstance.post(`recommendations/${id}/dismiss`)).data;
export const recommendationsStatsApi: RecommendationsStatsApi = async () =>
(await requestInstance.get("recommendations/stats")).data;

39
src/api/transactions.ts Normal file
View File

@ -0,0 +1,39 @@
import {
type TransactionsListApi,
type TransactionsGetApi,
type TransactionsCreateApi,
type TransactionsUpdateApi,
type TransactionsDeleteApi,
type TransactionsBulkCreateApi,
type TransactionsSummaryApi,
type TransactionsByCategoryApi,
} from "../interfaces/transactions";
import { requestInstance } from "../request-instance";
export const transactionsListApi: TransactionsListApi = async (filters) =>
(await requestInstance.get("transactions", { params: filters })).data;
export const transactionsGetApi: TransactionsGetApi = async (id) =>
(await requestInstance.get(`transactions/${id}`)).data;
export const transactionsCreateApi: TransactionsCreateApi = async (body) =>
(await requestInstance.post("transactions", body)).data;
export const transactionsUpdateApi: TransactionsUpdateApi = async ({
id,
body,
}) => (await requestInstance.put(`transactions/${id}`, body)).data;
export const transactionsDeleteApi: TransactionsDeleteApi = async (id) =>
(await requestInstance.delete(`transactions/${id}`)).data;
export const transactionsBulkCreateApi: TransactionsBulkCreateApi = async (
transactions,
) => (await requestInstance.post("transactions/bulk", transactions)).data;
export const transactionsSummaryApi: TransactionsSummaryApi = async (params) =>
(await requestInstance.get("transactions/summary", { params })).data;
export const transactionsByCategoryApi: TransactionsByCategoryApi = async (
params,
) => (await requestInstance.get("transactions/by-category", { params })).data;

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

3
src/browser-router.ts Normal file
View File

@ -0,0 +1,3 @@
import { createBrowserRouter } from "react-router";
import routes from "./router";
export const browserRouter = createBrowserRouter(routes);

View File

@ -0,0 +1,30 @@
type FAQItemProps = {
question: string;
answer: string;
};
export const FAQItem = ({ question, answer }: FAQItemProps) => {
return (
<details className="group rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 text-left">
<span className="text-sm font-semibold text-slate-900 sm:text-base">
{question}
</span>
<span
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-slate-200 text-slate-700 transition group-open:rotate-45"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</span>
</summary>
<p className="mt-3 text-sm leading-6 text-slate-600">{answer}</p>
</details>
);
};

View File

@ -0,0 +1,23 @@
import type { ReactNode } from "react";
type FeatureCardProps = {
icon: ReactNode;
title: string;
description: string;
};
export const FeatureCard = ({ icon, title, description }: FeatureCardProps) => {
return (
<div className="group rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-md">
<div className="flex items-start gap-4">
<div className="inline-flex h-11 w-11 flex-none items-center justify-center rounded-xl bg-slate-900 text-white shadow-sm">
{icon}
</div>
<div>
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
<p className="mt-1 text-sm leading-6 text-slate-600">{description}</p>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,38 @@
import type { ReactNode } from "react";
type SectionProps = {
id: string;
title: string;
subtitle?: string;
children: ReactNode;
className?: string;
};
export const Section = ({
id,
title,
subtitle,
children,
className,
}: SectionProps) => {
return (
<section id={id} className={className} aria-labelledby={`${id}-title`}>
<div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
<header className="mb-10">
<h2
id={`${id}-title`}
className="text-balance text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl"
>
{title}
</h2>
{subtitle ? (
<p className="mt-3 max-w-2xl text-pretty text-base leading-7 text-slate-600">
{subtitle}
</p>
) : null}
</header>
{children}
</div>
</section>
);
};

View File

@ -0,0 +1,32 @@
import { useState } from "react";
import { Outlet } from "react-router";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
import { TransactionModal } from "../transactions/TransactionModal";
export const AppShell = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [transactionModalOpen, setTransactionModalOpen] = useState(false);
return (
<div className="flex h-screen bg-slate-50">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex flex-1 flex-col overflow-hidden">
<Topbar
onMenuClick={() => setSidebarOpen(true)}
onAddTransaction={() => setTransactionModalOpen(true)}
/>
<main className="flex-1 overflow-y-auto p-4 lg:p-6">
<Outlet />
</main>
</div>
<TransactionModal
isOpen={transactionModalOpen}
onClose={() => setTransactionModalOpen(false)}
/>
</div>
);
};

View File

@ -0,0 +1,205 @@
import { NavLink } from "react-router";
const navItems = [
{
label: "Обзор",
path: "/dashboard",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
),
},
{
label: "Транзакции",
path: "/dashboard/transactions",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
</svg>
),
},
{
label: "Категории",
path: "/dashboard/categories",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
),
},
{
label: "Бюджет",
path: "/dashboard/budgets",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
),
},
{
label: "Цели",
path: "/dashboard/goals",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
/>
</svg>
),
},
{
label: "Рекомендации",
path: "/dashboard/recommendations",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
),
},
{
label: "Аналитика",
path: "/dashboard/analytics",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
/>
</svg>
),
},
{
label: "Настройки",
path: "/dashboard/settings",
icon: (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
),
},
];
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
}
export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={onClose}
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-slate-200 bg-white transition-transform lg:static lg:translate-x-0 ${
isOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
{/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-slate-200 px-6">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M5 12.5l4 4L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<span className="text-base font-semibold tracking-tight">
Finance App
</span>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto p-4">
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.path}>
<NavLink
to={item.path}
end={item.path === "/dashboard"}
onClick={onClose}
className={({ isActive }) =>
`flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? "bg-slate-900 text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
}`
}
>
{item.icon}
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
{/* Footer */}
<div className="border-t border-slate-200 p-4">
<p className="text-xs text-slate-400">© 2025 Finance App</p>
</div>
</aside>
</>
);
};

View File

@ -0,0 +1,48 @@
interface TopbarProps {
onMenuClick: () => void;
onAddTransaction: () => void;
}
export const Topbar = ({ onMenuClick, onAddTransaction }: TopbarProps) => {
return (
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 lg:px-6">
{/* Left side */}
<div className="flex items-center gap-4">
{/* Mobile menu button */}
<button
onClick={onMenuClick}
className="flex h-10 w-10 items-center justify-center rounded-xl text-slate-600 hover:bg-slate-100 lg:hidden"
aria-label="Открыть меню"
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
{/* Right side */}
<div className="flex items-center gap-3">
{/* Add transaction button */}
<button
onClick={onAddTransaction}
className="inline-flex h-10 items-center gap-2 rounded-xl bg-slate-900 px-4 text-sm font-medium text-white shadow-sm hover:bg-slate-800"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 5v14m-7-7h14"
/>
</svg>
<span className="hidden sm:inline">Добавить транзакцию</span>
</button>
</div>
</header>
);
};

View File

@ -0,0 +1,35 @@
import type { FC, ReactNode } from "react";
import { LoadingStatus } from "../../store/loading/types";
import { Spinner } from "../spinner";
interface PageContentProps {
children?: ReactNode;
error?: ReactNode;
loader?: ReactNode;
loadingStatus?: LoadingStatus;
emptyInitial?: boolean;
renderOnFail?: boolean;
}
export const LoadingContentComponent: FC<PageContentProps> = ({
loadingStatus,
children,
loader = <Spinner />,
error = "Ошибка загрузки данных",
emptyInitial = false,
renderOnFail = false,
}) => {
if (emptyInitial && loadingStatus === LoadingStatus.Initial) {
return null;
}
if (loadingStatus === LoadingStatus.Fulfilled || renderOnFail) {
return <>{children}</>;
}
if (loadingStatus === LoadingStatus.Rejected) {
return <div className="flex flex-1 justify-center pt-8">{error}</div>;
}
return <div className="flex flex-1 justify-center pt-8">{loader}</div>;
};

View File

@ -0,0 +1,21 @@
export const Spinner = () => (
<div role="status">
<svg
aria-hidden="true"
className="w-8 h-8 text-neutral-tertiary animate-spin fill-brand"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
);

View File

@ -0,0 +1,224 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import { categoriesSelector } from "../../store/categories/selectors";
import { categoriesList } from "../../store/categories/async-thunks";
import {
transactionsCreate,
transactionsUpdate,
} from "../../store/transactions/async-thunks";
import { parseApiError } from "../../api/parse-api-error";
import type { Transaction } from "../../interfaces/transactions";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
import { Select } from "../ui/Select";
const schema = z.object({
amount: z.number().positive("Сумма должна быть положительной"),
type: z.enum(["INCOME", "EXPENSE"] as const),
categoryId: z.string().min(1, "Выберите категорию"),
description: z.string().optional(),
transactionDate: z.string().min(1, "Выберите дату"),
paymentMethod: z.enum(["CASH", "CARD", "BANK_TRANSFER"] as const).optional(),
});
type FormValues = z.infer<typeof schema>;
interface TransactionModalProps {
isOpen: boolean;
onClose: () => void;
transaction?: Transaction;
onSuccess?: () => void;
}
export const TransactionModal = ({
isOpen,
onClose,
transaction,
onSuccess,
}: TransactionModalProps) => {
const dispatch = useAppDispatch();
const categories = useAppSelector(categoriesSelector);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEditing = !!transaction;
const {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
amount: transaction?.amount || 0,
type: transaction?.type || "EXPENSE",
categoryId: transaction?.categoryId ?? "",
description: transaction?.description || "",
transactionDate:
transaction?.transactionDate?.split("T")[0] ||
new Date().toISOString().split("T")[0],
paymentMethod: transaction?.paymentMethod || undefined,
},
});
const selectedType = watch("type");
useEffect(() => {
if (isOpen) {
dispatch(categoriesList());
if (transaction) {
reset({
amount: transaction.amount,
type: transaction.type,
categoryId: transaction.categoryId,
description: transaction.description || "",
transactionDate: transaction.transactionDate.split("T")[0],
paymentMethod: transaction.paymentMethod || undefined,
});
} else {
reset({
amount: 0,
type: "EXPENSE",
categoryId: "",
description: "",
transactionDate: new Date().toISOString().split("T")[0],
paymentMethod: undefined,
});
}
}
}, [dispatch, isOpen, transaction, reset]);
const onSubmit = async (values: FormValues) => {
setIsLoading(true);
setError(null);
try {
if (isEditing && transaction) {
await dispatch(
transactionsUpdate({
id: transaction.id,
body: {
...values,
transactionDate: new Date(values.transactionDate).toISOString(),
},
}),
).unwrap();
} else {
await dispatch(
transactionsCreate({
...values,
transactionDate: new Date(values.transactionDate).toISOString(),
}),
).unwrap();
}
onSuccess?.();
onClose();
} catch (err) {
const parsed = parseApiError(err);
setError(parsed.messages[0] || "Произошла ошибка");
} finally {
setIsLoading(false);
}
};
const filteredCategories = categories.filter(
(cat) => cat.type === selectedType,
);
const typeOptions = [
{ value: "EXPENSE", label: "Расход" },
{ value: "INCOME", label: "Доход" },
];
const paymentMethodOptions = [
{ value: "", label: "Не указан" },
{ value: "CASH", label: "Наличные" },
{ value: "CARD", label: "Карта" },
{ value: "BANK_TRANSFER", label: "Банковский перевод" },
];
const categoryOptions = [
{ value: "", label: "Без категории" },
...filteredCategories.map((cat) => ({
value: cat.id,
label: cat.nameRu,
})),
];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? "Редактировать транзакцию" : "Новая транзакция"}
size="md"
>
{error && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Input
label="Сумма"
type="number"
step="0.01"
error={errors.amount?.message}
{...register("amount", { valueAsNumber: true })}
/>
<Select
label="Тип"
options={typeOptions}
error={errors.type?.message}
{...register("type")}
/>
</div>
<Select
label="Категория"
options={categoryOptions}
error={errors.categoryId?.message}
{...register("categoryId")}
/>
<Input
label="Описание"
error={errors.description?.message}
{...register("description")}
/>
<div className="grid gap-4 sm:grid-cols-2">
<Input
label="Дата"
type="date"
error={errors.transactionDate?.message}
{...register("transactionDate")}
/>
<Select
label="Способ оплаты"
options={paymentMethodOptions}
error={errors.paymentMethod?.message}
{...register("paymentMethod")}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="secondary" onClick={onClose}>
Отмена
</Button>
<Button type="submit" isLoading={isLoading}>
{isEditing ? "Сохранить" : "Создать"}
</Button>
</div>
</form>
</Modal>
);
};

View File

@ -0,0 +1,34 @@
import type { ReactNode } from "react";
interface BadgeProps {
children: ReactNode;
variant?: "default" | "success" | "warning" | "danger" | "info";
size?: "sm" | "md";
}
const variantClasses = {
default: "bg-slate-100 text-slate-700",
success: "bg-emerald-50 text-emerald-700",
warning: "bg-amber-50 text-amber-700",
danger: "bg-rose-50 text-rose-700",
info: "bg-sky-50 text-sky-700",
};
const sizeClasses = {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-xs",
};
export const Badge = ({
children,
variant = "default",
size = "md",
}: BadgeProps) => {
return (
<span
className={`inline-flex items-center rounded-full font-medium ${variantClasses[variant]} ${sizeClasses[size]}`}
>
{children}
</span>
);
};

View File

@ -0,0 +1,65 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
}
const variantClasses = {
primary: "bg-slate-900 text-white hover:bg-slate-800 shadow-sm",
secondary:
"bg-white text-slate-900 border border-slate-200 hover:bg-slate-50 shadow-sm",
danger: "bg-rose-600 text-white hover:bg-rose-700 shadow-sm",
ghost: "text-slate-600 hover:bg-slate-100 hover:text-slate-900",
};
const sizeClasses = {
sm: "h-8 px-3 text-xs rounded-lg",
md: "h-10 px-4 text-sm rounded-xl",
lg: "h-12 px-6 text-sm rounded-xl",
};
export const Button = ({
variant = "primary",
size = "md",
isLoading = false,
leftIcon,
rightIcon,
children,
className = "",
disabled,
...props
}: ButtonProps) => {
return (
<button
className={`inline-flex items-center justify-center gap-2 font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/40 disabled:cursor-not-allowed disabled:opacity-50 ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
leftIcon
)}
{children}
{!isLoading && rightIcon}
</button>
);
};

View File

@ -0,0 +1,55 @@
import { Modal } from "./Modal";
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "danger" | "warning" | "default";
isLoading?: boolean;
}
export const ConfirmDialog = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Подтвердить",
cancelText = "Отмена",
variant = "default",
isLoading = false,
}: ConfirmDialogProps) => {
const variantClasses = {
danger: "bg-rose-600 hover:bg-rose-700 text-white",
warning: "bg-amber-500 hover:bg-amber-600 text-white",
default: "bg-slate-900 hover:bg-slate-800 text-white",
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<p className="text-sm text-slate-600">{message}</p>
<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="h-10 rounded-xl border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
disabled={isLoading}
className={`h-10 rounded-xl px-4 text-sm font-medium disabled:opacity-50 ${variantClasses[variant]}`}
>
{isLoading ? "Загрузка..." : confirmText}
</button>
</div>
</Modal>
);
};

View File

@ -0,0 +1,30 @@
import type { ReactNode } from "react";
interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
}
export const EmptyState = ({
icon,
title,
description,
action,
}: EmptyStateProps) => {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
{icon && (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
{icon}
</div>
)}
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
{description && (
<p className="mt-2 max-w-sm text-sm text-slate-500">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
};

View File

@ -0,0 +1,48 @@
import { forwardRef, type InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
hint?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, hint, className = "", id, ...props }, ref) => {
const inputId = id || props.name;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="mb-2 block text-sm font-medium text-slate-700"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`h-12 w-full rounded-xl border bg-white px-4 text-sm shadow-sm outline-none transition-colors focus:ring-2 focus:ring-slate-900/10 ${
error
? "border-rose-300 focus:border-rose-400"
: "border-slate-200 focus:border-slate-400"
} ${className}`}
aria-invalid={!!error}
aria-describedby={error ? `${inputId}-error` : undefined}
{...props}
/>
{error && (
<p id={`${inputId}-error`} className="mt-2 text-sm text-rose-600">
{error}
</p>
)}
{hint && !error && (
<p className="mt-2 text-sm text-slate-500">{hint}</p>
)}
</div>
);
},
);
Input.displayName = "Input";

View File

@ -0,0 +1,84 @@
import { useEffect, useRef, type ReactNode } from "react";
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: "sm" | "md" | "lg" | "xl";
}
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
};
export const Modal = ({
isOpen,
onClose,
title,
children,
size = "md",
}: ModalProps) => {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleOverlayClick = (e: React.MouseEvent) => {
if (e.target === overlayRef.current) onClose();
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<div
className={`w-full ${sizeClasses[size]} rounded-2xl bg-white shadow-xl`}
>
<div className="flex items-center justify-between border-b border-slate-200 px-6 py-4">
<h2 id="modal-title" className="text-lg font-semibold text-slate-900">
{title}
</h2>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600"
aria-label="Закрыть"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="px-6 py-4">{children}</div>
</div>
</div>
);
};

View File

@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
}
export const PageHeader = ({
title,
description,
actions,
}: PageHeaderProps) => {
return (
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-slate-600">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
);
};

View File

@ -0,0 +1,57 @@
interface ProgressBarProps {
value: number;
max?: number;
label?: string;
showValue?: boolean;
size?: "sm" | "md" | "lg";
color?: "default" | "success" | "warning" | "danger";
}
const colorClasses = {
default: "bg-slate-900",
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-rose-500",
};
const sizeClasses = {
sm: "h-1.5",
md: "h-2",
lg: "h-3",
};
export const ProgressBar = ({
value,
max = 100,
label,
showValue = false,
size = "md",
color = "default",
}: ProgressBarProps) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
return (
<div className="w-full">
{(label || showValue) && (
<div className="mb-2 flex items-center justify-between text-sm">
{label && <span className="font-medium text-slate-700">{label}</span>}
{showValue && (
<span className="text-slate-500">{Math.round(percentage)}%</span>
)}
</div>
)}
<div
className={`w-full overflow-hidden rounded-full bg-slate-100 ${sizeClasses[size]}`}
>
<div
className={`${sizeClasses[size]} rounded-full transition-all duration-300 ${colorClasses[color]}`}
style={{ width: `${percentage}%` }}
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={max}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,60 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
interface SelectOption {
value: string;
label: string;
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
options: SelectOption[];
placeholder?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
(
{ label, error, options, placeholder, className = "", id, ...props },
ref,
) => {
const selectId = id || props.name;
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="mb-2 block text-sm font-medium text-slate-700"
>
{label}
</label>
)}
<select
ref={ref}
id={selectId}
className={`h-12 w-full rounded-xl border bg-white px-4 text-sm shadow-sm outline-none transition-colors focus:ring-2 focus:ring-slate-900/10 ${
error
? "border-rose-300 focus:border-rose-400"
: "border-slate-200 focus:border-slate-400"
} ${className}`}
aria-invalid={!!error}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && <p className="mt-2 text-sm text-rose-600">{error}</p>}
</div>
);
},
);
Select.displayName = "Select";

View File

@ -0,0 +1,50 @@
interface SkeletonProps {
className?: string;
variant?: "text" | "circular" | "rectangular";
}
export const Skeleton = ({
className = "",
variant = "rectangular",
}: SkeletonProps) => {
const baseClasses = "animate-pulse bg-slate-200";
const variantClasses = {
text: "h-4 rounded",
circular: "rounded-full",
rectangular: "rounded-xl",
};
return (
<div className={`${baseClasses} ${variantClasses[variant]} ${className}`} />
);
};
export const CardSkeleton = () => (
<div className="rounded-2xl border border-slate-200 bg-white p-5">
<Skeleton className="mb-2 h-4 w-24" variant="text" />
<Skeleton className="h-8 w-32" variant="text" />
<Skeleton className="mt-2 h-3 w-20" variant="text" />
</div>
);
export const TableRowSkeleton = ({ columns = 4 }: { columns?: number }) => (
<tr>
{Array.from({ length: columns }).map((_, i) => (
<td key={i} className="px-4 py-3">
<Skeleton className="h-4 w-full" variant="text" />
</td>
))}
</tr>
);
export const ListItemSkeleton = () => (
<div className="flex items-center gap-4 rounded-xl border border-slate-200 bg-white p-4">
<Skeleton className="h-10 w-10" variant="circular" />
<div className="flex-1">
<Skeleton className="mb-2 h-4 w-32" variant="text" />
<Skeleton className="h-3 w-48" variant="text" />
</div>
<Skeleton className="h-6 w-16" variant="text" />
</div>
);

View File

@ -0,0 +1,53 @@
import type { ReactNode } from "react";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon?: ReactNode;
trend?: {
value: number;
isPositive: boolean;
};
className?: string;
}
export const StatCard = ({
title,
value,
subtitle,
icon,
trend,
className = "",
}: StatCardProps) => {
return (
<div
className={`rounded-2xl border border-slate-200 bg-white p-5 shadow-sm ${className}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-slate-500">{title}</p>
<p className="mt-2 text-2xl font-semibold text-slate-900">{value}</p>
{subtitle && (
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
)}
{trend && (
<p
className={`mt-2 text-sm font-medium ${
trend.isPositive ? "text-emerald-600" : "text-rose-600"
}`}
>
{trend.isPositive ? "+" : ""}
{trend.value}%
</p>
)}
</div>
{icon && (
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-slate-100 text-slate-600">
{icon}
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,16 @@
export { Badge } from "./Badge";
export { Button } from "./Button";
export { ConfirmDialog } from "./ConfirmDialog";
export { EmptyState } from "./EmptyState";
export { Input } from "./Input";
export { Modal } from "./Modal";
export { PageHeader } from "./PageHeader";
export { ProgressBar } from "./ProgressBar";
export { Select } from "./Select";
export {
CardSkeleton,
ListItemSkeleton,
Skeleton,
TableRowSkeleton,
} from "./Skeleton";
export { StatCard } from "./StatCard";

3
src/constants.ts Normal file
View File

@ -0,0 +1,3 @@
export const API_HOST = import.meta.env.VITE_API_HOST;
export const appRootId = "app" as const;

View File

@ -0,0 +1,12 @@
import type { AnalyticsInitialState } from "../interfaces/analytics";
export const analytics = "analytics" as const;
export const analyticsInitialState: AnalyticsInitialState = {
overview: null,
trends: [],
categoryBreakdown: [],
financialHealth: null,
isLoading: false,
error: undefined,
};

13
src/constants/auth.ts Normal file
View File

@ -0,0 +1,13 @@
import {
AuthStage,
AuthStatus,
type AuthInitialState,
} from "../interfaces/auth";
export const auth = "auth" as const;
export const authInitialState: AuthInitialState = {
authStatus: AuthStatus.None,
stage: AuthStage.Login,
error: undefined,
};

11
src/constants/budgets.ts Normal file
View File

@ -0,0 +1,11 @@
import type { BudgetsInitialState } from "../interfaces/budgets";
export const budgets = "budgets" as const;
export const budgetsInitialState: BudgetsInitialState = {
budgets: [],
currentBudget: null,
progress: null,
isLoading: false,
error: undefined,
};

View File

@ -0,0 +1,10 @@
import type { CategoriesInitialState } from "../interfaces/categories";
export const categories = "categories" as const;
export const categoriesInitialState: CategoriesInitialState = {
categories: [],
grouped: null,
isLoading: false,
error: undefined,
};

11
src/constants/goals.ts Normal file
View File

@ -0,0 +1,11 @@
import type { GoalsInitialState } from "../interfaces/goals";
export const goals = "goals" as const;
export const goalsInitialState: GoalsInitialState = {
goals: [],
summary: null,
upcoming: [],
isLoading: false,
error: undefined,
};

View File

@ -0,0 +1,10 @@
import type { RecommendationsInitialState } from "../interfaces/recommendations";
export const recommendations = "recommendations" as const;
export const recommendationsInitialState: RecommendationsInitialState = {
recommendations: [],
stats: null,
isLoading: false,
error: undefined,
};

View File

@ -0,0 +1,12 @@
import type { TransactionsInitialState } from "../interfaces/transactions";
export const transactions = "transactions" as const;
export const transactionsInitialState: TransactionsInitialState = {
transactions: [],
meta: null,
summary: null,
spendingByCategory: [],
isLoading: false,
error: undefined,
};

1
src/index.css Normal file
View File

@ -0,0 +1 @@
@import "tailwindcss";

View File

@ -0,0 +1,98 @@
import type { TransactionType } from "./transactions";
export interface TopCategory {
categoryId: string;
categoryName: string;
amount: number;
percentage: number;
}
export interface AnalyticsOverview {
month: string;
totalIncome: number;
totalExpenses: number;
netSavings: number;
savingsRate: number;
topCategories: TopCategory[];
}
export interface TrendDataPoint {
period: string;
amount: number;
change: number;
changePercent: number;
}
export interface CategoryBreakdown {
categoryId: string;
categoryName: string;
categoryIcon: string;
categoryColor: string;
amount: number;
percentage: number;
transactionCount: number;
averageTransaction: number;
}
export interface HealthFactor {
name: string;
score: number;
description: string;
recommendation?: string;
}
export interface FinancialHealth {
score: number;
grade: "A" | "B" | "C" | "D" | "F";
factors: HealthFactor[];
}
export interface BaseResponse {
success: boolean;
timestamp: string;
}
export interface AnalyticsOverviewResponse extends BaseResponse {
data: AnalyticsOverview;
}
export interface AnalyticsTrendsResponse extends BaseResponse {
data: TrendDataPoint[];
}
export interface AnalyticsCategoryBreakdownResponse extends BaseResponse {
data: CategoryBreakdown[];
}
export interface AnalyticsFinancialHealthResponse extends BaseResponse {
data: FinancialHealth;
}
export interface AnalyticsInitialState {
overview: AnalyticsOverview | null;
trends: TrendDataPoint[];
categoryBreakdown: CategoryBreakdown[];
financialHealth: FinancialHealth | null;
isLoading: boolean;
error: string | undefined;
}
export type AnalyticsOverviewApi = (params?: {
month?: string;
}) => Promise<AnalyticsOverviewResponse>;
export type AnalyticsTrendsApi = (params?: {
months?: number;
}) => Promise<AnalyticsTrendsResponse>;
export type AnalyticsCategoryBreakdownApi = (params: {
startDate: string;
endDate: string;
type?: TransactionType;
}) => Promise<AnalyticsCategoryBreakdownResponse>;
export type AnalyticsIncomeVsExpensesApi = (params?: {
months?: number;
}) => Promise<AnalyticsTrendsResponse>;
export type AnalyticsFinancialHealthApi =
() => Promise<AnalyticsFinancialHealthResponse>;
export type AnalyticsYearlySummaryApi = (params?: {
year?: number;
}) => Promise<unknown>;

71
src/interfaces/auth.ts Normal file
View File

@ -0,0 +1,71 @@
export enum AuthStatus {
None,
Authorized,
NotAuthorized,
}
export enum AuthStage {
Login,
}
export interface AuthInitialState {
authStatus: AuthStatus;
stage: AuthStage;
error: string | undefined;
}
export interface LoginBody {
email: string;
password: string;
}
export interface RegisterBody {
email: string;
password: string;
firstName?: string;
lastName?: string;
phone?: string;
}
export interface User {
userId: string;
email: string;
firstName: string | null;
lastName: string | null;
tokens: Tokens;
}
export interface Tokens {
accessToken: string;
expiresIn: number;
}
export type AuthLoginApi = (body: LoginBody) => Promise<User>;
export type AuthRegisterApi = (body: RegisterBody) => Promise<User>;
export type ReApi = () => Promise<void>;
export interface UserProfile {
id: string;
email: string;
firstName?: string;
lastName?: string;
phone?: string;
monthlyIncome?: number;
timezone?: string;
language?: string;
createdAt: string;
updatedAt: string;
}
export interface UpdateProfilePayload {
firstName?: string;
lastName?: string;
phone?: string;
monthlyIncome?: number;
timezone?: string;
language?: string;
}
export interface ChangePasswordPayload {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}

78
src/interfaces/budgets.ts Normal file
View File

@ -0,0 +1,78 @@
export interface Budget {
id: string;
month: string;
totalIncome: number;
essentialsLimit: number;
personalLimit: number;
savingsLimit: number;
essentialsSpent?: number;
personalSpent?: number;
savingsSpent?: number;
}
export interface CreateBudgetBody {
month: string;
totalIncome: number;
essentialsPercent?: number;
personalPercent?: number;
savingsPercent?: number;
}
export type UpdateBudgetBody = Partial<Omit<CreateBudgetBody, "month">>;
export interface BudgetProgress {
limit: number;
spent: number;
percent: number;
}
export interface BudgetProgressResponse {
month: string;
essentials: BudgetProgress;
personal: BudgetProgress;
savings: BudgetProgress;
}
export interface BaseResponse {
success: boolean;
timestamp: string;
}
export interface BudgetsListResponse extends BaseResponse {
data: Budget[];
}
export interface BudgetResponse extends BaseResponse {
data: Budget;
}
export interface BudgetProgressApiResponse extends BaseResponse {
data: BudgetProgressResponse;
}
export interface BudgetsInitialState {
budgets: Budget[];
currentBudget: Budget | null;
progress: BudgetProgressResponse | null;
isLoading: boolean;
error: string | undefined;
}
export interface BudgetsUpdateArgs {
month: string;
body: UpdateBudgetBody;
}
export type BudgetsListApi = () => Promise<BudgetsListResponse>;
export type BudgetsGetCurrentApi = () => Promise<BudgetResponse | null>;
export type BudgetsGetByMonthApi = (month: string) => Promise<BudgetResponse>;
export type BudgetsCreateApi = (
body: CreateBudgetBody,
) => Promise<BudgetResponse>;
export type BudgetsUpdateApi = (
args: BudgetsUpdateArgs,
) => Promise<BudgetResponse>;
export type BudgetsDeleteApi = (month: string) => Promise<void>;
export type BudgetsProgressApi = (
month: string,
) => Promise<BudgetProgressApiResponse>;

View File

@ -0,0 +1,77 @@
export type CategoryType = "INCOME" | "EXPENSE";
export type CategoryGroupType = "ESSENTIAL" | "PERSONAL" | "SAVINGS";
export interface Category {
id: string;
nameRu: string;
nameEn?: string;
type: CategoryType;
groupType?: CategoryGroupType;
icon?: string;
color?: string;
parentId?: string;
isDefault: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateCategoryBody {
nameRu: string;
nameEn?: string;
type: CategoryType;
groupType?: CategoryGroupType;
icon?: string;
color?: string;
parentId?: string;
}
export type UpdateCategoryBody = Partial<CreateCategoryBody>;
export interface GroupedCategories {
ESSENTIAL: Category[];
PERSONAL: Category[];
SAVINGS: Category[];
}
export interface CategoriesInitialState {
categories: Category[];
grouped: GroupedCategories | null;
isLoading: boolean;
error: string | undefined;
}
export interface CategoriesUpdateArgs {
id: string;
body: UpdateCategoryBody;
}
export interface BaseResponse {
success: boolean;
timestamp: string;
}
export interface CategoriesListResponse extends BaseResponse {
data: Category[];
}
export interface CategoriesGetGroupedResponse extends BaseResponse {
data: GroupedCategories;
}
export interface CategoryResponse extends BaseResponse {
data: Category;
}
export type CategoriesListApi = (params?: {
type?: CategoryType;
}) => Promise<CategoriesListResponse>;
export type CategoriesGetApi = (id: string) => Promise<CategoryResponse>;
export type CategoriesGetGroupedApi =
() => Promise<CategoriesGetGroupedResponse>;
export type CategoriesCreateApi = (
body: CreateCategoryBody,
) => Promise<CategoryResponse>;
export type CategoriesUpdateApi = (
args: CategoriesUpdateArgs,
) => Promise<CategoryResponse>;
export type CategoriesDeleteApi = (id: string) => Promise<void>;

110
src/interfaces/goals.ts Normal file
View File

@ -0,0 +1,110 @@
export type GoalStatus = "ACTIVE" | "COMPLETED" | "PAUSED" | "CANCELLED";
export type GoalPriority = "LOW" | "MEDIUM" | "HIGH";
export type AutoSaveFrequency = "DAILY" | "WEEKLY" | "MONTHLY";
export interface Goal {
id: string;
titleRu: string;
descriptionRu?: string;
targetAmount: number;
currentAmount: number;
targetDate?: string;
status: GoalStatus;
priority: GoalPriority;
icon?: string;
color?: string;
autoSaveEnabled: boolean;
autoSaveAmount?: number;
autoSaveFrequency?: AutoSaveFrequency;
createdAt: string;
updatedAt: string;
}
export interface CreateGoalBody {
titleRu: string;
descriptionRu?: string;
targetAmount: number;
currentAmount?: number;
targetDate?: string;
priority?: GoalPriority;
icon?: string;
color?: string;
autoSaveEnabled?: boolean;
autoSaveAmount?: number;
autoSaveFrequency?: AutoSaveFrequency;
}
export interface UpdateGoalBody {
titleRu?: string;
descriptionRu?: string;
targetAmount?: number;
targetDate?: string;
status?: GoalStatus;
priority?: GoalPriority;
icon?: string;
color?: string;
autoSaveEnabled?: boolean;
autoSaveAmount?: number;
autoSaveFrequency?: AutoSaveFrequency;
}
export interface GoalFundsBody {
amount: number;
note?: string;
}
export interface GoalsSummary {
totalGoals: number;
activeGoals: number;
completedGoals: number;
totalTargetAmount: number;
totalCurrentAmount: number;
overallProgress: number;
}
export interface BaseResponse {
success: boolean;
timestamp: string;
}
export interface GoalsListResponse extends BaseResponse {
data: Goal[];
}
export interface GoalResponse extends BaseResponse {
data: Goal;
}
export interface GoalsSummaryResponse extends BaseResponse {
data: GoalsSummary;
}
export interface GoalsInitialState {
goals: Goal[];
summary: GoalsSummary | null;
upcoming: Goal[];
isLoading: boolean;
error: string | undefined;
}
export interface GoalsUpdateArgs {
id: string;
body: UpdateGoalBody;
}
export interface GoalsFundsArgs {
id: string;
body: GoalFundsBody;
}
export type GoalsListApi = (params?: {
status?: GoalStatus;
}) => Promise<GoalsListResponse>;
export type GoalsGetApi = (id: string) => Promise<GoalResponse>;
export type GoalsCreateApi = (body: CreateGoalBody) => Promise<GoalResponse>;
export type GoalsUpdateApi = (args: GoalsUpdateArgs) => Promise<GoalResponse>;
export type GoalsDeleteApi = (id: string) => Promise<void>;
export type GoalsAddFundsApi = (args: GoalsFundsArgs) => Promise<GoalResponse>;
export type GoalsWithdrawApi = (args: GoalsFundsArgs) => Promise<GoalResponse>;
export type GoalsSummaryApi = () => Promise<GoalsSummaryResponse>;
export type GoalsUpcomingApi = (days?: number) => Promise<GoalsListResponse>;

View File

@ -0,0 +1,83 @@
export type RecommendationType =
| "SAVING"
| "SPENDING"
| "INVESTMENT"
| "TAX"
| "DEBT"
| "BUDGET"
| "GOAL";
export type RecommendationStatus = "NEW" | "VIEWED" | "APPLIED" | "DISMISSED";
export interface Recommendation {
id: string;
userId: string;
type: RecommendationType;
titleRu: string;
descriptionRu: string;
priorityScore: number;
confidenceScore: number;
source: string;
status: RecommendationStatus;
actionData?: Record<string, unknown>;
expiresAt?: string;
createdAt: string;
viewedAt?: string;
appliedAt?: string;
}
export interface RecommendationStats {
total: number;
new: number;
viewed: number;
applied: number;
dismissed: number;
}
export interface RecommendationsInitialState {
recommendations: Recommendation[];
stats: RecommendationStats | null;
isLoading: boolean;
error: string | undefined;
}
export interface BaseResponse {
success: boolean;
timestamp: string;
}
export interface RecommendationListResponse extends BaseResponse {
data: Recommendation[];
}
export interface RecommendationResponse extends BaseResponse {
data: Recommendation;
}
export interface RecommendationStatsResponse extends BaseResponse {
data: RecommendationStats;
}
export type RecommendationsListApi = (params?: {
type?: RecommendationType;
}) => Promise<RecommendationListResponse>;
export type RecommendationsGetApi = (
id: string,
) => Promise<RecommendationResponse>;
export type RecommendationsGenerateApi =
() => Promise<RecommendationListResponse>;
export type RecommendationsMarkViewedApi = (
id: string,
) => Promise<RecommendationResponse>;
export type RecommendationsApplyApi = (
id: string,
) => Promise<RecommendationResponse>;
export type RecommendationsDismissApi = (
id: string,
) => Promise<RecommendationResponse>;
export type RecommendationsStatsApi =
() => Promise<RecommendationStatsResponse>;

View File

@ -0,0 +1,127 @@
import type { Category } from "./categories";
export type TransactionType = "INCOME" | "EXPENSE";
export type PaymentMethod = "CASH" | "CARD" | "BANK_TRANSFER";
export type TransactionSort =
| "date_asc"
| "date_desc"
| "amount_asc"
| "amount_desc";
export interface Transaction {
id: string;
amount: number;
type: TransactionType;
categoryId: string;
category?: Category;
transactionDate: string;
description?: string;
paymentMethod?: PaymentMethod;
receiptUrl?: string;
isPlanned?: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateTransactionBody {
amount: number;
type: TransactionType;
categoryId: string;
transactionDate: string;
description?: string;
paymentMethod?: PaymentMethod;
receiptUrl?: string;
isPlanned?: boolean;
}
export type UpdateTransactionBody = Partial<CreateTransactionBody>;
export interface TransactionFilters {
page?: number;
limit?: number;
type?: TransactionType;
categoryId?: string;
paymentMethod?: PaymentMethod;
startDate?: string;
endDate?: string;
search?: string;
sort?: TransactionSort;
}
export interface TransactionSummary {
totalIncome: number;
totalExpense: number;
balance: number;
}
export interface TransactionSummaryResponse extends BaseResponse {
data: TransactionSummary;
}
export interface SpendingByCategory {
categoryId: string;
categoryName: string;
total: number;
percentage: number;
}
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface BaseResponse {
success: boolean;
timestamp: string;
}
export interface PaginatedTransactions extends BaseResponse {
data: { data: Transaction[]; meta: PaginationMeta };
}
export interface TransactionResponse extends BaseResponse {
data: Transaction;
}
export interface TransactionsInitialState {
transactions: Transaction[];
meta: PaginationMeta | null;
summary: TransactionSummary | null;
spendingByCategory: SpendingByCategory[];
isLoading: boolean;
error: string | undefined;
}
export interface TransactionsUpdateArgs {
id: string;
body: UpdateTransactionBody;
}
export type TransactionsListApi = (
filters?: TransactionFilters,
) => Promise<PaginatedTransactions>;
export type TransactionsGetApi = (id: string) => Promise<TransactionResponse>;
export type TransactionsCreateApi = (
body: CreateTransactionBody,
) => Promise<TransactionResponse>;
export type TransactionsUpdateApi = (
args: TransactionsUpdateArgs,
) => Promise<TransactionResponse>;
export type TransactionsDeleteApi = (id: string) => Promise<void>;
export type TransactionsBulkCreateApi = (
transactions: CreateTransactionBody[],
) => Promise<Transaction[]>;
export type TransactionsSummaryApi = (params: {
startDate: string;
endDate: string;
}) => Promise<TransactionSummaryResponse>;
export interface SpendingByCategoryResponse extends BaseResponse {
data: SpendingByCategory[];
}
export type TransactionsByCategoryApi = (params: {
startDate: string;
endDate: string;
}) => Promise<SpendingByCategoryResponse>;

16
src/main.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Provider as StoreProvider } from "react-redux";
import { RouterProvider } from "react-router/dom";
import { browserRouter } from "./browser-router";
import { createRoot } from "react-dom/client";
import { asyncStore } from "./store";
import { Suspense } from "react";
import "./index.css";
import { LoadingContentComponent } from "./components/loading-component";
createRoot(document.getElementById("root")!).render(
<StoreProvider store={asyncStore.store}>
<Suspense fallback={<LoadingContentComponent />}>
<RouterProvider router={browserRouter} />
</Suspense>
</StoreProvider>,
);

0
src/pages/dashboard.tsx Normal file
View File

View File

@ -0,0 +1,256 @@
import { useEffect, useState } from "react";
import {
PageHeader,
StatCard,
ProgressBar,
CardSkeleton,
EmptyState,
Select,
} from "../../components/ui";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
analyticsOverviewSelector,
analyticsTrendsSelector,
analyticsCategoryBreakdownSelector,
analyticsFinancialHealthSelector,
analyticsLoadingSelector,
} from "../../store/analytics/selectors";
import {
analyticsOverview,
analyticsTrends,
analyticsCategoryBreakdown,
analyticsFinancialHealth,
} from "../../store/analytics/async-thunks";
const formatMoney = (amount: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
}).format(amount);
};
const gradeColors: Record<string, string> = {
A: "text-emerald-600 bg-emerald-100",
B: "text-sky-600 bg-sky-100",
C: "text-amber-600 bg-amber-100",
D: "text-orange-600 bg-orange-100",
F: "text-rose-600 bg-rose-100",
};
const AnalyticsPage = () => {
const dispatch = useAppDispatch();
const overview = useAppSelector(analyticsOverviewSelector);
const trends = useAppSelector(analyticsTrendsSelector);
const categoryBreakdown = useAppSelector(analyticsCategoryBreakdownSelector);
const health = useAppSelector(analyticsFinancialHealthSelector);
const isLoading = useAppSelector(analyticsLoadingSelector);
const [period, setPeriod] = useState("6");
useEffect(() => {
const now = new Date();
const startDate = new Date(
now.getFullYear(),
now.getMonth() - parseInt(period),
1,
).toISOString();
const endDate = now.toISOString();
dispatch(analyticsOverview());
dispatch(analyticsTrends({ months: parseInt(period) }));
dispatch(
analyticsCategoryBreakdown({ startDate, endDate, type: "EXPENSE" }),
);
dispatch(analyticsFinancialHealth());
}, [dispatch, period]);
const periodOptions = [
{ value: "3", label: "3 месяца" },
{ value: "6", label: "6 месяцев" },
{ value: "12", label: "12 месяцев" },
];
const maxTrendValue = Math.max(...trends.map((t) => t.amount), 1);
if (isLoading) {
return (
<div>
<PageHeader title="Аналитика" description="Анализ финансов и тренды" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
</div>
);
}
return (
<div>
<PageHeader
title="Аналитика"
description="Анализ финансов и тренды"
actions={
<Select
options={periodOptions}
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-36"
/>
}
/>
{/* Overview Stats */}
<div className="mb-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Доходы"
value={formatMoney(overview?.totalIncome || 0)}
/>
<StatCard
title="Расходы"
value={formatMoney(overview?.totalExpenses || 0)}
/>
<StatCard
title="Сбережения"
value={formatMoney(overview?.netSavings || 0)}
/>
<StatCard
title="Норма сбережений"
value={`${(overview?.savingsRate || 0).toFixed(1)}%`}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Trends Chart */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-slate-900">Тренды</h3>
{trends.length > 0 ? (
<div className="space-y-4">
{trends.map((point) => (
<div key={point.period} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-600">{point.period}</span>
<div className="flex gap-4">
<span className="text-slate-900 font-medium">
{formatMoney(point.amount)}
</span>
{point.changePercent !== 0 && (
<span
className={
point.change >= 0
? "text-emerald-600"
: "text-rose-600"
}
>
{point.change >= 0 ? "+" : ""}
{point.changePercent.toFixed(1)}%
</span>
)}
</div>
</div>
<div className="flex gap-1">
<div
className="h-2 rounded bg-slate-500"
style={{
width: `${(point.amount / maxTrendValue) * 100}%`,
}}
/>
</div>
</div>
))}
</div>
) : (
<EmptyState
title="Нет данных"
description="Добавьте транзакции для отображения трендов"
/>
)}
</div>
{/* Category Breakdown */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-slate-900">
Расходы по категориям
</h3>
{categoryBreakdown.length > 0 ? (
<div className="space-y-3">
{categoryBreakdown.slice(0, 8).map((cat) => (
<div key={cat.categoryId}>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="font-medium text-slate-700">
{cat.categoryName}
</span>
<span className="text-slate-500">
{formatMoney(cat.amount)} ({cat.percentage.toFixed(1)}%)
</span>
</div>
<ProgressBar value={cat.percentage} size="sm" />
</div>
))}
</div>
) : (
<EmptyState
title="Нет данных"
description="Добавьте расходы для отображения статистики"
/>
)}
</div>
{/* Financial Health */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 lg:col-span-2">
<h3 className="mb-4 text-lg font-semibold text-slate-900">
Финансовое здоровье
</h3>
{health ? (
<div className="grid gap-6 sm:grid-cols-2">
<div className="flex items-center gap-6">
<div
className={`flex h-20 w-20 items-center justify-center rounded-2xl text-3xl font-bold ${gradeColors[health.grade]}`}
>
{health.grade}
</div>
<div>
<p className="text-3xl font-bold text-slate-900">
{health.score}/100
</p>
<p className="text-sm text-slate-500">Общий балл</p>
</div>
</div>
<div className="space-y-3">
{health.factors.map((factor, i) => (
<div key={i}>
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-slate-600">{factor.name}</span>
<span className="font-medium">{factor.score}%</span>
</div>
<ProgressBar
value={factor.score}
size="sm"
color="success"
/>
<p className="mt-1 text-xs text-slate-500">
{factor.description}
</p>
{factor.recommendation && (
<p className="mt-1 text-xs text-amber-600">
{factor.recommendation}
</p>
)}
</div>
))}
</div>
</div>
) : (
<EmptyState
title="Недостаточно данных"
description="Добавьте больше транзакций для расчёта финансового здоровья"
/>
)}
</div>
</div>
</div>
);
};
export default AnalyticsPage;

View File

@ -0,0 +1,454 @@
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
PageHeader,
Button,
EmptyState,
ProgressBar,
Modal,
Input,
CardSkeleton,
} from "../../components/ui";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
budgetsCurrentSelector,
budgetsProgressSelector,
budgetsLoadingSelector,
} from "../../store/budgets/selectors";
import {
budgetsGetByMonth,
budgetsCreate,
budgetsUpdate,
budgetsProgress,
} from "../../store/budgets/async-thunks";
import { parseApiError } from "../../api/parse-api-error";
const schema = z.object({
totalIncome: z.number().positive("Введите сумму дохода"),
essentialsPercent: z.number().min(0).max(100),
personalPercent: z.number().min(0).max(100),
savingsPercent: z.number().min(0).max(100),
});
type FormValues = z.infer<typeof schema>;
const formatMoney = (amount: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
}).format(amount);
};
const getMonthOptions = () => {
const options = [];
const now = new Date();
for (let i = -6; i <= 6; i++) {
const date = new Date(now.getFullYear(), now.getMonth() + i, 1);
options.push({
value: date.toISOString().split("T")[0],
label: date.toLocaleDateString("ru-RU", {
month: "long",
year: "numeric",
}),
});
}
return options;
};
const BudgetsPage = () => {
const dispatch = useAppDispatch();
const budget = useAppSelector(budgetsCurrentSelector);
const progress = useAppSelector(budgetsProgressSelector);
const isLoading = useAppSelector(budgetsLoadingSelector);
const [selectedMonth, setSelectedMonth] = useState(
new Date(new Date().getFullYear(), new Date().getMonth(), 1)
.toISOString()
.split("T")[0],
);
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
totalIncome: 0,
essentialsPercent: 50,
personalPercent: 30,
savingsPercent: 20,
},
});
const watchedValues = watch();
const totalPercent =
(watchedValues.essentialsPercent || 0) +
(watchedValues.personalPercent || 0) +
(watchedValues.savingsPercent || 0);
useEffect(() => {
dispatch(budgetsGetByMonth(selectedMonth));
dispatch(budgetsProgress(selectedMonth));
}, [dispatch, selectedMonth]);
useEffect(() => {
if (budget) {
// Calculate percentages from limits
const essentialsPercent =
budget.totalIncome > 0
? Math.round((budget.essentialsLimit / budget.totalIncome) * 100)
: 50;
const personalPercent =
budget.totalIncome > 0
? Math.round((budget.personalLimit / budget.totalIncome) * 100)
: 30;
const savingsPercent =
budget.totalIncome > 0
? Math.round((budget.savingsLimit / budget.totalIncome) * 100)
: 20;
reset({
totalIncome: budget.totalIncome,
essentialsPercent,
personalPercent,
savingsPercent,
});
} else {
reset({
totalIncome: 0,
essentialsPercent: 50,
personalPercent: 30,
savingsPercent: 20,
});
}
}, [budget, reset]);
const onSubmit = async (values: FormValues) => {
if (totalPercent !== 100) {
setError("Сумма процентов должна быть равна 100%");
return;
}
setIsSaving(true);
setError(null);
try {
if (budget) {
await dispatch(
budgetsUpdate({ month: selectedMonth, body: values }),
).unwrap();
} else {
await dispatch(
budgetsCreate({ month: selectedMonth, ...values }),
).unwrap();
}
setModalOpen(false);
dispatch(budgetsGetByMonth(selectedMonth));
dispatch(budgetsProgress(selectedMonth));
} catch (err) {
const parsed = parseApiError(err);
setError(parsed.messages[0] || "Произошла ошибка");
} finally {
setIsSaving(false);
}
};
const getProgressColor = (
percentage: number,
): "success" | "warning" | "danger" => {
if (percentage <= 80) return "success";
if (percentage <= 100) return "warning";
return "danger";
};
const monthOptions = getMonthOptions();
return (
<div>
<PageHeader
title="Бюджет"
description="Планирование по правилу 50/30/20"
actions={
<div className="flex items-center gap-3">
<select
value={selectedMonth}
onChange={(e) => setSelectedMonth(e.target.value)}
className="h-10 rounded-xl border border-slate-200 bg-white px-4 text-sm"
>
{monthOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button onClick={() => setModalOpen(true)}>
{budget ? "Редактировать" : "Создать бюджет"}
</Button>
</div>
}
/>
{isLoading ? (
<div className="grid gap-4 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : !budget ? (
<EmptyState
title="Бюджет не создан"
description="Создайте бюджет на этот месяц, чтобы отслеживать расходы"
action={
<Button onClick={() => setModalOpen(true)}>Создать бюджет</Button>
}
/>
) : (
<div className="space-y-6">
{/* Summary */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-900">
Общий доход
</h3>
<p className="text-2xl font-bold text-slate-900">
{formatMoney(budget.totalIncome)}
</p>
</div>
{progress && (
<div className="text-right">
<p className="text-sm text-slate-500">Потрачено</p>
<p className="text-xl font-semibold text-slate-900">
{formatMoney(
progress.essentials.spent +
progress.personal.spent +
progress.savings.spent,
)}
</p>
</div>
)}
</div>
{progress && (
<ProgressBar
value={
progress.essentials.spent +
progress.personal.spent +
progress.savings.spent
}
max={budget.totalIncome}
showValue
color={getProgressColor(
((progress.essentials.spent +
progress.personal.spent +
progress.savings.spent) /
budget.totalIncome) *
100,
)}
/>
)}
</div>
{/* Categories */}
<div className="grid gap-4 lg:grid-cols-3">
{/* Essentials */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">Необходимое</h3>
<p className="text-sm text-slate-500">
{budget.totalIncome > 0
? Math.round(
(budget.essentialsLimit / budget.totalIncome) * 100,
)
: 50}
% от дохода
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-100 text-blue-600">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
</div>
</div>
<p className="mb-2 text-2xl font-bold text-slate-900">
{formatMoney(
progress?.essentials.limit ?? budget.essentialsLimit,
)}
</p>
{progress && (
<>
<ProgressBar
value={progress.essentials.percent}
color={getProgressColor(progress.essentials.percent)}
/>
<p className="mt-2 text-sm text-slate-500">
Потрачено: {formatMoney(progress.essentials.spent)}
</p>
</>
)}
</div>
{/* Personal */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">Желания</h3>
<p className="text-sm text-slate-500">
{budget.totalIncome > 0
? Math.round(
(budget.personalLimit / budget.totalIncome) * 100,
)
: 30}
% от дохода
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-purple-100 text-purple-600">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</div>
</div>
<p className="mb-2 text-2xl font-bold text-slate-900">
{formatMoney(progress?.personal.limit ?? budget.personalLimit)}
</p>
{progress && (
<>
<ProgressBar
value={progress.personal.percent}
color={getProgressColor(progress.personal.percent)}
/>
<p className="mt-2 text-sm text-slate-500">
Потрачено: {formatMoney(progress.personal.spent)}
</p>
</>
)}
</div>
{/* Savings */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-semibold text-slate-900">Сбережения</h3>
<p className="text-sm text-slate-500">
{budget.totalIncome > 0
? Math.round(
(budget.savingsLimit / budget.totalIncome) * 100,
)
: 20}
% от дохода
</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<p className="mb-2 text-2xl font-bold text-slate-900">
{formatMoney(progress?.savings.limit ?? budget.savingsLimit)}
</p>
{progress && (
<>
<ProgressBar
value={progress.savings.percent}
color={getProgressColor(progress.savings.percent)}
/>
<p className="mt-2 text-sm text-slate-500">
Отложено: {formatMoney(progress.savings.spent)}
</p>
</>
)}
</div>
</div>
</div>
)}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={budget ? "Редактировать бюджет" : "Создать бюджет"}
>
{error && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
label="Общий доход за месяц"
type="number"
error={errors.totalIncome?.message}
{...register("totalIncome", { valueAsNumber: true })}
/>
<div className="grid gap-4 sm:grid-cols-3">
<Input
label="Необходимое %"
type="number"
error={errors.essentialsPercent?.message}
{...register("essentialsPercent")}
/>
<Input
label="Желания %"
type="number"
error={errors.personalPercent?.message}
{...register("personalPercent")}
/>
<Input
label="Сбережения %"
type="number"
error={errors.savingsPercent?.message}
{...register("savingsPercent")}
/>
</div>
<p
className={`text-sm ${totalPercent === 100 ? "text-emerald-600" : "text-rose-600"}`}
>
Сумма: {totalPercent}%{" "}
{totalPercent !== 100 && "(должно быть 100%)"}
</p>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Отмена
</Button>
<Button
type="submit"
isLoading={isSaving}
disabled={totalPercent !== 100}
>
{budget ? "Сохранить" : "Создать"}
</Button>
</div>
</form>
</Modal>
</div>
);
};
export default BudgetsPage;

View File

@ -0,0 +1,358 @@
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
PageHeader,
Button,
EmptyState,
Badge,
ConfirmDialog,
Modal,
Input,
Select,
CardSkeleton,
} from "../../components/ui";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
categoriesSelector,
categoriesGroupedSelector,
categoriesLoadingSelector,
} from "../../store/categories/selectors";
import {
categoriesList,
categoriesGetGrouped,
categoriesCreate,
categoriesUpdate,
categoriesDelete,
} from "../../store/categories/async-thunks";
import { parseApiError } from "../../api/parse-api-error";
import type { Category, CreateCategoryBody } from "../../interfaces/categories";
const schema = z.object({
nameRu: z.string().min(1, "Введите название"),
type: z.enum(["INCOME", "EXPENSE"] as const),
groupType: z.enum(["ESSENTIAL", "PERSONAL", "SAVINGS"] as const).optional(),
icon: z.string().optional(),
color: z.string().optional(),
});
type FormValues = z.infer<typeof schema>;
const groupLabels: Record<string, string> = {
ESSENTIAL: "Необходимое (50%)",
PERSONAL: "Желания (30%)",
SAVINGS: "Сбережения (20%)",
};
const CategoriesPage = () => {
const dispatch = useAppDispatch();
const categories = useAppSelector(categoriesSelector);
const grouped = useAppSelector(categoriesGroupedSelector);
const isLoading = useAppSelector(categoriesLoadingSelector);
const [modalOpen, setModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [viewMode, setViewMode] = useState<"list" | "grouped">("grouped");
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
nameRu: "",
type: "EXPENSE",
groupType: undefined,
},
});
useEffect(() => {
dispatch(categoriesList());
dispatch(categoriesGetGrouped());
}, [dispatch]);
const handleEdit = (category: Category) => {
setEditingCategory(category);
reset({
nameRu: category.nameRu,
type: category.type,
groupType: category.groupType,
icon: category.icon || "",
color: category.color || "",
});
setModalOpen(true);
};
const handleCreate = () => {
setEditingCategory(null);
reset({
nameRu: "",
type: "EXPENSE",
groupType: undefined,
});
setModalOpen(true);
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
await dispatch(categoriesDelete(deleteId)).unwrap();
dispatch(categoriesList());
dispatch(categoriesGetGrouped());
} catch {
// Handle error
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const onSubmit = async (values: FormValues) => {
setIsSaving(true);
setError(null);
try {
const payload: CreateCategoryBody = {
...values,
groupType: values.groupType || undefined,
};
if (editingCategory) {
await dispatch(
categoriesUpdate({ id: editingCategory.id, body: payload }),
).unwrap();
} else {
await dispatch(categoriesCreate(payload)).unwrap();
}
setModalOpen(false);
dispatch(categoriesList());
dispatch(categoriesGetGrouped());
} catch (err) {
const parsed = parseApiError(err);
setError(parsed.messages[0] || "Произошла ошибка");
} finally {
setIsSaving(false);
}
};
const typeOptions = [
{ value: "EXPENSE", label: "Расход" },
{ value: "INCOME", label: "Доход" },
];
const groupOptions = [
{ value: "", label: "Не указана" },
{ value: "ESSENTIAL", label: "Необходимое (50%)" },
{ value: "PERSONAL", label: "Желания (30%)" },
{ value: "SAVINGS", label: "Сбережения (20%)" },
];
const renderCategoryCard = (category: Category) => (
<div
key={category.id}
className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4"
>
<div className="flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-xl text-lg"
style={{ backgroundColor: category.color || "#f1f5f9" }}
>
{category.icon || "📁"}
</div>
<div>
<p className="font-medium text-slate-900">{category.nameRu}</p>
<div className="flex items-center gap-2">
<Badge
variant={category.type === "INCOME" ? "success" : "danger"}
size="sm"
>
{category.type === "INCOME" ? "Доход" : "Расход"}
</Badge>
{category.isDefault && (
<Badge variant="info" size="sm">
По умолчанию
</Badge>
)}
</div>
</div>
</div>
{!category.isDefault && (
<div className="flex items-center gap-1">
<button
onClick={() => handleEdit(category)}
className="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={() => setDeleteId(category.id)}
className="rounded-lg p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
)}
</div>
);
return (
<div>
<PageHeader
title="Категории"
description="Управление категориями доходов и расходов"
actions={
<div className="flex items-center gap-3">
<div className="flex rounded-xl border border-slate-200 bg-white p-1">
<button
onClick={() => setViewMode("grouped")}
className={`rounded-lg px-3 py-1.5 text-sm font-medium ${
viewMode === "grouped"
? "bg-slate-900 text-white"
: "text-slate-600"
}`}
>
По группам
</button>
<button
onClick={() => setViewMode("list")}
className={`rounded-lg px-3 py-1.5 text-sm font-medium ${
viewMode === "list"
? "bg-slate-900 text-white"
: "text-slate-600"
}`}
>
Список
</button>
</div>
<Button onClick={handleCreate}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 5v14m-7-7h14"
/>
</svg>
Добавить
</Button>
</div>
}
/>
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : viewMode === "grouped" && grouped ? (
<div className="space-y-6">
{(["ESSENTIAL", "PERSONAL", "SAVINGS"] as const).map((group) => (
<div key={group}>
<h3 className="mb-3 text-sm font-semibold text-slate-500 uppercase tracking-wide">
{groupLabels[group]}
</h3>
{grouped[group].length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{grouped[group].map(renderCategoryCard)}
</div>
) : (
<p className="text-sm text-slate-400">
Нет категорий в этой группе
</p>
)}
</div>
))}
</div>
) : categories.length === 0 ? (
<EmptyState
title="Нет категорий"
description="Создайте категорию для организации транзакций"
action={<Button onClick={handleCreate}>Создать категорию</Button>}
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{categories.map(renderCategoryCard)}
</div>
)}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={editingCategory ? "Редактировать категорию" : "Новая категория"}
>
{error && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
label="Название"
error={errors.nameRu?.message}
{...register("nameRu")}
/>
<Select
label="Тип"
options={typeOptions}
error={errors.type?.message}
{...register("type")}
/>
<Select
label="Группа бюджета"
options={groupOptions}
error={errors.groupType?.message}
{...register("groupType")}
/>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Отмена
</Button>
<Button type="submit" isLoading={isSaving}>
{editingCategory ? "Сохранить" : "Создать"}
</Button>
</div>
</form>
</Modal>
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Удалить категорию?"
message="Транзакции с этой категорией останутся без категории."
confirmText="Удалить"
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default CategoriesPage;

View File

@ -0,0 +1,534 @@
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
PageHeader,
Button,
EmptyState,
Badge,
ProgressBar,
ConfirmDialog,
Modal,
Input,
Select,
CardSkeleton,
} from "../../components/ui";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
goalsSelector,
goalsUpcomingSelector,
goalsLoadingSelector,
} from "../../store/goals/selectors";
import {
goalsList,
goalsCreate,
goalsUpdate,
goalsDelete,
goalsAddFunds,
goalsWithdraw,
goalsUpcoming,
} from "../../store/goals/async-thunks";
import { parseApiError } from "../../api/parse-api-error";
import type { Goal, GoalStatus, CreateGoalBody } from "../../interfaces/goals";
const schema = z.object({
titleRu: z.string().min(1, "Введите название"),
descriptionRu: z.string().optional(),
targetAmount: z.number().positive("Введите целевую сумму"),
currentAmount: z.number().min(0).optional(),
targetDate: z.string().optional(),
priority: z.enum(["LOW", "MEDIUM", "HIGH"] as const),
});
type FormValues = z.infer<typeof schema>;
const fundsSchema = z.object({
amount: z.number().positive("Введите сумму"),
note: z.string().optional(),
});
type FundsFormValues = z.infer<typeof fundsSchema>;
const formatMoney = (amount: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
}).format(amount);
};
const statusLabels: Record<GoalStatus, string> = {
ACTIVE: "Активная",
COMPLETED: "Достигнута",
PAUSED: "Приостановлена",
CANCELLED: "Отменена",
};
const statusVariants: Record<
GoalStatus,
"success" | "warning" | "danger" | "default"
> = {
ACTIVE: "success",
COMPLETED: "success",
PAUSED: "warning",
CANCELLED: "danger",
};
const GoalsPage = () => {
const dispatch = useAppDispatch();
const goals = useAppSelector(goalsSelector);
const upcoming = useAppSelector(goalsUpcomingSelector);
const isLoading = useAppSelector(goalsLoadingSelector);
const [statusFilter, setStatusFilter] = useState<GoalStatus | "">("");
const [modalOpen, setModalOpen] = useState(false);
const [fundsModalOpen, setFundsModalOpen] = useState(false);
const [fundsMode, setFundsMode] = useState<"add" | "withdraw">("add");
const [selectedGoal, setSelectedGoal] = useState<Goal | null>(null);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
titleRu: "",
descriptionRu: "",
targetAmount: 0,
currentAmount: 0,
targetDate: "",
priority: "MEDIUM",
},
});
const {
register: registerFunds,
handleSubmit: handleSubmitFunds,
reset: resetFunds,
formState: { errors: fundsErrors },
} = useForm<FundsFormValues>({
resolver: zodResolver(fundsSchema),
defaultValues: { amount: 0, note: "" },
});
useEffect(() => {
dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined));
dispatch(goalsUpcoming(30));
}, [dispatch, statusFilter]);
const handleCreate = () => {
setSelectedGoal(null);
reset({
titleRu: "",
descriptionRu: "",
targetAmount: 0,
currentAmount: 0,
targetDate: "",
priority: "MEDIUM",
});
setModalOpen(true);
};
const handleEdit = (goal: Goal) => {
setSelectedGoal(goal);
reset({
titleRu: goal.titleRu,
descriptionRu: goal.descriptionRu || "",
targetAmount: goal.targetAmount,
currentAmount: goal.currentAmount,
targetDate: goal.targetDate?.split("T")[0] || "",
priority: goal.priority,
});
setModalOpen(true);
};
const handleAddFunds = (goal: Goal) => {
setSelectedGoal(goal);
setFundsMode("add");
resetFunds({ amount: 0, note: "" });
setFundsModalOpen(true);
};
const handleWithdraw = (goal: Goal) => {
setSelectedGoal(goal);
setFundsMode("withdraw");
resetFunds({ amount: 0, note: "" });
setFundsModalOpen(true);
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
await dispatch(goalsDelete(deleteId)).unwrap();
dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined));
} catch {
// Handle error
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const onSubmit = async (values: FormValues) => {
setIsSaving(true);
setError(null);
try {
const payload: CreateGoalBody = {
...values,
targetDate: values.targetDate
? new Date(values.targetDate).toISOString()
: undefined,
};
if (selectedGoal) {
await dispatch(
goalsUpdate({ id: selectedGoal.id, body: payload }),
).unwrap();
} else {
await dispatch(goalsCreate(payload)).unwrap();
}
setModalOpen(false);
dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined));
} catch (err) {
const parsed = parseApiError(err);
setError(parsed.messages[0] || "Произошла ошибка");
} finally {
setIsSaving(false);
}
};
const onSubmitFunds = async (values: FundsFormValues) => {
if (!selectedGoal) return;
setIsSaving(true);
setError(null);
try {
if (fundsMode === "add") {
await dispatch(
goalsAddFunds({ id: selectedGoal.id, body: values }),
).unwrap();
} else {
await dispatch(
goalsWithdraw({ id: selectedGoal.id, body: values }),
).unwrap();
}
setFundsModalOpen(false);
dispatch(goalsList(statusFilter ? { status: statusFilter } : undefined));
} catch (err) {
const parsed = parseApiError(err);
setError(parsed.messages[0] || "Произошла ошибка");
} finally {
setIsSaving(false);
}
};
const statusOptions = [
{ value: "", label: "Все цели" },
{ value: "ACTIVE", label: "Активные" },
{ value: "COMPLETED", label: "Достигнутые" },
{ value: "PAUSED", label: "Приостановленные" },
{ value: "CANCELLED", label: "Отменённые" },
];
const priorityOptions = [
{ value: "LOW", label: "Низкий" },
{ value: "MEDIUM", label: "Средний" },
{ value: "HIGH", label: "Высокий" },
];
return (
<div>
<PageHeader
title="Цели"
description="Финансовые цели и прогресс"
actions={
<div className="flex items-center gap-3">
<Select
options={statusOptions}
value={statusFilter}
onChange={(e) =>
setStatusFilter(e.target.value as GoalStatus | "")
}
className="w-40"
/>
<Button onClick={handleCreate}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 5v14m-7-7h14"
/>
</svg>
Добавить
</Button>
</div>
}
/>
{/* Upcoming Deadlines */}
{upcoming.length > 0 && (
<div className="mb-6 rounded-2xl border border-amber-200 bg-amber-50 p-4">
<h3 className="font-semibold text-amber-800">Ближайшие дедлайны</h3>
<div className="mt-2 space-y-2">
{upcoming.slice(0, 3).map((goal) => (
<div
key={goal.id}
className="flex items-center justify-between text-sm"
>
<span className="text-amber-700">{goal.titleRu}</span>
<span className="text-amber-600">
{goal.targetDate &&
new Date(goal.targetDate).toLocaleDateString("ru-RU")}
</span>
</div>
))}
</div>
</div>
)}
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : goals.length === 0 ? (
<EmptyState
title="Нет целей"
description="Создайте финансовую цель, чтобы начать копить"
action={<Button onClick={handleCreate}>Создать цель</Button>}
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{goals.map((goal) => {
const progress = (goal.currentAmount / goal.targetAmount) * 100;
return (
<div
key={goal.id}
className="rounded-2xl border border-slate-200 bg-white p-5"
>
<div className="mb-3 flex items-start justify-between">
<div>
<h3 className="font-semibold text-slate-900">
{goal.titleRu}
</h3>
{goal.descriptionRu && (
<p className="mt-1 text-sm text-slate-500">
{goal.descriptionRu}
</p>
)}
</div>
<Badge variant={statusVariants[goal.status]} size="sm">
{statusLabels[goal.status]}
</Badge>
</div>
<div className="mb-3">
<div className="mb-1 flex items-center justify-between text-sm">
<span className="text-slate-500">Прогресс</span>
<span className="font-medium text-slate-900">
{Math.round(progress)}%
</span>
</div>
<ProgressBar
value={progress}
color={progress >= 100 ? "success" : "default"}
/>
</div>
<div className="mb-4 flex items-center justify-between text-sm">
<span className="text-slate-500">
{formatMoney(goal.currentAmount)} /{" "}
{formatMoney(goal.targetAmount)}
</span>
{goal.targetDate && (
<span className="text-slate-400">
до {new Date(goal.targetDate).toLocaleDateString("ru-RU")}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleAddFunds(goal)}
disabled={goal.status !== "ACTIVE"}
>
Пополнить
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleWithdraw(goal)}
disabled={
goal.status !== "ACTIVE" || goal.currentAmount === 0
}
>
Снять
</Button>
<div className="ml-auto flex items-center gap-1">
<button
onClick={() => handleEdit(goal)}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={() => setDeleteId(goal.id)}
className="rounded-lg p-1.5 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* Create/Edit Modal */}
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
title={selectedGoal ? "Редактировать цель" : "Новая цель"}
size="md"
>
{error && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{error}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
label="Название"
error={errors.titleRu?.message}
{...register("titleRu")}
/>
<Input
label="Описание"
error={errors.descriptionRu?.message}
{...register("descriptionRu")}
/>
<div className="grid gap-4 sm:grid-cols-2">
<Input
label="Целевая сумма"
type="number"
error={errors.targetAmount?.message}
{...register("targetAmount", { valueAsNumber: true })}
/>
<Input
label="Текущая сумма"
type="number"
error={errors.currentAmount?.message}
{...register("currentAmount", { valueAsNumber: true })}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Input
label="Дедлайн"
type="date"
error={errors.targetDate?.message}
{...register("targetDate")}
/>
<Select
label="Приоритет"
options={priorityOptions}
error={errors.priority?.message}
{...register("priority")}
/>
</div>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Отмена
</Button>
<Button type="submit" isLoading={isSaving}>
{selectedGoal ? "Сохранить" : "Создать"}
</Button>
</div>
</form>
</Modal>
{/* Funds Modal */}
<Modal
isOpen={fundsModalOpen}
onClose={() => setFundsModalOpen(false)}
title={fundsMode === "add" ? "Пополнить цель" : "Снять средства"}
size="sm"
>
{error && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{error}
</div>
)}
<form onSubmit={handleSubmitFunds(onSubmitFunds)} className="space-y-4">
<Input
label="Сумма"
type="number"
error={fundsErrors.amount?.message}
{...registerFunds("amount", { valueAsNumber: true })}
/>
<Input
label="Комментарий"
error={fundsErrors.note?.message}
{...registerFunds("note")}
/>
<div className="flex justify-end gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setFundsModalOpen(false)}
>
Отмена
</Button>
<Button type="submit" isLoading={isSaving}>
{fundsMode === "add" ? "Пополнить" : "Снять"}
</Button>
</div>
</form>
</Modal>
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Удалить цель?"
message="Это действие нельзя отменить."
confirmText="Удалить"
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default GoalsPage;

View File

@ -0,0 +1,340 @@
import { useEffect } from "react";
import {
PageHeader,
StatCard,
ProgressBar,
CardSkeleton,
EmptyState,
} from "../../components/ui";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
analyticsOverviewSelector,
analyticsLoadingSelector,
} from "../../store/analytics/selectors";
import { analyticsOverview } from "../../store/analytics/async-thunks";
import {
budgetsCurrentSelector,
budgetsProgressSelector,
} from "../../store/budgets/selectors";
import {
budgetsGetCurrent,
budgetsProgress,
} from "../../store/budgets/async-thunks";
import { goalsSummarySelector } from "../../store/goals/selectors";
import { goalsSummary } from "../../store/goals/async-thunks";
import { recommendationsStatsSelector } from "../../store/recommendations/selectors";
import { recommendationsStats } from "../../store/recommendations/async-thunks";
const formatMoney = (amount: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
}).format(amount);
};
const DashboardPage = () => {
const dispatch = useAppDispatch();
const overview = useAppSelector(analyticsOverviewSelector);
const isLoading = useAppSelector(analyticsLoadingSelector);
const budget = useAppSelector(budgetsCurrentSelector);
const budgetProgress = useAppSelector(budgetsProgressSelector);
const goals = useAppSelector(goalsSummarySelector);
const recommendations = useAppSelector(recommendationsStatsSelector);
useEffect(() => {
dispatch(analyticsOverview());
dispatch(budgetsGetCurrent());
dispatch(goalsSummary());
dispatch(recommendationsStats());
}, [dispatch]);
useEffect(() => {
if (budget?.month) {
dispatch(budgetsProgress(budget.month));
}
}, [dispatch, budget?.month]);
const getProgressColor = (
percentage: number,
): "success" | "warning" | "danger" => {
if (percentage <= 80) return "success";
if (percentage <= 100) return "warning";
return "danger";
};
if (isLoading) {
return (
<div>
<PageHeader title="Обзор" description="Ваши финансы за текущий месяц" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
</div>
);
}
return (
<div>
<PageHeader title="Обзор" description="Ваши финансы за текущий месяц" />
{/* Main Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Доходы"
value={formatMoney(overview?.totalIncome || 0)}
icon={
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 4v16m0-16l-4 4m4-4l4 4"
/>
</svg>
}
/>
<StatCard
title="Расходы"
value={formatMoney(overview?.totalExpenses || 0)}
icon={
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 20V4m0 16l-4-4m4 4l4-4"
/>
</svg>
}
/>
<StatCard
title="Сбережения"
value={formatMoney(overview?.netSavings || 0)}
icon={
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2z"
/>
</svg>
}
/>
<StatCard
title="Норма сбережений"
value={`${(overview?.savingsRate || 0).toFixed(1)}%`}
icon={
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
}
/>
</div>
{/* Budget Progress */}
<div className="mt-6 grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Бюджет 50/30/20
</h2>
<p className="mt-1 text-sm text-slate-500">
Прогресс по категориям расходов
</p>
{budgetProgress ? (
<div className="mt-6 space-y-5">
<div>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-slate-700">
Необходимое (50%)
</span>
<span className="text-slate-500">
{formatMoney(budgetProgress.essentials.spent)} /{" "}
{formatMoney(budgetProgress.essentials.limit)}
</span>
</div>
<ProgressBar
value={budgetProgress.essentials.percent}
color={getProgressColor(budgetProgress.essentials.percent)}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-slate-700">
Желания (30%)
</span>
<span className="text-slate-500">
{formatMoney(budgetProgress.personal.spent)} /{" "}
{formatMoney(budgetProgress.personal.limit)}
</span>
</div>
<ProgressBar
value={budgetProgress.personal.percent}
color={getProgressColor(budgetProgress.personal.percent)}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-slate-700">
Сбережения (20%)
</span>
<span className="text-slate-500">
{formatMoney(budgetProgress.savings.spent)} /{" "}
{formatMoney(budgetProgress.savings.limit)}
</span>
</div>
<ProgressBar
value={budgetProgress.savings.percent}
color={getProgressColor(budgetProgress.savings.percent)}
/>
</div>
</div>
) : (
<EmptyState
title="Бюджет не настроен"
description="Создайте бюджет, чтобы отслеживать расходы по правилу 50/30/20"
/>
)}
</div>
{/* Goals Summary */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">Цели</h2>
<p className="mt-1 text-sm text-slate-500">
Прогресс по финансовым целям
</p>
{goals && goals.totalGoals > 0 ? (
<div className="mt-6">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl bg-slate-50 p-4">
<p className="text-2xl font-semibold text-slate-900">
{goals.activeGoals}
</p>
<p className="text-sm text-slate-500">Активных целей</p>
</div>
<div className="rounded-xl bg-slate-50 p-4">
<p className="text-2xl font-semibold text-slate-900">
{goals.completedGoals}
</p>
<p className="text-sm text-slate-500">Достигнуто</p>
</div>
</div>
<div className="mt-4">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-slate-700">
Общий прогресс
</span>
<span className="text-slate-500">
{Math.round(goals.overallProgress)}%
</span>
</div>
<ProgressBar value={goals.overallProgress} color="default" />
</div>
<div className="mt-4 text-sm text-slate-600">
<p>Накоплено: {formatMoney(goals.totalCurrentAmount)}</p>
<p>Цель: {formatMoney(goals.totalTargetAmount)}</p>
</div>
</div>
) : (
<EmptyState
title="Нет целей"
description="Создайте финансовую цель, чтобы отслеживать прогресс"
/>
)}
</div>
</div>
{/* Top Categories & Recommendations */}
<div className="mt-6 grid gap-6 lg:grid-cols-2">
{/* Top Categories */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Топ категорий
</h2>
<p className="mt-1 text-sm text-slate-500">
Основные статьи расходов
</p>
{overview?.topCategories && overview.topCategories.length > 0 ? (
<div className="mt-4 space-y-3">
{overview.topCategories.slice(0, 5).map((cat) => (
<div
key={cat.categoryId}
className="flex items-center justify-between"
>
<div className="flex items-center gap-3">
<div className="h-2 w-2 rounded-full bg-slate-400" />
<span className="text-sm font-medium text-slate-700">
{cat.categoryName}
</span>
</div>
<div className="text-right">
<p className="text-sm font-medium text-slate-900">
{formatMoney(cat.amount)}
</p>
<p className="text-xs text-slate-500">
{cat.percentage.toFixed(1)}%
</p>
</div>
</div>
))}
</div>
) : (
<EmptyState
title="Нет данных"
description="Добавьте транзакции, чтобы увидеть статистику"
/>
)}
</div>
{/* Recommendations */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">Рекомендации</h2>
<p className="mt-1 text-sm text-slate-500">
Советы по улучшению финансов
</p>
{recommendations && recommendations.total > 0 ? (
<div className="mt-4">
<div className="grid grid-cols-2 gap-4">
<div className="rounded-xl bg-sky-50 p-4">
<p className="text-2xl font-semibold text-sky-700">
{recommendations.new}
</p>
<p className="text-sm text-sky-600">Новых</p>
</div>
<div className="rounded-xl bg-emerald-50 p-4">
<p className="text-2xl font-semibold text-emerald-700">
{recommendations.applied}
</p>
<p className="text-sm text-emerald-600">Применено</p>
</div>
</div>
<p className="mt-4 text-sm text-slate-600">
Применено рекомендаций: {recommendations.applied} из{" "}
{recommendations.total}
</p>
</div>
) : (
<EmptyState
title="Нет рекомендаций"
description="Рекомендации появятся после анализа ваших финансов"
/>
)}
</div>
</div>
</div>
);
};
export default DashboardPage;

View File

@ -0,0 +1,280 @@
import { useEffect, useState } from "react";
import {
PageHeader,
Button,
EmptyState,
Badge,
CardSkeleton,
} from "../../components/ui";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
recommendationsSelector,
recommendationsStatsSelector,
recommendationsLoadingSelector,
} from "../../store/recommendations/selectors";
import {
recommendationsList,
recommendationsGenerate,
recommendationsApply,
recommendationsDismiss,
recommendationsStats,
} from "../../store/recommendations/async-thunks";
import type { RecommendationType } from "../../interfaces/recommendations";
const typeLabels: Record<RecommendationType, string> = {
SAVING: "Экономия",
SPENDING: "Расходы",
INVESTMENT: "Инвестиции",
TAX: "Налоги",
DEBT: "Долги",
BUDGET: "Бюджет",
GOAL: "Цели",
};
const typeVariants: Record<
RecommendationType,
"success" | "warning" | "danger" | "info" | "default"
> = {
SAVING: "success",
SPENDING: "warning",
INVESTMENT: "info",
TAX: "default",
DEBT: "danger",
BUDGET: "info",
GOAL: "success",
};
const RecommendationsPage = () => {
const dispatch = useAppDispatch();
const recommendations = useAppSelector(recommendationsSelector);
const stats = useAppSelector(recommendationsStatsSelector);
const isLoading = useAppSelector(recommendationsLoadingSelector);
const [isGenerating, setIsGenerating] = useState(false);
const [processingId, setProcessingId] = useState<string | null>(null);
useEffect(() => {
dispatch(recommendationsList());
dispatch(recommendationsStats());
}, [dispatch]);
const handleGenerate = async () => {
setIsGenerating(true);
try {
await dispatch(recommendationsGenerate()).unwrap();
dispatch(recommendationsList());
dispatch(recommendationsStats());
} catch {
// Handle error
} finally {
setIsGenerating(false);
}
};
const handleApply = async (id: string) => {
setProcessingId(id);
try {
await dispatch(recommendationsApply(id)).unwrap();
dispatch(recommendationsList());
dispatch(recommendationsStats());
} catch {
// Handle error
} finally {
setProcessingId(null);
}
};
const handleDismiss = async (id: string) => {
setProcessingId(id);
try {
await dispatch(recommendationsDismiss(id)).unwrap();
dispatch(recommendationsList());
dispatch(recommendationsStats());
} catch {
// Handle error
} finally {
setProcessingId(null);
}
};
const newRecommendations = recommendations.filter((r) => r.status === "NEW");
const appliedRecommendations = recommendations.filter(
(r) => r.status === "APPLIED",
);
return (
<div>
<PageHeader
title="Рекомендации"
description="Персональные советы по улучшению финансов"
actions={
<Button onClick={handleGenerate} isLoading={isGenerating}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Обновить
</Button>
}
/>
{/* Stats */}
{stats && (
<div className="mb-6 grid gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Всего</p>
<p className="mt-1 text-2xl font-semibold text-slate-900">
{stats.total}
</p>
</div>
<div className="rounded-xl border border-sky-200 bg-sky-50 p-4">
<p className="text-sm text-sky-600">Новых</p>
<p className="mt-1 text-2xl font-semibold text-sky-700">
{stats.new}
</p>
</div>
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<p className="text-sm text-emerald-600">Применено</p>
<p className="mt-1 text-2xl font-semibold text-emerald-700">
{stats.applied}
</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<p className="text-sm text-amber-600">Отклонено</p>
<p className="mt-1 text-2xl font-semibold text-amber-700">
{stats.dismissed}
</p>
</div>
</div>
)}
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
) : recommendations.length === 0 ? (
<EmptyState
title="Нет рекомендаций"
description="Нажмите 'Обновить' для генерации персональных рекомендаций"
action={
<Button onClick={handleGenerate} isLoading={isGenerating}>
Сгенерировать рекомендации
</Button>
}
/>
) : (
<div className="space-y-6">
{/* New Recommendations */}
{newRecommendations.length > 0 && (
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Новые рекомендации
</h3>
<div className="space-y-3">
{newRecommendations.map((rec) => (
<div
key={rec.id}
className="rounded-2xl border border-slate-200 bg-white p-5"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="mb-2 flex items-center gap-2">
<Badge variant={typeVariants[rec.type]} size="sm">
{typeLabels[rec.type]}
</Badge>
{rec.confidenceScore > 0.8 && (
<Badge variant="success" size="sm">
Высокая уверенность
</Badge>
)}
</div>
<h4 className="font-semibold text-slate-900">
{rec.titleRu}
</h4>
<p className="mt-1 text-sm text-slate-600">
{rec.descriptionRu}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => handleApply(rec.id)}
isLoading={processingId === rec.id}
>
Применить
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismiss(rec.id)}
disabled={processingId === rec.id}
>
Скрыть
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Applied Recommendations */}
{appliedRecommendations.length > 0 && (
<div>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">
Применённые рекомендации
</h3>
<div className="space-y-3">
{appliedRecommendations.map((rec) => (
<div
key={rec.id}
className="rounded-2xl border border-emerald-100 bg-emerald-50/50 p-5"
>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-100 text-emerald-600">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<div className="mb-1 flex items-center gap-2">
<Badge variant={typeVariants[rec.type]} size="sm">
{typeLabels[rec.type]}
</Badge>
</div>
<h4 className="font-semibold text-slate-900">
{rec.titleRu}
</h4>
<p className="mt-1 text-sm text-slate-600">
{rec.descriptionRu}
</p>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
);
};
export default RecommendationsPage;

View File

@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useNavigate } from "react-router";
import { PageHeader, Button, Input, CardSkeleton } from "../../components/ui";
import {
authGetProfile,
authUpdateProfile,
authChangePassword,
authLogout,
} from "../../api/profile";
import { parseApiError } from "../../api/parse-api-error";
import type { UserProfile } from "../../interfaces/auth";
const profileSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
phone: z.string().optional(),
});
type ProfileFormValues = z.infer<typeof profileSchema>;
const passwordSchema = z
.object({
currentPassword: z.string().min(1, "Введите текущий пароль"),
newPassword: z.string().min(8, "Минимум 8 символов"),
confirmPassword: z.string().min(1, "Подтвердите пароль"),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Пароли не совпадают",
path: ["confirmPassword"],
});
type PasswordFormValues = z.infer<typeof passwordSchema>;
const SettingsPage = () => {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSavingProfile, setIsSavingProfile] = useState(false);
const [isSavingPassword, setIsSavingPassword] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [profileError, setProfileError] = useState<string | null>(null);
const [profileSuccess, setProfileSuccess] = useState(false);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [passwordSuccess, setPasswordSuccess] = useState(false);
const {
register: registerProfile,
handleSubmit: handleSubmitProfile,
reset: resetProfile,
formState: { errors: profileErrors },
} = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
});
const {
register: registerPassword,
handleSubmit: handleSubmitPassword,
reset: resetPassword,
formState: { errors: passwordErrors },
} = useForm<PasswordFormValues>({
resolver: zodResolver(passwordSchema),
});
useEffect(() => {
loadProfile();
}, []);
const loadProfile = async () => {
setIsLoading(true);
try {
const data = await authGetProfile();
setProfile(data);
resetProfile({
firstName: data.firstName || "",
lastName: data.lastName || "",
phone: data.phone || "",
});
} catch {
// Handle error
} finally {
setIsLoading(false);
}
};
const onSubmitProfile = async (values: ProfileFormValues) => {
setIsSavingProfile(true);
setProfileError(null);
setProfileSuccess(false);
try {
const updated = await authUpdateProfile(values);
setProfile(updated);
setProfileSuccess(true);
setTimeout(() => setProfileSuccess(false), 3000);
} catch (err) {
const parsed = parseApiError(err);
setProfileError(parsed.messages[0] || "Произошла ошибка");
} finally {
setIsSavingProfile(false);
}
};
const onSubmitPassword = async (values: PasswordFormValues) => {
setIsSavingPassword(true);
setPasswordError(null);
setPasswordSuccess(false);
try {
await authChangePassword(values);
setPasswordSuccess(true);
resetPassword();
setTimeout(() => setPasswordSuccess(false), 3000);
} catch (err) {
const parsed = parseApiError(err);
setPasswordError(parsed.messages[0] || "Произошла ошибка");
} finally {
setIsSavingPassword(false);
}
};
const handleLogout = async () => {
setIsLoggingOut(true);
try {
await authLogout();
navigate("/auth/login");
} catch {
// Even if logout fails, redirect to login
navigate("/auth/login");
}
};
if (isLoading) {
return (
<div>
<PageHeader
title="Настройки"
description="Управление профилем и безопасностью"
/>
<div className="max-w-2xl space-y-6">
<CardSkeleton />
<CardSkeleton />
</div>
</div>
);
}
return (
<div>
<PageHeader
title="Настройки"
description="Управление профилем и безопасностью"
/>
<div className="max-w-2xl space-y-6">
{/* Profile Section */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-slate-900">Профиль</h3>
{profileError && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{profileError}
</div>
)}
{profileSuccess && (
<div className="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">
Профиль успешно обновлён
</div>
)}
<form
onSubmit={handleSubmitProfile(onSubmitProfile)}
className="space-y-4"
>
<div className="rounded-xl bg-slate-50 p-4">
<p className="text-sm text-slate-500">Email</p>
<p className="font-medium text-slate-900">{profile?.email}</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Input
label="Имя"
error={profileErrors.firstName?.message}
{...registerProfile("firstName")}
/>
<Input
label="Фамилия"
error={profileErrors.lastName?.message}
{...registerProfile("lastName")}
/>
</div>
<Input
label="Телефон"
type="tel"
error={profileErrors.phone?.message}
{...registerProfile("phone")}
/>
<div className="flex justify-end">
<Button type="submit" isLoading={isSavingProfile}>
Сохранить
</Button>
</div>
</form>
</div>
{/* Password Section */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-slate-900">
Изменить пароль
</h3>
{passwordError && (
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">
{passwordError}
</div>
)}
{passwordSuccess && (
<div className="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">
Пароль успешно изменён
</div>
)}
<form
onSubmit={handleSubmitPassword(onSubmitPassword)}
className="space-y-4"
>
<Input
label="Текущий пароль"
type="password"
error={passwordErrors.currentPassword?.message}
{...registerPassword("currentPassword")}
/>
<Input
label="Новый пароль"
type="password"
error={passwordErrors.newPassword?.message}
{...registerPassword("newPassword")}
/>
<Input
label="Подтвердите пароль"
type="password"
error={passwordErrors.confirmPassword?.message}
{...registerPassword("confirmPassword")}
/>
<div className="flex justify-end">
<Button type="submit" isLoading={isSavingPassword}>
Изменить пароль
</Button>
</div>
</form>
</div>
{/* Logout Section */}
<div className="rounded-2xl border border-slate-200 bg-white p-6">
<h3 className="mb-2 text-lg font-semibold text-slate-900">Выход</h3>
<p className="mb-4 text-sm text-slate-500">
Выйти из аккаунта на этом устройстве
</p>
<Button
variant="danger"
onClick={handleLogout}
isLoading={isLoggingOut}
>
Выйти из аккаунта
</Button>
</div>
</div>
</div>
);
};
export default SettingsPage;

View File

@ -0,0 +1,404 @@
import { useEffect, useState } from "react";
import {
PageHeader,
Button,
EmptyState,
Badge,
ConfirmDialog,
ListItemSkeleton,
Input,
Select,
} from "../../components/ui";
import { TransactionModal } from "../../components/transactions/TransactionModal";
import { useAppDispatch, useAppSelector } from "../../store/hooks";
import {
transactionsSelector,
transactionsSummarySelector,
transactionsLoadingSelector,
} from "../../store/transactions/selectors";
import {
transactionsList,
transactionsDelete,
transactionsSummary,
} from "../../store/transactions/async-thunks";
import { categoriesSelector } from "../../store/categories/selectors";
import { categoriesList } from "../../store/categories/async-thunks";
import type {
Transaction,
TransactionFilters,
} from "../../interfaces/transactions";
const formatMoney = (amount: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
currency: "RUB",
maximumFractionDigits: 0,
}).format(amount);
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString("ru-RU", {
day: "numeric",
month: "short",
});
};
const TransactionsPage = () => {
const dispatch = useAppDispatch();
const transactions = useAppSelector(transactionsSelector);
const categories = useAppSelector(categoriesSelector);
const summary = useAppSelector(transactionsSummarySelector);
const isLoading = useAppSelector(transactionsLoadingSelector);
const [filters, setFilters] = useState<TransactionFilters>({
page: 1,
limit: 50,
});
const [modalOpen, setModalOpen] = useState(false);
const [editingTransaction, setEditingTransaction] = useState<
Transaction | undefined
>();
const [deleteId, setDeleteId] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
dispatch(transactionsList(filters));
dispatch(
transactionsSummary({
startDate:
filters.startDate ||
new Date(
new Date().getFullYear(),
new Date().getMonth(),
1,
).toISOString(),
endDate: filters.endDate || new Date().toISOString(),
}),
);
}, [dispatch, filters]);
useEffect(() => {
dispatch(categoriesList());
}, [dispatch]);
const handleEdit = (transaction: Transaction) => {
setEditingTransaction(transaction);
setModalOpen(true);
};
const handleDelete = async () => {
if (!deleteId) return;
setIsDeleting(true);
try {
await dispatch(transactionsDelete(deleteId)).unwrap();
dispatch(transactionsList(filters));
} catch {
// Handle error
} finally {
setIsDeleting(false);
setDeleteId(null);
}
};
const handleModalClose = () => {
setModalOpen(false);
setEditingTransaction(undefined);
};
const handleSuccess = () => {
dispatch(transactionsList(filters));
};
const safeTransactions = Array.isArray(transactions) ? transactions : [];
const groupedTransactions = safeTransactions.reduce<
Record<string, Transaction[]>
>((groups, transaction) => {
const date = transaction.transactionDate.split("T")[0];
if (!groups[date]) groups[date] = [];
groups[date].push(transaction);
return groups;
}, {});
const typeOptions = [
{ value: "", label: "Все типы" },
{ value: "INCOME", label: "Доходы" },
{ value: "EXPENSE", label: "Расходы" },
];
const categoryOptions = [
{ value: "", label: "Все категории" },
...(categories || []).map((cat) => ({ value: cat.id, label: cat.nameRu })),
];
return (
<div>
<PageHeader
title="Транзакции"
description="История доходов и расходов"
actions={
<Button onClick={() => setModalOpen(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 5v14m-7-7h14"
/>
</svg>
Добавить
</Button>
}
/>
{/* Summary Cards */}
{summary && (
<div className="mb-6 grid gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Доходы</p>
<p className="mt-1 text-xl font-semibold text-emerald-600">
+{formatMoney(summary.totalIncome)}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Расходы</p>
<p className="mt-1 text-xl font-semibold text-rose-600">
-{formatMoney(summary.totalExpense)}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4">
<p className="text-sm text-slate-500">Баланс</p>
<p
className={`mt-1 text-xl font-semibold ${summary.balance >= 0 ? "text-slate-900" : "text-rose-600"}`}
>
{formatMoney(summary.balance)}
</p>
</div>
</div>
)}
{/* Filters */}
<div className="mb-6 rounded-xl border border-slate-200 bg-white p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Select
label="Тип"
options={typeOptions}
value={filters.type || ""}
onChange={(e) =>
setFilters({
...filters,
type:
(e.target.value as TransactionFilters["type"]) || undefined,
})
}
/>
<Select
label="Категория"
options={categoryOptions}
value={filters.categoryId || ""}
onChange={(e) =>
setFilters({
...filters,
categoryId: e.target.value || undefined,
})
}
/>
<Input
label="С даты"
type="date"
value={filters.startDate?.split("T")[0] || ""}
onChange={(e) =>
setFilters({
...filters,
startDate: e.target.value
? new Date(e.target.value).toISOString()
: undefined,
})
}
/>
<Input
label="По дату"
type="date"
value={filters.endDate?.split("T")[0] || ""}
onChange={(e) =>
setFilters({
...filters,
endDate: e.target.value
? new Date(e.target.value).toISOString()
: undefined,
})
}
/>
</div>
</div>
{/* Transactions List */}
<div className="rounded-xl border border-slate-200 bg-white">
{isLoading ? (
<div className="space-y-2 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<ListItemSkeleton key={i} />
))}
</div>
) : transactions.length === 0 ? (
<EmptyState
title="Нет транзакций"
description="Добавьте первую транзакцию, чтобы начать отслеживать финансы"
action={
<Button onClick={() => setModalOpen(true)}>
Добавить транзакцию
</Button>
}
/>
) : (
<div className="divide-y divide-slate-100">
{Object.entries(groupedTransactions)
.sort(([a], [b]) => b.localeCompare(a))
.map(([date, dayTransactions]) => (
<div key={date}>
<div className="bg-slate-50 px-4 py-2">
<p className="text-sm font-medium text-slate-600">
{new Date(date).toLocaleDateString("ru-RU", {
weekday: "long",
day: "numeric",
month: "long",
})}
</p>
</div>
{dayTransactions.map((transaction) => (
<div
key={transaction.id}
className="flex items-center justify-between px-4 py-3 hover:bg-slate-50"
>
<div className="flex items-center gap-3">
<div
className={`flex h-10 w-10 items-center justify-center rounded-xl ${
transaction.type === "INCOME"
? "bg-emerald-100 text-emerald-600"
: "bg-rose-100 text-rose-600"
}`}
>
{transaction.type === "INCOME" ? (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 4v16m0-16l-4 4m4-4l4 4"
/>
</svg>
) : (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M12 20V4m0 16l-4-4m4 4l4-4"
/>
</svg>
)}
</div>
<div>
<p className="font-medium text-slate-900">
{transaction.description ||
transaction.category?.nameRu ||
"Без описания"}
</p>
<div className="flex items-center gap-2 text-sm text-slate-500">
{transaction.category && (
<Badge variant="default" size="sm">
{transaction.category.nameRu}
</Badge>
)}
<span>
{formatDate(transaction.transactionDate)}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<p
className={`text-lg font-semibold ${
transaction.type === "INCOME"
? "text-emerald-600"
: "text-rose-600"
}`}
>
{transaction.type === "INCOME" ? "+" : "-"}
{formatMoney(transaction.amount)}
</p>
<div className="flex items-center gap-1">
<button
onClick={() => handleEdit(transaction)}
className="rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
onClick={() => setDeleteId(transaction.id)}
className="rounded-lg p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
<TransactionModal
isOpen={modalOpen}
onClose={handleModalClose}
transaction={editingTransaction}
onSuccess={handleSuccess}
/>
<ConfirmDialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={handleDelete}
title="Удалить транзакцию?"
message="Это действие нельзя отменить. Транзакция будет удалена навсегда."
confirmText="Удалить"
variant="danger"
isLoading={isDeleting}
/>
</div>
);
};
export default TransactionsPage;

493
src/pages/index.tsx Normal file
View File

@ -0,0 +1,493 @@
import { useMemo } from "react";
import { Link } from "react-router";
import { FeatureCard } from "../components/landing/FeatureCard";
import { FAQItem } from "../components/landing/FAQItem";
import { Section } from "../components/landing/Section";
const LandingPage = () => {
const navItems = useMemo(
() => [
{ label: "Возможности", href: "#features" },
{ label: "Как это работает", href: "#how" },
{ label: "Безопасность", href: "#security" },
{ label: "FAQ", href: "#faq" },
],
[],
);
const scrollToId = (id: string) => {
const el = document.getElementById(id);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<div className="pointer-events-none fixed inset-0 overflow-hidden">
<div className="absolute -top-40 left-1/2 h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-200 via-sky-200 to-emerald-200 blur-3xl opacity-60" />
<div className="absolute -bottom-48 -left-24 h-[520px] w-[520px] rounded-full bg-gradient-to-br from-emerald-200 via-sky-200 to-indigo-200 blur-3xl opacity-50" />
</div>
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<div
className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M5 12.5l4 4L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<span className="text-sm font-semibold tracking-tight sm:text-base">
Finance App
</span>
</div>
<nav
className="hidden items-center gap-6 md:flex"
aria-label="Навигация"
>
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
className="text-sm font-medium text-slate-600 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/40"
>
{item.label}
</a>
))}
</nav>
<div className="flex items-center gap-2 sm:gap-3">
<Link
to="/auth/login"
className="hidden h-10 items-center justify-center rounded-xl border border-slate-200 bg-white px-4 text-sm font-semibold text-slate-900 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/40 sm:inline-flex"
>
Войти
</Link>
<Link
to="/auth/register"
className="inline-flex h-10 items-center justify-center rounded-xl bg-slate-900 px-4 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/40"
>
Начать бесплатно
</Link>
</div>
</div>
</header>
<main className="relative">
<section aria-labelledby="hero-title">
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-14 sm:px-6 sm:pb-16 sm:pt-20 lg:px-8">
<div className="grid items-center gap-10 lg:grid-cols-12">
<div className="lg:col-span-6">
<h1
id="hero-title"
className="mt-5 text-balance text-4xl font-semibold tracking-tight text-slate-900 sm:text-5xl"
>
Контролируй деньги. Достигай целей. Спокойнее живи.
</h1>
<p className="mt-4 max-w-xl text-pretty text-base leading-7 text-slate-600 sm:text-lg">
Доходы и расходы, категории, бюджет 50/30/20, цели, аналитика
и рекомендации в одном месте. Быстрый старт и понятные
инсайты.
</p>
<div className="mt-7 flex flex-col gap-3 sm:flex-row sm:items-center">
<Link
to="/auth/register"
className="inline-flex h-12 items-center justify-center rounded-2xl bg-slate-900 px-6 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/40"
>
Начать бесплатно
</Link>
<button
type="button"
onClick={() => scrollToId("features")}
className="inline-flex h-12 items-center justify-center rounded-2xl border border-slate-200 bg-white px-6 text-sm font-semibold text-slate-900 shadow-sm hover:bg-slate-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-900/40"
>
Посмотреть возможности
</button>
</div>
<dl className="mt-10 grid grid-cols-3 gap-4">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<dt className="text-xs font-medium text-slate-500">
Время до старта
</dt>
<dd className="mt-1 text-lg font-semibold text-slate-900">
2 мин
</dd>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<dt className="text-xs font-medium text-slate-500">
Бюджет
</dt>
<dd className="mt-1 text-lg font-semibold text-slate-900">
50/30/20
</dd>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<dt className="text-xs font-medium text-slate-500">
Фокус
</dt>
<dd className="mt-1 text-lg font-semibold text-slate-900">
Цели
</dd>
</div>
</dl>
</div>
<div className="lg:col-span-6">
<div className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-lg backdrop-blur">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-slate-500">
Панель управления
</p>
<p className="mt-1 text-sm font-semibold text-slate-900">
Декабрь Обзор месяца
</p>
</div>
<div className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">
<span
className="h-2 w-2 rounded-full bg-emerald-600"
aria-hidden="true"
/>
Health Score: 82
</div>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-slate-500">
Доходы
</p>
<p className="mt-1 text-xl font-semibold text-slate-900">
185 400
</p>
<div className="mt-3 h-2 w-full rounded-full bg-slate-100">
<div className="h-2 w-[72%] rounded-full bg-emerald-500" />
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-slate-500">
Расходы
</p>
<p className="mt-1 text-xl font-semibold text-slate-900">
119 800
</p>
<div className="mt-3 h-2 w-full rounded-full bg-slate-100">
<div className="h-2 w-[58%] rounded-full bg-sky-500" />
</div>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-slate-500">
Сбережения
</p>
<p className="mt-1 text-xl font-semibold text-slate-900">
32 600
</p>
<p className="mt-1 text-xs text-slate-500">
+8% к прошлому месяцу
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-slate-500">
Прогресс целей
</p>
<p className="mt-1 text-xl font-semibold text-slate-900">
64%
</p>
<div className="mt-3 h-2 w-full rounded-full bg-slate-100">
<div className="h-2 w-[64%] rounded-full bg-indigo-500" />
</div>
</div>
</div>
<div className="mt-6 rounded-2xl border border-slate-200 bg-gradient-to-r from-slate-900 to-slate-800 p-4 text-white shadow-sm">
<p className="text-xs font-semibold text-white/70">
Рекомендации (скоро)
</p>
<p className="mt-2 text-sm leading-6">
Снизьте долю трат на подписки до 5% и направьте разницу в
цель «Подушка безопасности».
</p>
</div>
</div>
</div>
</div>
</div>
</section>
<Section
id="features"
title="Возможности, которые ведут к результату"
subtitle="Всё, что нужно для контроля бюджета и движения к целям — без лишней сложности."
>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<FeatureCard
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M8 7h8M8 12h5M6 3h12a2 2 0 0 1 2 2v14l-4-2-4 2-4-2-4 2V5a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
title="Доходы / Расходы"
description="Записывай транзакции быстро и получай понятную картину движения денег."
/>
<FeatureCard
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M4 7h16M7 4v6m10-6v6M6 11h12v9H6v-9Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
title="Категории"
description="Гибкая структура: еда, жильё, транспорт, подписки — всё на своих местах."
/>
<FeatureCard
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M12 3v18m9-9H3"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
}
title="Бюджеты 50/30/20"
description="Следи за потребностями, желаниями и сбережениями по популярной формуле."
/>
<FeatureCard
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M12 2l3 7h7l-5.5 4.3L18 21l-6-4-6 4 1.5-7.7L2 9h7l3-7Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
title="Финансовые цели"
description="Создавай цели и отслеживай прогресс: подушка безопасности, отпуск, техника."
/>
<FeatureCard
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M8 9h8M8 13h6M12 22a10 10 0 1 0-10-10c0 5.5 4.5 10 10 10Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
}
title="Рекомендации"
description="AI-style подсказки (плейсхолдер): что улучшить в бюджете и где оптимизировать."
/>
<FeatureCard
icon={
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M4 19V5m0 14h16M7 16l3-4 4 3 5-7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
title="Аналитика"
description="Обзор месяца, тренды, и понятный health score — чтобы видеть прогресс."
/>
</div>
</Section>
<Section
id="how"
title="Как это работает"
subtitle="Три простых шага — и у тебя есть понятный план и контроль над финансами."
className="bg-white/60"
>
<ol className="grid gap-4 md:grid-cols-3">
<li className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<p className="text-xs font-semibold text-slate-500">Шаг 1</p>
<h3 className="mt-2 text-base font-semibold text-slate-900">
Создай аккаунт
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Регистрация занимает минуты. Затем заходи в личный кабинет.
</p>
</li>
<li className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<p className="text-xs font-semibold text-slate-500">Шаг 2</p>
<h3 className="mt-2 text-base font-semibold text-slate-900">
Добавь транзакции / категории
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Заноси доходы и расходы и группируй их так, как удобно тебе.
</p>
</li>
<li className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<p className="text-xs font-semibold text-slate-500">Шаг 3</p>
<h3 className="mt-2 text-base font-semibold text-slate-900">
Получи бюджет, цели и аналитику
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Следи за 50/30/20, ростом целей и динамикой расходов по месяцам.
</p>
</li>
</ol>
</Section>
<Section
id="security"
title="Безопасность и приватность"
subtitle="Простая и честная политика: твои данные — это твои данные."
>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="text-base font-semibold text-slate-900">
Безопасно по умолчанию
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Сессии через HTTP-only cookies, JWT и ограничение доступа на
уровне API. Мы проектируем систему так, чтобы безопасное
поведение было стандартом.
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h3 className="text-base font-semibold text-slate-900">
Прозрачность
</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
Никаких лишних интеграций. Данные используются только для
расчётов бюджета, целей и аналитики внутри приложения.
</p>
</div>
</div>
</Section>
<Section
id="faq"
title="FAQ"
subtitle="Короткие ответы на частые вопросы."
className="bg-white/60"
>
<div className="grid gap-4 md:grid-cols-2">
<FAQItem
question="Это бесплатно?"
answer="Есть бесплатный старт: базовые функции доступны сразу. В будущем могут появиться расширенные планы — заранее предупредим."
/>
<FAQItem
question="Мои данные в безопасности?"
answer="Да. Используем HTTP-only cookies и JWT, а доступ к данным защищён авторизацией. Также стараемся хранить только то, что нужно для работы сервиса."
/>
<FAQItem
question="Можно ли использовать без банка?"
answer="Да. Это приложение для учёта: можно вносить транзакции вручную и вести бюджет без подключения банка."
/>
<FAQItem
question="Есть ли мобильное приложение?"
answer="Пока нет. Но интерфейс адаптивный и отлично работает на телефоне."
/>
<FAQItem
question="Как работает аналитика?"
answer="Мы считаем суммы по категориям, сравниваем периоды, строим тренды и выводим метрики (например health score) на основе твоих данных."
/>
</div>
</Section>
<section aria-label="Финальный призыв">
<div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 lg:px-8">
<div className="rounded-3xl bg-slate-900 p-8 text-white shadow-xl sm:p-10">
<div className="flex flex-col items-start justify-between gap-6 md:flex-row md:items-center">
<div>
<h2 className="text-balance text-2xl font-semibold tracking-tight sm:text-3xl">
Начни сегодня это займёт 2 минуты
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/80">
Создай аккаунт, добавь первые транзакции и получи понятный
бюджет, цели и аналитику.
</p>
</div>
<Link
to="/auth/register"
className="inline-flex h-12 items-center justify-center rounded-2xl bg-white px-6 text-sm font-semibold text-slate-900 shadow-sm hover:bg-slate-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
>
Создать аккаунт
</Link>
</div>
</div>
</div>
</section>
</main>
<footer className="border-t border-slate-200 bg-white/70 backdrop-blur">
<div className="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
<div className="flex flex-col gap-8 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
Finance App
</p>
<p className="mt-1 text-sm text-slate-600">
Личный финансовый контроль для спокойной жизни.
</p>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-2 text-sm">
<a
href="#features"
className="font-medium text-slate-600 hover:text-slate-900"
>
Возможности
</a>
<a
href="#security"
className="font-medium text-slate-600 hover:text-slate-900"
>
Безопасность
</a>
<a
href="#faq"
className="font-medium text-slate-600 hover:text-slate-900"
>
FAQ
</a>
</div>
</div>
<p className="mt-8 text-xs text-slate-500">
© {new Date().getFullYear()} Finance App. Все права защищены.
</p>
</div>
</footer>
</div>
);
};
export default LandingPage;

155
src/pages/login.tsx Normal file
View File

@ -0,0 +1,155 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router";
import { z } from "zod";
import { authLogin } from "../store/auth/aysnc-thunks";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { authStatusSelector } from "../store/auth/selectors";
import { useEffect } from "react";
import { AuthStatus } from "../interfaces/auth";
const schema = z.object({
email: z.email("Введите корректный email"),
password: z.string().min(6, "Минимум 6 символов"),
});
type LoginFormValues = z.infer<typeof schema>;
const LoginPage = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
resolver: zodResolver(schema),
defaultValues: {
email: "",
password: "",
},
mode: "onTouched",
});
const onSubmit = async (values: LoginFormValues) => {
dispatch(authLogin(values));
};
const authStatus = useAppSelector(authStatusSelector);
useEffect(() => {
if (authStatus === AuthStatus.Authorized) {
navigate("/dashboard");
}
}, [authStatus, navigate]);
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<div className="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-md">
<div className="mb-8">
<Link
to="/"
className="inline-flex items-center gap-3 text-sm font-semibold text-slate-900"
>
<span
className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M5 12.5l4 4L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
Finance App
</Link>
<h1 className="mt-5 text-balance text-3xl font-semibold tracking-tight">
Войти
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Введите email и пароль, чтобы открыть личный кабинет.
</p>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="mt-2 h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none focus:border-slate-400 focus:ring-2 focus:ring-slate-900/10"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="mt-2 text-sm text-rose-700">
{errors.email.message}
</p>
) : null}
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-slate-700"
>
Пароль
</label>
<input
id="password"
type="password"
autoComplete="current-password"
className="mt-2 h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none focus:border-slate-400 focus:ring-2 focus:ring-slate-900/10"
aria-invalid={Boolean(errors.password)}
aria-describedby={
errors.password ? "password-error" : undefined
}
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="mt-2 text-sm text-rose-700">
{errors.password.message}
</p>
) : null}
</div>
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-12 w-full items-center justify-center rounded-2xl bg-slate-900 px-6 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Входим..." : "Войти"}
</button>
</form>
<div className="mt-6 text-sm text-slate-600">
Нет аккаунта?{" "}
<Link
to="/auth/register"
className="font-semibold text-slate-900 hover:underline"
>
Начать бесплатно
</Link>
</div>
</div>
</div>
</div>
</div>
);
};
export default LoginPage;

View File

@ -0,0 +1,40 @@
import { useNavigate } from "react-router";
import { authStatusSelector } from "../store/auth/selectors";
import { useAppDispatch, useAppSelector } from "../store/hooks";
import { useEffect } from "react";
import { authRe } from "../store/auth/aysnc-thunks";
import { AuthStatus } from "../interfaces/auth";
import { Spinner } from "../components/spinner";
import { AppShell } from "../components/layout/AppShell";
const ProtectedLayout = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const authStatus = useAppSelector(authStatusSelector);
useEffect(() => {
dispatch(authRe());
}, [dispatch]);
useEffect(() => {
if (authStatus === AuthStatus.NotAuthorized) {
navigate("/auth/login");
}
}, [authStatus, navigate]);
if (authStatus === AuthStatus.None) {
return (
<div className="flex h-screen w-full items-center justify-center">
<Spinner />
</div>
);
}
if (authStatus === AuthStatus.Authorized) {
return <AppShell />;
}
return null;
};
export default ProtectedLayout;

216
src/pages/register.tsx Normal file
View File

@ -0,0 +1,216 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Link } from "react-router";
import { z } from "zod";
import { authRegister } from "../store/auth/aysnc-thunks";
import { useAppDispatch } from "../store/hooks";
const schema = z.object({
email: z.email("Введите корректный email"),
password: z.string().min(6, "Минимум 6 символов"),
firstName: z.string().trim().optional(),
lastName: z.string().trim().optional(),
phone: z
.string()
.trim()
.optional()
.refine(
(value) => {
if (!value) return true;
return /^[+]?[-0-9()\s]{7,}$/.test(value);
},
{ message: "Введите корректный номер телефона" },
),
});
type RegisterFormValues = z.infer<typeof schema>;
const RegisterPage = () => {
const dispatch = useAppDispatch();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormValues>({
resolver: zodResolver(schema),
defaultValues: {
email: "",
password: "",
firstName: "",
lastName: "",
phone: "",
},
mode: "onTouched",
});
const onSubmit = (values: RegisterFormValues) => {
dispatch(authRegister(values));
};
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<div className="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6 lg:px-8">
<div className="mx-auto w-full max-w-md">
<div className="mb-8">
<Link
to="/"
className="inline-flex items-center gap-3 text-sm font-semibold text-slate-900"
>
<span
className="flex h-9 w-9 items-center justify-center rounded-xl bg-slate-900 text-white"
aria-hidden="true"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M5 12.5l4 4L19 7.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
Finance App
</Link>
<h1 className="mt-5 text-balance text-3xl font-semibold tracking-tight">
Начать бесплатно
</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
Создайте аккаунт, чтобы настроить бюджет 50/30/20, цели и
аналитику.
</p>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-slate-700"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
className="mt-2 h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none focus:border-slate-400 focus:ring-2 focus:ring-slate-900/10"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email")}
/>
{errors.email ? (
<p id="email-error" className="mt-2 text-sm text-rose-700">
{errors.email.message}
</p>
) : null}
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-slate-700"
>
Пароль
</label>
<input
id="password"
type="password"
autoComplete="new-password"
className="mt-2 h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none focus:border-slate-400 focus:ring-2 focus:ring-slate-900/10"
aria-invalid={Boolean(errors.password)}
aria-describedby={
errors.password ? "password-error" : undefined
}
{...register("password")}
/>
{errors.password ? (
<p id="password-error" className="mt-2 text-sm text-rose-700">
{errors.password.message}
</p>
) : null}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label
htmlFor="firstName"
className="block text-sm font-medium text-slate-700"
>
Имя (необязательно)
</label>
<input
id="firstName"
type="text"
autoComplete="given-name"
className="mt-2 h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none focus:border-slate-400 focus:ring-2 focus:ring-slate-900/10"
{...register("firstName")}
/>
</div>
<div>
<label
htmlFor="lastName"
className="block text-sm font-medium text-slate-700"
>
Фамилия (необязательно)
</label>
<input
id="lastName"
type="text"
autoComplete="family-name"
className="mt-2 h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none focus:border-slate-400 focus:ring-2 focus:ring-slate-900/10"
{...register("lastName")}
/>
</div>
</div>
<div>
<label
htmlFor="phone"
className="block text-sm font-medium text-slate-700"
>
Телефон (необязательно)
</label>
<input
id="phone"
type="tel"
autoComplete="tel"
className="mt-2 h-12 w-full rounded-2xl border border-slate-200 bg-white px-4 text-sm shadow-sm outline-none focus:border-slate-400 focus:ring-2 focus:ring-slate-900/10"
aria-invalid={Boolean(errors.phone)}
aria-describedby={errors.phone ? "phone-error" : undefined}
{...register("phone")}
/>
{errors.phone ? (
<p id="phone-error" className="mt-2 text-sm text-rose-700">
{errors.phone.message}
</p>
) : null}
</div>
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-12 w-full items-center justify-center rounded-2xl bg-slate-900 px-6 text-sm font-semibold text-white shadow-sm hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Создаём аккаунт..." : "Создать аккаунт"}
</button>
</form>
<div className="mt-6 text-sm text-slate-600">
Уже есть аккаунт?{" "}
<Link
to="/auth/login"
className="font-semibold text-slate-900 hover:underline"
>
Войти
</Link>
</div>
</div>
</div>
</div>
</div>
);
};
export default RegisterPage;

18
src/request-instance.ts Normal file
View File

@ -0,0 +1,18 @@
import axios, { type CreateAxiosDefaults } from "axios";
import { API_HOST } from "./constants";
const config: CreateAxiosDefaults = {
baseURL: `${API_HOST}/api/v1/`,
withCredentials: true,
paramsSerializer: {
indexes: null,
},
};
export const requestInstance = axios.create(config);
requestInstance.interceptors.request.use((requestConfig) => {
const modifiedConfig = { ...requestConfig };
return modifiedConfig;
});

49
src/router.tsx Normal file
View File

@ -0,0 +1,49 @@
import type { RouteObject } from "react-router";
import LandingPage from "./pages";
import { lazy } from "react";
const LoginPage = lazy(() => import("./pages/login"));
const RegisterPage = lazy(() => import("./pages/register"));
const ProtectedLayout = lazy(() => import("./pages/protected-layout"));
const DashboardPage = lazy(() => import("./pages/dashboard/index"));
const TransactionsPage = lazy(() => import("./pages/dashboard/transactions"));
const CategoriesPage = lazy(() => import("./pages/dashboard/categories"));
const BudgetsPage = lazy(() => import("./pages/dashboard/budgets"));
const GoalsPage = lazy(() => import("./pages/dashboard/goals"));
const RecommendationsPage = lazy(
() => import("./pages/dashboard/recommendations"),
);
const AnalyticsPage = lazy(() => import("./pages/dashboard/analytics"));
const SettingsPage = lazy(() => import("./pages/dashboard/settings"));
export const routes: RouteObject[] = [
{
element: <LandingPage />,
path: "/",
},
{
element: <LoginPage />,
path: "/auth/login",
},
{
element: <RegisterPage />,
path: "/auth/register",
},
{
path: "/dashboard",
element: <ProtectedLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "transactions", element: <TransactionsPage /> },
{ path: "categories", element: <CategoriesPage /> },
{ path: "budgets", element: <BudgetsPage /> },
{ path: "goals", element: <GoalsPage /> },
{ path: "recommendations", element: <RecommendationsPage /> },
{ path: "analytics", element: <AnalyticsPage /> },
{ path: "settings", element: <SettingsPage /> },
],
},
];
export default routes;

View File

@ -0,0 +1,40 @@
import {
analyticsOverviewApi,
analyticsTrendsApi,
analyticsCategoryBreakdownApi,
analyticsIncomeVsExpensesApi,
analyticsFinancialHealthApi,
analyticsYearlySummaryApi,
} from "../../api/analytics";
import { analytics } from "../../constants/analytics";
import { createAppAsyncThunk } from "../create-app-async-thunk";
export const analyticsOverview = createAppAsyncThunk(
`${analytics}/overview`,
analyticsOverviewApi,
);
export const analyticsTrends = createAppAsyncThunk(
`${analytics}/trends`,
analyticsTrendsApi,
);
export const analyticsCategoryBreakdown = createAppAsyncThunk(
`${analytics}/categoryBreakdown`,
analyticsCategoryBreakdownApi,
);
export const analyticsIncomeVsExpenses = createAppAsyncThunk(
`${analytics}/incomeVsExpenses`,
analyticsIncomeVsExpensesApi,
);
export const analyticsFinancialHealth = createAppAsyncThunk(
`${analytics}/financialHealth`,
analyticsFinancialHealthApi,
);
export const analyticsYearlySummary = createAppAsyncThunk(
`${analytics}/yearlySummary`,
analyticsYearlySummaryApi,
);

View File

@ -0,0 +1,61 @@
import { createSlice, isAnyOf } from "@reduxjs/toolkit";
import { analytics, analyticsInitialState } from "../../constants/analytics";
import {
analyticsOverview,
analyticsTrends,
analyticsCategoryBreakdown,
analyticsFinancialHealth,
} from "./async-thunks";
export const analyticsSlice = createSlice({
name: analytics,
initialState: analyticsInitialState,
reducers: {},
extraReducers: ({ addCase, addMatcher }) => {
addCase(analyticsOverview.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.overview = action.payload.data;
});
addCase(analyticsTrends.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.trends = action.payload.data;
});
addCase(analyticsCategoryBreakdown.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.categoryBreakdown = action.payload.data;
});
addCase(analyticsFinancialHealth.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.financialHealth = action.payload.data;
});
addMatcher(
isAnyOf(
analyticsOverview.pending,
analyticsTrends.pending,
analyticsCategoryBreakdown.pending,
analyticsFinancialHealth.pending,
),
(draft) => {
draft.isLoading = true;
draft.error = undefined;
},
);
addMatcher(
isAnyOf(
analyticsOverview.rejected,
analyticsTrends.rejected,
analyticsCategoryBreakdown.rejected,
analyticsFinancialHealth.rejected,
),
(draft, action) => {
draft.isLoading = false;
draft.error = action.error.message || "Произошла ошибка";
},
);
},
});

View File

@ -0,0 +1,30 @@
import type {
AnalyticsOverview,
TrendDataPoint,
CategoryBreakdown,
FinancialHealth,
} from "../../interfaces/analytics";
import type { RootSelector } from "../types";
export const analyticsOverviewSelector: RootSelector<
AnalyticsOverview | null
> = (state) => state.analytics.overview;
export const analyticsTrendsSelector: RootSelector<TrendDataPoint[]> = (
state,
) => state.analytics.trends;
export const analyticsCategoryBreakdownSelector: RootSelector<
CategoryBreakdown[]
> = (state) => state.analytics.categoryBreakdown;
export const analyticsFinancialHealthSelector: RootSelector<
FinancialHealth | null
> = (state) => state.analytics.financialHealth;
export const analyticsLoadingSelector: RootSelector<boolean> = (state) =>
state.analytics.isLoading;
export const analyticsErrorSelector: RootSelector<string | undefined> = (
state,
) => state.analytics.error;

View File

@ -0,0 +1,17 @@
import { authLoginApi, authReApi, authRegisterApi } from "../../api/auth";
import { auth } from "../../constants/auth";
import { createAppAsyncThunk } from "../../store/create-app-async-thunk";
export const authLogin = createAppAsyncThunk(`${auth}/authLogin`, authLoginApi);
export const authRegister = createAppAsyncThunk(
`${auth}/authRegister`,
authRegisterApi,
);
// export const authLogout = createAppAsyncThunk(
// `${auth}/authLogout`,
// authLogoutApi
// );
export const authRe = createAppAsyncThunk(`${auth}/authRe`, authReApi);

40
src/store/auth/index.ts Normal file
View File

@ -0,0 +1,40 @@
import { type PayloadAction, createSlice, isAnyOf } from "@reduxjs/toolkit";
import { auth, authInitialState } from "../../constants/auth";
import { authLogin, authRe, authRegister } from "./aysnc-thunks";
import { AuthStage, AuthStatus } from "../../interfaces/auth";
export const authSlice = createSlice({
name: auth,
initialState: authInitialState,
reducers: {
changeAuthStage: (draft, { payload }: PayloadAction<AuthStage>) => {
draft.stage = payload;
},
changeAuthStatus: (draft, { payload }: PayloadAction<AuthStatus>) => {
draft.authStatus = payload;
},
},
extraReducers: ({ addCase, addMatcher }) => {
addCase(authLogin.rejected, (draft) => {
draft.authStatus = AuthStatus.NotAuthorized;
draft.error = "Неверный логин или пароль";
});
addCase(authRegister.rejected, (draft) => {
draft.authStatus = AuthStatus.NotAuthorized;
draft.error = "Не удалось зарегистрироваться";
});
addCase(authRe.rejected, (draft) => {
draft.authStatus = AuthStatus.NotAuthorized;
});
addMatcher(
isAnyOf(authLogin.fulfilled, authRe.fulfilled, authRegister.fulfilled),
(draft) => {
draft.authStatus = AuthStatus.Authorized;
},
);
},
});

View File

View File

@ -0,0 +1,11 @@
import type { AuthStage, AuthStatus } from "../../interfaces/auth";
import { type RootSelector } from "../../store/types";
export const authStatusSelector: RootSelector<AuthStatus> = (state) =>
state.auth.authStatus;
export const authStageSelector: RootSelector<AuthStage> = (state) =>
state.auth.stage;
export const authErrorSelector: RootSelector<string | undefined> = (state) =>
state.auth.error;

View File

@ -0,0 +1,46 @@
import {
budgetsListApi,
budgetsGetCurrentApi,
budgetsGetByMonthApi,
budgetsCreateApi,
budgetsUpdateApi,
budgetsDeleteApi,
budgetsProgressApi,
} from "../../api/budgets";
import { budgets } from "../../constants/budgets";
import { createAppAsyncThunk } from "../create-app-async-thunk";
export const budgetsList = createAppAsyncThunk(
`${budgets}/list`,
budgetsListApi,
);
export const budgetsGetCurrent = createAppAsyncThunk(
`${budgets}/getCurrent`,
budgetsGetCurrentApi,
);
export const budgetsGetByMonth = createAppAsyncThunk(
`${budgets}/getByMonth`,
budgetsGetByMonthApi,
);
export const budgetsCreate = createAppAsyncThunk(
`${budgets}/create`,
budgetsCreateApi,
);
export const budgetsUpdate = createAppAsyncThunk(
`${budgets}/update`,
budgetsUpdateApi,
);
export const budgetsDelete = createAppAsyncThunk(
`${budgets}/delete`,
budgetsDeleteApi,
);
export const budgetsProgress = createAppAsyncThunk(
`${budgets}/progress`,
budgetsProgressApi,
);

View File

@ -0,0 +1,95 @@
import { createSlice, isAnyOf } from "@reduxjs/toolkit";
import { budgets, budgetsInitialState } from "../../constants/budgets";
import {
budgetsList,
budgetsGetCurrent,
budgetsGetByMonth,
budgetsCreate,
budgetsUpdate,
budgetsDelete,
budgetsProgress,
} from "./async-thunks";
export const budgetsSlice = createSlice({
name: budgets,
initialState: budgetsInitialState,
reducers: {},
extraReducers: ({ addCase, addMatcher }) => {
addCase(budgetsList.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.budgets = action.payload.data;
});
addCase(budgetsGetCurrent.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.currentBudget = action.payload?.data ?? null;
});
addCase(budgetsGetByMonth.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.currentBudget = action.payload.data;
});
addCase(budgetsCreate.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.budgets.push(action.payload.data);
draft.currentBudget = action.payload.data;
});
addCase(budgetsUpdate.fulfilled, (draft, action) => {
draft.isLoading = false;
const index = draft.budgets.findIndex(
(b) => b.id === action.payload.data.id,
);
if (index !== -1) {
draft.budgets[index] = action.payload.data;
}
draft.currentBudget = action.payload.data;
});
addCase(budgetsDelete.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.budgets = draft.budgets.filter((b) => b.month !== action.meta.arg);
if (draft.currentBudget?.month === action.meta.arg) {
draft.currentBudget = null;
}
});
addCase(budgetsProgress.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.progress = action.payload.data;
});
addMatcher(
isAnyOf(
budgetsList.pending,
budgetsGetCurrent.pending,
budgetsGetByMonth.pending,
budgetsCreate.pending,
budgetsUpdate.pending,
budgetsDelete.pending,
budgetsProgress.pending,
),
(draft) => {
draft.isLoading = true;
draft.error = undefined;
},
);
addMatcher(
isAnyOf(
budgetsList.rejected,
budgetsGetCurrent.rejected,
budgetsGetByMonth.rejected,
budgetsCreate.rejected,
budgetsUpdate.rejected,
budgetsDelete.rejected,
budgetsProgress.rejected,
),
(draft, action) => {
draft.isLoading = false;
draft.error = action.error.message || "Произошла ошибка";
},
);
},
});

View File

@ -0,0 +1,18 @@
import type { Budget, BudgetProgressResponse } from "../../interfaces/budgets";
import type { RootSelector } from "../types";
export const budgetsSelector: RootSelector<Budget[]> = (state) =>
state.budgets.budgets;
export const budgetsCurrentSelector: RootSelector<Budget | null> = (state) =>
state.budgets.currentBudget;
export const budgetsProgressSelector: RootSelector<
BudgetProgressResponse | null
> = (state) => state.budgets.progress;
export const budgetsLoadingSelector: RootSelector<boolean> = (state) =>
state.budgets.isLoading;
export const budgetsErrorSelector: RootSelector<string | undefined> = (state) =>
state.budgets.error;

View File

@ -0,0 +1,40 @@
import {
categoriesListApi,
categoriesGetApi,
categoriesGetGroupedApi,
categoriesCreateApi,
categoriesUpdateApi,
categoriesDeleteApi,
} from "../../api/categories";
import { categories } from "../../constants/categories";
import { createAppAsyncThunk } from "../create-app-async-thunk";
export const categoriesList = createAppAsyncThunk(
`${categories}/list`,
categoriesListApi,
);
export const categoriesGet = createAppAsyncThunk(
`${categories}/get`,
categoriesGetApi,
);
export const categoriesGetGrouped = createAppAsyncThunk(
`${categories}/getGrouped`,
categoriesGetGroupedApi,
);
export const categoriesCreate = createAppAsyncThunk(
`${categories}/create`,
categoriesCreateApi,
);
export const categoriesUpdate = createAppAsyncThunk(
`${categories}/update`,
categoriesUpdateApi,
);
export const categoriesDelete = createAppAsyncThunk(
`${categories}/delete`,
categoriesDeleteApi,
);

View File

@ -0,0 +1,76 @@
import { createSlice, isAnyOf } from "@reduxjs/toolkit";
import { categories, categoriesInitialState } from "../../constants/categories";
import {
categoriesList,
categoriesGetGrouped,
categoriesCreate,
categoriesUpdate,
categoriesDelete,
} from "./async-thunks";
export const categoriesSlice = createSlice({
name: categories,
initialState: categoriesInitialState,
reducers: {},
extraReducers: ({ addCase, addMatcher }) => {
addCase(categoriesList.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.categories = action.payload.data;
});
addCase(categoriesGetGrouped.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.grouped = action.payload.data;
});
addCase(categoriesCreate.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.categories.push(action.payload.data);
});
addCase(categoriesUpdate.fulfilled, (draft, action) => {
draft.isLoading = false;
const index = draft.categories.findIndex(
(c) => c.id === action.payload.data.id,
);
if (index !== -1) {
draft.categories[index] = action.payload.data;
}
});
addCase(categoriesDelete.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.categories = draft.categories.filter(
(c) => c.id !== action.meta.arg,
);
});
addMatcher(
isAnyOf(
categoriesList.pending,
categoriesGetGrouped.pending,
categoriesCreate.pending,
categoriesUpdate.pending,
categoriesDelete.pending,
),
(draft) => {
draft.isLoading = true;
draft.error = undefined;
},
);
addMatcher(
isAnyOf(
categoriesList.rejected,
categoriesGetGrouped.rejected,
categoriesCreate.rejected,
categoriesUpdate.rejected,
categoriesDelete.rejected,
),
(draft, action) => {
draft.isLoading = false;
draft.error = action.error.message || "Произошла ошибка";
},
);
},
});

View File

@ -0,0 +1,16 @@
import type { Category, GroupedCategories } from "../../interfaces/categories";
import type { RootSelector } from "../types";
export const categoriesSelector: RootSelector<Category[]> = (state) =>
state.categories.categories;
export const categoriesGroupedSelector: RootSelector<
GroupedCategories | null
> = (state) => state.categories.grouped;
export const categoriesLoadingSelector: RootSelector<boolean> = (state) =>
state.categories.isLoading;
export const categoriesErrorSelector: RootSelector<string | undefined> = (
state,
) => state.categories.error;

View File

@ -0,0 +1,26 @@
import {
type AsyncThunk,
type AsyncThunkPayloadCreator,
createAsyncThunk,
} from "@reduxjs/toolkit";
import { type AsyncThunkConfig } from "./types";
export const createAppAsyncThunk = <Returned, ThunkArgument = void>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<
Returned,
ThunkArgument,
AsyncThunkConfig
>,
): AsyncThunk<Returned, ThunkArgument, AsyncThunkConfig> =>
createAsyncThunk<Returned, ThunkArgument, AsyncThunkConfig>(
typePrefix,
// @ts-expect-error Ошибка типов, не влияет на работу
async (params: ThunkArgument, thunkApi) => {
try {
return await payloadCreator(params, thunkApi);
} catch (error) {
return thunkApi.rejectWithValue(error);
}
},
);

View File

@ -0,0 +1,53 @@
import {
type ConfigureStoreOptions,
combineReducers,
configureStore,
} from "@reduxjs/toolkit";
import { listenerMiddleware } from "./listener-middleware";
import { type ThunkExtraArgument } from "./types";
import { requestInstance } from "../request-instance";
import "../store/auth/middlewares";
import { loadingSlice } from "./loading";
import { authSlice } from "./auth";
import { categoriesSlice } from "./categories";
import { transactionsSlice } from "./transactions";
import { budgetsSlice } from "./budgets";
import { goalsSlice } from "./goals";
import { recommendationsSlice } from "./recommendations";
import { analyticsSlice } from "./analytics";
const reducers = combineReducers({
[loadingSlice.name]: loadingSlice.reducer,
[authSlice.name]: authSlice.reducer,
[categoriesSlice.name]: categoriesSlice.reducer,
[transactionsSlice.name]: transactionsSlice.reducer,
[budgetsSlice.name]: budgetsSlice.reducer,
[goalsSlice.name]: goalsSlice.reducer,
[recommendationsSlice.name]: recommendationsSlice.reducer,
[analyticsSlice.name]: analyticsSlice.reducer,
});
export const createAsyncStore = ({
preloadedState,
requestConfig,
}: Pick<ConfigureStoreOptions, "preloadedState"> & ThunkExtraArgument = {}) => {
const store = configureStore({
preloadedState,
devTools: true,
reducer: reducers,
middleware: (getDefaultMiddleware) => {
const middleware = getDefaultMiddleware({
thunk: { extraArgument: requestConfig },
});
return middleware.concat(listenerMiddleware.middleware);
},
});
requestInstance.interceptors.response.use(
(response) => response,
async (error) => Promise.reject(error),
);
return { store };
};

View File

@ -0,0 +1,52 @@
import {
goalsListApi,
goalsGetApi,
goalsCreateApi,
goalsUpdateApi,
goalsDeleteApi,
goalsAddFundsApi,
goalsWithdrawApi,
goalsSummaryApi,
goalsUpcomingApi,
} from "../../api/goals";
import { goals } from "../../constants/goals";
import { createAppAsyncThunk } from "../create-app-async-thunk";
export const goalsList = createAppAsyncThunk(`${goals}/list`, goalsListApi);
export const goalsGet = createAppAsyncThunk(`${goals}/get`, goalsGetApi);
export const goalsCreate = createAppAsyncThunk(
`${goals}/create`,
goalsCreateApi,
);
export const goalsUpdate = createAppAsyncThunk(
`${goals}/update`,
goalsUpdateApi,
);
export const goalsDelete = createAppAsyncThunk(
`${goals}/delete`,
goalsDeleteApi,
);
export const goalsAddFunds = createAppAsyncThunk(
`${goals}/addFunds`,
goalsAddFundsApi,
);
export const goalsWithdraw = createAppAsyncThunk(
`${goals}/withdraw`,
goalsWithdrawApi,
);
export const goalsSummary = createAppAsyncThunk(
`${goals}/summary`,
goalsSummaryApi,
);
export const goalsUpcoming = createAppAsyncThunk(
`${goals}/upcoming`,
goalsUpcomingApi,
);

108
src/store/goals/index.ts Normal file
View File

@ -0,0 +1,108 @@
import { createSlice, isAnyOf } from "@reduxjs/toolkit";
import { goals, goalsInitialState } from "../../constants/goals";
import {
goalsList,
goalsCreate,
goalsUpdate,
goalsDelete,
goalsAddFunds,
goalsWithdraw,
goalsSummary,
goalsUpcoming,
} from "./async-thunks";
export const goalsSlice = createSlice({
name: goals,
initialState: goalsInitialState,
reducers: {},
extraReducers: ({ addCase, addMatcher }) => {
addCase(goalsList.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.goals = action.payload.data;
});
addCase(goalsCreate.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.goals.push(action.payload.data);
});
addCase(goalsUpdate.fulfilled, (draft, action) => {
draft.isLoading = false;
const index = draft.goals.findIndex(
(g) => g.id === action.payload.data.id,
);
if (index !== -1) {
draft.goals[index] = action.payload.data;
}
});
addCase(goalsDelete.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.goals = draft.goals.filter((g) => g.id !== action.meta.arg);
});
addCase(goalsAddFunds.fulfilled, (draft, action) => {
draft.isLoading = false;
const index = draft.goals.findIndex(
(g) => g.id === action.payload.data.id,
);
if (index !== -1) {
draft.goals[index] = action.payload.data;
}
});
addCase(goalsWithdraw.fulfilled, (draft, action) => {
draft.isLoading = false;
const index = draft.goals.findIndex(
(g) => g.id === action.payload.data.id,
);
if (index !== -1) {
draft.goals[index] = action.payload.data;
}
});
addCase(goalsSummary.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.summary = action.payload.data;
});
addCase(goalsUpcoming.fulfilled, (draft, action) => {
draft.isLoading = false;
draft.upcoming = action.payload.data;
});
addMatcher(
isAnyOf(
goalsList.pending,
goalsCreate.pending,
goalsUpdate.pending,
goalsDelete.pending,
goalsAddFunds.pending,
goalsWithdraw.pending,
goalsSummary.pending,
goalsUpcoming.pending,
),
(draft) => {
draft.isLoading = true;
draft.error = undefined;
},
);
addMatcher(
isAnyOf(
goalsList.rejected,
goalsCreate.rejected,
goalsUpdate.rejected,
goalsDelete.rejected,
goalsAddFunds.rejected,
goalsWithdraw.rejected,
goalsSummary.rejected,
goalsUpcoming.rejected,
),
(draft, action) => {
draft.isLoading = false;
draft.error = action.error.message || "Произошла ошибка";
},
);
},
});

View File

@ -0,0 +1,17 @@
import type { Goal, GoalsSummary } from "../../interfaces/goals";
import type { RootSelector } from "../types";
export const goalsSelector: RootSelector<Goal[]> = (state) => state.goals.goals;
export const goalsSummarySelector: RootSelector<GoalsSummary | null> = (
state,
) => state.goals.summary;
export const goalsUpcomingSelector: RootSelector<Goal[]> = (state) =>
state.goals.upcoming;
export const goalsLoadingSelector: RootSelector<boolean> = (state) =>
state.goals.isLoading;
export const goalsErrorSelector: RootSelector<string | undefined> = (state) =>
state.goals.error;

6
src/store/hooks.ts Normal file
View File

@ -0,0 +1,6 @@
import { useDispatch, useSelector } from "react-redux";
import type { TypedUseSelectorHook } from "react-redux";
import type { RootState, AppDispatch } from "./types";
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

3
src/store/index.ts Normal file
View File

@ -0,0 +1,3 @@
import { createAsyncStore } from "./create-async-store";
export const asyncStore = createAsyncStore();

View File

@ -0,0 +1,10 @@
import { createListenerMiddleware } from "@reduxjs/toolkit";
import type { TypedStartListening } from "@reduxjs/toolkit";
import type { AppDispatch, RootState } from "./types";
export const listenerMiddleware = createListenerMiddleware();
export type AppStartListening = TypedStartListening<RootState, AppDispatch>;
export const startAppListening =
listenerMiddleware.startListening as AppStartListening;

Some files were not shown because too many files have changed in this diff Show More