This commit is contained in:
commit
06c5887fd8
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_API_HOST=http://localhost:3000
|
||||
NODE_ENV=development
|
||||
2
.env.example
Normal file
2
.env.example
Normal 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
1
.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_HOST=https://api-finance.ai-assistant-bot.xyz
|
||||
24
.gitea/workflows/deploy.yml
Normal file
24
.gitea/workflows/deploy.yml
Normal 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
24
.gitignore
vendored
Normal 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
37
Dockerfile
Normal 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
241
FRONTEND_FUNCTIONS.md
Normal 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
73
README.md
Normal 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
24
docker-compose.server.yml
Normal 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
23
eslint.config.js
Normal 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
13
index.html
Normal 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
29
nginx.conf
Normal 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
4326
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal 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
1
public/vite.svg
Normal 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
31
src/api/analytics.ts
Normal 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
18
src/api/auth.ts
Normal 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
31
src/api/budgets.ts
Normal 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
27
src/api/categories.ts
Normal 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
43
src/api/goals.ts
Normal 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;
|
||||
39
src/api/parse-api-error.ts
Normal file
39
src/api/parse-api-error.ts
Normal 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
32
src/api/profile.ts
Normal 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");
|
||||
};
|
||||
32
src/api/recommendations.ts
Normal file
32
src/api/recommendations.ts
Normal 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
39
src/api/transactions.ts
Normal 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
1
src/assets/react.svg
Normal 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
3
src/browser-router.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createBrowserRouter } from "react-router";
|
||||
import routes from "./router";
|
||||
export const browserRouter = createBrowserRouter(routes);
|
||||
30
src/components/landing/FAQItem.tsx
Normal file
30
src/components/landing/FAQItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/components/landing/FeatureCard.tsx
Normal file
23
src/components/landing/FeatureCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/components/landing/Section.tsx
Normal file
38
src/components/landing/Section.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/components/layout/AppShell.tsx
Normal file
32
src/components/layout/AppShell.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
205
src/components/layout/Sidebar.tsx
Normal file
205
src/components/layout/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
48
src/components/layout/Topbar.tsx
Normal file
48
src/components/layout/Topbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
src/components/loading-component/index.tsx
Normal file
35
src/components/loading-component/index.tsx
Normal 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>;
|
||||
};
|
||||
21
src/components/spinner/index.tsx
Normal file
21
src/components/spinner/index.tsx
Normal 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>
|
||||
);
|
||||
224
src/components/transactions/TransactionModal.tsx
Normal file
224
src/components/transactions/TransactionModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
34
src/components/ui/Badge.tsx
Normal file
34
src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
65
src/components/ui/Button.tsx
Normal file
65
src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
src/components/ui/ConfirmDialog.tsx
Normal file
55
src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
src/components/ui/EmptyState.tsx
Normal file
30
src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
48
src/components/ui/Input.tsx
Normal file
48
src/components/ui/Input.tsx
Normal 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";
|
||||
84
src/components/ui/Modal.tsx
Normal file
84
src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
src/components/ui/PageHeader.tsx
Normal file
27
src/components/ui/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
57
src/components/ui/ProgressBar.tsx
Normal file
57
src/components/ui/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
src/components/ui/Select.tsx
Normal file
60
src/components/ui/Select.tsx
Normal 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";
|
||||
50
src/components/ui/Skeleton.tsx
Normal file
50
src/components/ui/Skeleton.tsx
Normal 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>
|
||||
);
|
||||
53
src/components/ui/StatCard.tsx
Normal file
53
src/components/ui/StatCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
src/components/ui/index.ts
Normal file
16
src/components/ui/index.ts
Normal 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
3
src/constants.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const API_HOST = import.meta.env.VITE_API_HOST;
|
||||
|
||||
export const appRootId = "app" as const;
|
||||
12
src/constants/analytics.ts
Normal file
12
src/constants/analytics.ts
Normal 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
13
src/constants/auth.ts
Normal 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
11
src/constants/budgets.ts
Normal 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,
|
||||
};
|
||||
10
src/constants/categories.ts
Normal file
10
src/constants/categories.ts
Normal 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
11
src/constants/goals.ts
Normal 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,
|
||||
};
|
||||
10
src/constants/recommendations.ts
Normal file
10
src/constants/recommendations.ts
Normal 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,
|
||||
};
|
||||
12
src/constants/transactions.ts
Normal file
12
src/constants/transactions.ts
Normal 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
1
src/index.css
Normal file
@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
98
src/interfaces/analytics.ts
Normal file
98
src/interfaces/analytics.ts
Normal 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
71
src/interfaces/auth.ts
Normal 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
78
src/interfaces/budgets.ts
Normal 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>;
|
||||
77
src/interfaces/categories.ts
Normal file
77
src/interfaces/categories.ts
Normal 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
110
src/interfaces/goals.ts
Normal 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>;
|
||||
83
src/interfaces/recommendations.ts
Normal file
83
src/interfaces/recommendations.ts
Normal 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>;
|
||||
127
src/interfaces/transactions.ts
Normal file
127
src/interfaces/transactions.ts
Normal 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
16
src/main.tsx
Normal 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
0
src/pages/dashboard.tsx
Normal file
256
src/pages/dashboard/analytics.tsx
Normal file
256
src/pages/dashboard/analytics.tsx
Normal 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;
|
||||
454
src/pages/dashboard/budgets.tsx
Normal file
454
src/pages/dashboard/budgets.tsx
Normal 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;
|
||||
358
src/pages/dashboard/categories.tsx
Normal file
358
src/pages/dashboard/categories.tsx
Normal 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;
|
||||
534
src/pages/dashboard/goals.tsx
Normal file
534
src/pages/dashboard/goals.tsx
Normal 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;
|
||||
340
src/pages/dashboard/index.tsx
Normal file
340
src/pages/dashboard/index.tsx
Normal 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;
|
||||
280
src/pages/dashboard/recommendations.tsx
Normal file
280
src/pages/dashboard/recommendations.tsx
Normal 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;
|
||||
279
src/pages/dashboard/settings.tsx
Normal file
279
src/pages/dashboard/settings.tsx
Normal 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;
|
||||
404
src/pages/dashboard/transactions.tsx
Normal file
404
src/pages/dashboard/transactions.tsx
Normal 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
493
src/pages/index.tsx
Normal 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
155
src/pages/login.tsx
Normal 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;
|
||||
40
src/pages/protected-layout.tsx
Normal file
40
src/pages/protected-layout.tsx
Normal 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
216
src/pages/register.tsx
Normal 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
18
src/request-instance.ts
Normal 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
49
src/router.tsx
Normal 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;
|
||||
40
src/store/analytics/async-thunks.ts
Normal file
40
src/store/analytics/async-thunks.ts
Normal 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,
|
||||
);
|
||||
61
src/store/analytics/index.ts
Normal file
61
src/store/analytics/index.ts
Normal 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 || "Произошла ошибка";
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
30
src/store/analytics/selectors.ts
Normal file
30
src/store/analytics/selectors.ts
Normal 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;
|
||||
17
src/store/auth/aysnc-thunks.ts
Normal file
17
src/store/auth/aysnc-thunks.ts
Normal 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
40
src/store/auth/index.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
0
src/store/auth/middlewares.ts
Normal file
0
src/store/auth/middlewares.ts
Normal file
11
src/store/auth/selectors.ts
Normal file
11
src/store/auth/selectors.ts
Normal 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;
|
||||
46
src/store/budgets/async-thunks.ts
Normal file
46
src/store/budgets/async-thunks.ts
Normal 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,
|
||||
);
|
||||
95
src/store/budgets/index.ts
Normal file
95
src/store/budgets/index.ts
Normal 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 || "Произошла ошибка";
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
18
src/store/budgets/selectors.ts
Normal file
18
src/store/budgets/selectors.ts
Normal 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;
|
||||
40
src/store/categories/async-thunks.ts
Normal file
40
src/store/categories/async-thunks.ts
Normal 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,
|
||||
);
|
||||
76
src/store/categories/index.ts
Normal file
76
src/store/categories/index.ts
Normal 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 || "Произошла ошибка";
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
16
src/store/categories/selectors.ts
Normal file
16
src/store/categories/selectors.ts
Normal 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;
|
||||
26
src/store/create-app-async-thunk.ts
Normal file
26
src/store/create-app-async-thunk.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
);
|
||||
53
src/store/create-async-store.ts
Normal file
53
src/store/create-async-store.ts
Normal 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 };
|
||||
};
|
||||
52
src/store/goals/async-thunks.ts
Normal file
52
src/store/goals/async-thunks.ts
Normal 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
108
src/store/goals/index.ts
Normal 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 || "Произошла ошибка";
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
17
src/store/goals/selectors.ts
Normal file
17
src/store/goals/selectors.ts
Normal 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
6
src/store/hooks.ts
Normal 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
3
src/store/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { createAsyncStore } from "./create-async-store";
|
||||
|
||||
export const asyncStore = createAsyncStore();
|
||||
10
src/store/listener-middleware.ts
Normal file
10
src/store/listener-middleware.ts
Normal 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
Loading…
Reference in New Issue
Block a user