Merge pull request 'feat: Модуль Финансы + Трекер + CI/CD' (#1) from dev into main
All checks were successful
Deploy Production / deploy (push) Successful in 26s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-03-01 05:14:59 +00:00
27 changed files with 3722 additions and 50 deletions

View File

@@ -25,7 +25,3 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Deploy to dev
run: |
echo "Build successful - dev deploy would happen via docker"

View File

@@ -19,7 +19,3 @@ jobs:
- name: Build - name: Build
run: npm run build run: npm run build
- name: Deploy to production
run: |
echo "Production deploy would happen via docker"

13
Dockerfile.dev Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ENV VITE_API_URL=http://192.168.31.60:8081
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# Pulse Web
# test webhook

16
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,16 @@
networks:
proxy:
external: true
name: services_proxy
services:
web-dev:
build:
context: .
dockerfile: Dockerfile.dev
container_name: pulse-web-dev
restart: always
ports:
- "5174:80"
networks:
- proxy

1739
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,31 +11,36 @@
"build-storybook": "storybook build" "build-storybook": "storybook build"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"clsx": "^2.1.0",
"date-fns": "^3.3.1",
"framer-motion": "^11.0.3",
"lucide-react": "^0.312.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"@tanstack/react-query": "^5.17.0", "recharts": "^2.12.0",
"axios": "^1.6.5", "zustand": "^4.5.0"
"zustand": "^4.5.0",
"date-fns": "^3.3.1",
"lucide-react": "^0.312.0",
"clsx": "^2.1.0",
"framer-motion": "^11.0.3",
"recharts": "^2.12.0"
}, },
"devDependencies": { "devDependencies": {
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-themes": "^8.5.0",
"@storybook/blocks": "^8.5.0",
"@storybook/react": "^8.5.0",
"@storybook/react-vite": "^8.5.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.48", "@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"jsdom": "^28.1.0",
"postcss": "^8.4.33", "postcss": "^8.4.33",
"storybook": "^8.5.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"vite": "^5.0.12", "vite": "^5.0.12",
"@storybook/react": "^8.5.0", "vitest": "^4.0.18"
"@storybook/react-vite": "^8.5.0",
"@storybook/addon-essentials": "^8.5.0",
"@storybook/addon-themes": "^8.5.0",
"@storybook/blocks": "^8.5.0",
"storybook": "^8.5.0"
} }
} }

View File

@@ -12,6 +12,8 @@ import ResetPassword from "./pages/ResetPassword"
import ForgotPassword from "./pages/ForgotPassword" import ForgotPassword from "./pages/ForgotPassword"
import Stats from "./pages/Stats" import Stats from "./pages/Stats"
import Settings from "./pages/Settings" import Settings from "./pages/Settings"
import Finance from "./pages/Finance"
import Tracker from "./pages/Tracker"
function ProtectedRoute({ children }) { function ProtectedRoute({ children }) {
const { isAuthenticated, isLoading } = useAuthStore() const { isAuthenticated, isLoading } = useAuthStore()
@@ -92,11 +94,20 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/tracker"
element={
<ProtectedRoute>
<Tracker />
</ProtectedRoute>
}
/>
{/* Legacy routes redirect to tracker */}
<Route <Route
path="/habits" path="/habits"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<Habits /> <Navigate to="/tracker" replace />
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -104,7 +115,15 @@ export default function App() {
path="/tasks" path="/tasks"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<Tasks /> <Navigate to="/tracker" replace />
</ProtectedRoute>
}
/>
<Route
path="/stats"
element={
<ProtectedRoute>
<Navigate to="/tracker" replace />
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -117,10 +136,10 @@ export default function App() {
} }
/> />
<Route <Route
path="/stats" path="/finance"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<Stats /> <Finance />
</ProtectedRoute> </ProtectedRoute>
} }
/> />

267
src/__tests__/api.test.js Normal file
View File

@@ -0,0 +1,267 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
},
}))
import api from '../api/client'
import { tasksApi } from '../api/tasks'
import { habitsApi } from '../api/habits'
import { savingsApi } from '../api/savings'
import { profileApi } from '../api/profile'
describe('tasksApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should list all tasks', async () => {
api.get.mockResolvedValueOnce({ data: [{ id: 1, title: 'Test' }] })
const result = await tasksApi.list()
expect(api.get).toHaveBeenCalledWith('tasks')
expect(result).toEqual([{ id: 1, title: 'Test' }])
})
it('should list completed tasks', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await tasksApi.list(true)
expect(api.get).toHaveBeenCalledWith('tasks?completed=true')
})
it('should list incomplete tasks', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await tasksApi.list(false)
expect(api.get).toHaveBeenCalledWith('tasks?completed=false')
})
it('should get today tasks', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await tasksApi.today()
expect(api.get).toHaveBeenCalledWith('tasks/today')
})
it('should create a task', async () => {
const taskData = { title: 'New Task', priority: 1 }
api.post.mockResolvedValueOnce({ data: { id: 1, ...taskData } })
const result = await tasksApi.create(taskData)
expect(api.post).toHaveBeenCalledWith('tasks', taskData)
expect(result.title).toBe('New Task')
})
it('should update a task', async () => {
api.put.mockResolvedValueOnce({ data: { id: 1, title: 'Updated' } })
await tasksApi.update(1, { title: 'Updated' })
expect(api.put).toHaveBeenCalledWith('tasks/1', { title: 'Updated' })
})
it('should delete a task', async () => {
api.delete.mockResolvedValueOnce({})
await tasksApi.delete(1)
expect(api.delete).toHaveBeenCalledWith('tasks/1')
})
it('should complete a task', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1, completed: true } })
await tasksApi.complete(1)
expect(api.post).toHaveBeenCalledWith('tasks/1/complete')
})
it('should uncomplete a task', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1, completed: false } })
await tasksApi.uncomplete(1)
expect(api.post).toHaveBeenCalledWith('tasks/1/uncomplete')
})
it('should get a single task', async () => {
api.get.mockResolvedValueOnce({ data: { id: 5, title: 'Task 5' } })
const result = await tasksApi.get(5)
expect(api.get).toHaveBeenCalledWith('tasks/5')
expect(result.id).toBe(5)
})
})
describe('habitsApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should list habits', async () => {
api.get.mockResolvedValueOnce({ data: [{ id: 1, name: 'Exercise' }] })
const result = await habitsApi.list()
expect(api.get).toHaveBeenCalledWith('/habits')
expect(result).toEqual([{ id: 1, name: 'Exercise' }])
})
it('should create a habit', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1, name: 'Read' } })
await habitsApi.create({ name: 'Read' })
expect(api.post).toHaveBeenCalledWith('/habits', { name: 'Read' })
})
it('should update a habit', async () => {
api.put.mockResolvedValueOnce({ data: { id: 1, name: 'Updated' } })
await habitsApi.update(1, { name: 'Updated' })
expect(api.put).toHaveBeenCalledWith('/habits/1', { name: 'Updated' })
})
it('should log a habit', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await habitsApi.log(5, { date: '2025-03-01' })
expect(api.post).toHaveBeenCalledWith('/habits/5/log', { date: '2025-03-01' })
})
it('should log habit with empty data', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await habitsApi.log(5)
expect(api.post).toHaveBeenCalledWith('/habits/5/log', {})
})
it('should get logs with custom days', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await habitsApi.getLogs(5, 60)
expect(api.get).toHaveBeenCalledWith('/habits/5/logs?days=60')
})
it('should get overall stats', async () => {
api.get.mockResolvedValueOnce({ data: { total_habits: 5 } })
await habitsApi.getStats()
expect(api.get).toHaveBeenCalledWith('/habits/stats')
})
it('should get single habit stats', async () => {
api.get.mockResolvedValueOnce({ data: { habit_id: 3 } })
await habitsApi.getHabitStats(3)
expect(api.get).toHaveBeenCalledWith('/habits/3/stats')
})
it('should get freezes', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await habitsApi.getFreezes(3)
expect(api.get).toHaveBeenCalledWith('/habits/3/freezes')
})
it('should add freeze', async () => {
const freezeData = { start_date: '2025-03-01', end_date: '2025-03-07' }
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await habitsApi.addFreeze(3, freezeData)
expect(api.post).toHaveBeenCalledWith('/habits/3/freezes', freezeData)
})
it('should delete freeze', async () => {
api.delete.mockResolvedValueOnce({})
await habitsApi.deleteFreeze(3, 10)
expect(api.delete).toHaveBeenCalledWith('/habits/3/freezes/10')
})
it('should delete a habit', async () => {
api.delete.mockResolvedValueOnce({})
await habitsApi.delete(5)
expect(api.delete).toHaveBeenCalledWith('/habits/5')
})
it('should delete a log', async () => {
api.delete.mockResolvedValueOnce({})
await habitsApi.deleteLog(3, 7)
expect(api.delete).toHaveBeenCalledWith('/habits/3/logs/7')
})
})
describe('savingsApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should list categories', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.listCategories()
expect(api.get).toHaveBeenCalledWith('/savings/categories')
})
it('should get single category', async () => {
api.get.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.getCategory(1)
expect(api.get).toHaveBeenCalledWith('/savings/categories/1')
})
it('should create category', async () => {
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.createCategory({ name: 'Savings' })
expect(api.post).toHaveBeenCalledWith('/savings/categories', { name: 'Savings' })
})
it('should update category', async () => {
api.put.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.updateCategory(1, { name: 'Updated' })
expect(api.put).toHaveBeenCalledWith('/savings/categories/1', { name: 'Updated' })
})
it('should delete category', async () => {
api.delete.mockResolvedValueOnce({})
await savingsApi.deleteCategory(1)
expect(api.delete).toHaveBeenCalledWith('/savings/categories/1')
})
it('should create transaction', async () => {
const data = { category_id: 1, amount: 1000, type: 'deposit', date: '2025-03-01' }
api.post.mockResolvedValueOnce({ data: { id: 1 } })
await savingsApi.createTransaction(data)
expect(api.post).toHaveBeenCalledWith('/savings/transactions', data)
})
it('should list transactions with category filter', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.listTransactions(5, 50, 10)
expect(api.get).toHaveBeenCalledWith('/savings/transactions?limit=50&offset=10&category_id=5')
})
it('should list transactions without category filter', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.listTransactions(null)
expect(api.get).toHaveBeenCalledWith('/savings/transactions?limit=100&offset=0')
})
it('should get stats', async () => {
api.get.mockResolvedValueOnce({ data: { total_balance: 5000 } })
await savingsApi.getStats()
expect(api.get).toHaveBeenCalledWith('/savings/stats')
})
it('should get members', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.getMembers(1)
expect(api.get).toHaveBeenCalledWith('/savings/categories/1/members')
})
it('should add member', async () => {
api.post.mockResolvedValueOnce({ data: [] })
await savingsApi.addMember(1, 42)
expect(api.post).toHaveBeenCalledWith('/savings/categories/1/members', { user_id: 42 })
})
it('should get recurring plans', async () => {
api.get.mockResolvedValueOnce({ data: [] })
await savingsApi.getRecurringPlans(1)
expect(api.get).toHaveBeenCalledWith('/savings/categories/1/recurring-plans')
})
})
describe('profileApi', () => {
beforeEach(() => vi.clearAllMocks())
it('should get profile', async () => {
api.get.mockResolvedValueOnce({ data: { username: 'test' } })
const result = await profileApi.get()
expect(api.get).toHaveBeenCalledWith('/profile')
expect(result.username).toBe('test')
})
it('should update profile', async () => {
api.put.mockResolvedValueOnce({ data: { username: 'updated' } })
const result = await profileApi.update({ username: 'updated' })
expect(api.put).toHaveBeenCalledWith('/profile', { username: 'updated' })
expect(result.username).toBe('updated')
})
})

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
},
}))
import api from '../api/client'
import { useAuthStore } from '../store/auth'
describe('useAuthStore', () => {
beforeEach(() => {
useAuthStore.setState({
user: null,
isLoading: true,
isAuthenticated: false,
})
localStorage.clear()
vi.clearAllMocks()
})
it('should have correct initial state', () => {
const state = useAuthStore.getState()
expect(state.user).toBeNull()
expect(state.isLoading).toBe(true)
expect(state.isAuthenticated).toBe(false)
})
it('should login successfully', async () => {
const mockResponse = {
data: {
user: { id: 1, email: 'test@test.com', username: 'test' },
access_token: 'access-123',
refresh_token: 'refresh-123',
},
}
api.post.mockResolvedValueOnce(mockResponse)
await useAuthStore.getState().login('test@test.com', 'password')
expect(api.post).toHaveBeenCalledWith('/auth/login', {
email: 'test@test.com',
password: 'password',
})
expect(localStorage.getItem('access_token')).toBe('access-123')
expect(localStorage.getItem('refresh_token')).toBe('refresh-123')
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(useAuthStore.getState().user).toEqual(mockResponse.data.user)
})
it('should register successfully', async () => {
const mockResponse = {
data: {
user: { id: 2, email: 'new@test.com', username: 'newuser' },
access_token: 'access-456',
refresh_token: 'refresh-456',
},
}
api.post.mockResolvedValueOnce(mockResponse)
await useAuthStore.getState().register('new@test.com', 'newuser', 'password123')
expect(api.post).toHaveBeenCalledWith('/auth/register', {
email: 'new@test.com',
username: 'newuser',
password: 'password123',
})
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(localStorage.getItem('access_token')).toBe('access-456')
})
it('should logout and clear tokens', () => {
localStorage.setItem('access_token', 'old-token')
localStorage.setItem('refresh_token', 'old-refresh')
useAuthStore.setState({ user: { id: 1 }, isAuthenticated: true })
useAuthStore.getState().logout()
expect(localStorage.getItem('access_token')).toBeNull()
expect(localStorage.getItem('refresh_token')).toBeNull()
expect(useAuthStore.getState().user).toBeNull()
expect(useAuthStore.getState().isAuthenticated).toBe(false)
})
it('should initialize with no token', async () => {
await useAuthStore.getState().initialize()
expect(useAuthStore.getState().isLoading).toBe(false)
expect(useAuthStore.getState().isAuthenticated).toBe(false)
})
it('should initialize with valid token', async () => {
localStorage.setItem('access_token', 'valid-token')
api.get.mockResolvedValueOnce({
data: { id: 1, email: 'test@test.com', username: 'test' },
})
await useAuthStore.getState().initialize()
expect(api.get).toHaveBeenCalledWith('/auth/me')
expect(useAuthStore.getState().isAuthenticated).toBe(true)
expect(useAuthStore.getState().isLoading).toBe(false)
expect(useAuthStore.getState().user.email).toBe('test@test.com')
})
it('should handle invalid token on initialize', async () => {
localStorage.setItem('access_token', 'invalid-token')
api.get.mockRejectedValueOnce(new Error('401'))
await useAuthStore.getState().initialize()
expect(useAuthStore.getState().isAuthenticated).toBe(false)
expect(localStorage.getItem('access_token')).toBeNull()
expect(localStorage.getItem('refresh_token')).toBeNull()
})
it('should handle login error', async () => {
api.post.mockRejectedValueOnce(new Error('Invalid credentials'))
await expect(
useAuthStore.getState().login('bad@test.com', 'wrong')
).rejects.toThrow('Invalid credentials')
expect(useAuthStore.getState().isAuthenticated).toBe(false)
})
})

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock client module
vi.mock('../api/client', () => {
return {
default: {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
}
}
})
import client from '../api/client'
import { financeApi } from '../api/finance'
beforeEach(() => {
vi.clearAllMocks()
})
describe('financeApi', () => {
// Categories
describe('categories', () => {
it('listCategories calls GET finance/categories', async () => {
const mockData = [{ id: 1, name: 'Еда', emoji: '🍔' }]
client.get.mockResolvedValue({ data: mockData })
const result = await financeApi.listCategories()
expect(client.get).toHaveBeenCalledWith('finance/categories')
expect(result).toEqual(mockData)
})
it('createCategory calls POST finance/categories', async () => {
const input = { name: 'Test', type: 'expense', emoji: '🧪' }
const mockData = { id: 1, ...input }
client.post.mockResolvedValue({ data: mockData })
const result = await financeApi.createCategory(input)
expect(client.post).toHaveBeenCalledWith('finance/categories', input)
expect(result).toEqual(mockData)
})
it('updateCategory calls PUT finance/categories/:id', async () => {
const data = { name: 'Updated' }
client.put.mockResolvedValue({ data: { id: 5, ...data } })
const result = await financeApi.updateCategory(5, data)
expect(client.put).toHaveBeenCalledWith('finance/categories/5', data)
expect(result.name).toBe('Updated')
})
it('deleteCategory calls DELETE finance/categories/:id', async () => {
client.delete.mockResolvedValue({})
await financeApi.deleteCategory(3)
expect(client.delete).toHaveBeenCalledWith('finance/categories/3')
})
})
// Transactions
describe('transactions', () => {
it('listTransactions calls GET with params', async () => {
const mockData = [{ id: 1, amount: 500 }]
client.get.mockResolvedValue({ data: mockData })
const params = { month: 3, year: 2026 }
const result = await financeApi.listTransactions(params)
expect(client.get).toHaveBeenCalledWith('finance/transactions', { params })
expect(result).toEqual(mockData)
})
it('listTransactions works without params', async () => {
client.get.mockResolvedValue({ data: [] })
const result = await financeApi.listTransactions()
expect(client.get).toHaveBeenCalledWith('finance/transactions', { params: {} })
expect(result).toEqual([])
})
it('createTransaction calls POST', async () => {
const input = { amount: 100, type: 'expense', category_id: 1, date: '2026-03-01' }
client.post.mockResolvedValue({ data: { id: 1, ...input } })
const result = await financeApi.createTransaction(input)
expect(client.post).toHaveBeenCalledWith('finance/transactions', input)
expect(result.amount).toBe(100)
})
it('updateTransaction calls PUT', async () => {
const data = { amount: 200 }
client.put.mockResolvedValue({ data: { id: 7, ...data } })
const result = await financeApi.updateTransaction(7, data)
expect(client.put).toHaveBeenCalledWith('finance/transactions/7', data)
expect(result.amount).toBe(200)
})
it('deleteTransaction calls DELETE', async () => {
client.delete.mockResolvedValue({})
await financeApi.deleteTransaction(10)
expect(client.delete).toHaveBeenCalledWith('finance/transactions/10')
})
})
// Summary & Analytics
describe('summary & analytics', () => {
it('getSummary calls GET with params', async () => {
const mockData = { balance: 5000, total_income: 10000, total_expense: 5000 }
client.get.mockResolvedValue({ data: mockData })
const result = await financeApi.getSummary({ month: 3, year: 2026 })
expect(client.get).toHaveBeenCalledWith('finance/summary', { params: { month: 3, year: 2026 } })
expect(result.balance).toBe(5000)
})
it('getAnalytics calls GET with params', async () => {
const mockData = { monthly_trend: [], avg_daily_expense: 500 }
client.get.mockResolvedValue({ data: mockData })
const result = await financeApi.getAnalytics({ months: 6 })
expect(client.get).toHaveBeenCalledWith('finance/analytics', { params: { months: 6 } })
expect(result.avg_daily_expense).toBe(500)
})
})
})

1
src/__tests__/setup.js Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import Tracker from '../pages/Tracker'
// Mock child pages
vi.mock('../pages/Habits', () => ({
default: ({ embedded }) => <div data-testid="habits">Habits {embedded ? 'embedded' : ''}</div>
}))
vi.mock('../pages/Tasks', () => ({
default: ({ embedded }) => <div data-testid="tasks">Tasks {embedded ? 'embedded' : ''}</div>
}))
vi.mock('../pages/Stats', () => ({
default: ({ embedded }) => <div data-testid="stats">Stats {embedded ? 'embedded' : ''}</div>
}))
vi.mock('../components/Navigation', () => ({
default: () => <nav data-testid="navigation">Nav</nav>
}))
describe('Tracker', () => {
it('renders header with title', () => {
render(<Tracker />)
expect(screen.getByText(/Трекер/)).toBeInTheDocument()
})
it('renders all three tab buttons', () => {
render(<Tracker />)
expect(screen.getByText(/Привычки/)).toBeInTheDocument()
expect(screen.getByText(/Задачи/)).toBeInTheDocument()
expect(screen.getByText(/Статистика/)).toBeInTheDocument()
})
it('shows Habits tab by default', () => {
render(<Tracker />)
expect(screen.getByTestId('habits')).toBeInTheDocument()
expect(screen.queryByTestId('tasks')).not.toBeInTheDocument()
expect(screen.queryByTestId('stats')).not.toBeInTheDocument()
})
it('switches to Tasks tab on click', () => {
render(<Tracker />)
fireEvent.click(screen.getByText(/Задачи/))
expect(screen.getByTestId('tasks')).toBeInTheDocument()
expect(screen.queryByTestId('habits')).not.toBeInTheDocument()
})
it('switches to Stats tab on click', () => {
render(<Tracker />)
fireEvent.click(screen.getByText(/Статистика/))
expect(screen.getByTestId('stats')).toBeInTheDocument()
expect(screen.queryByTestId('habits')).not.toBeInTheDocument()
})
it('switches back to Habits', () => {
render(<Tracker />)
fireEvent.click(screen.getByText(/Задачи/))
fireEvent.click(screen.getByText(/Привычки/))
expect(screen.getByTestId('habits')).toBeInTheDocument()
})
it('renders navigation', () => {
render(<Tracker />)
expect(screen.getByTestId('navigation')).toBeInTheDocument()
})
})

47
src/api/finance.js Normal file
View File

@@ -0,0 +1,47 @@
import client from './client'
export const financeApi = {
// Categories
listCategories: async () => {
const res = await client.get('finance/categories')
return res.data
},
createCategory: async (data) => {
const res = await client.post('finance/categories', data)
return res.data
},
updateCategory: async (id, data) => {
const res = await client.put(`finance/categories/${id}`, data)
return res.data
},
deleteCategory: async (id) => {
await client.delete(`finance/categories/${id}`)
},
// Transactions
listTransactions: async (params = {}) => {
const res = await client.get('finance/transactions', { params })
return res.data
},
createTransaction: async (data) => {
const res = await client.post('finance/transactions', data)
return res.data
},
updateTransaction: async (id, data) => {
const res = await client.put(`finance/transactions/${id}`, data)
return res.data
},
deleteTransaction: async (id) => {
await client.delete(`finance/transactions/${id}`)
},
// Summary & Analytics
getSummary: async (params = {}) => {
const res = await client.get('finance/summary', { params })
return res.data
},
getAnalytics: async (params = {}) => {
const res = await client.get('finance/analytics', { params })
return res.data
},
}

View File

@@ -1,16 +1,21 @@
import { NavLink } from "react-router-dom" import { NavLink } from "react-router-dom"
import { Home, ListChecks, CheckSquare, BarChart3, PiggyBank, Settings } from "lucide-react" import { Home, BarChart3, Wallet, PiggyBank, Settings } from "lucide-react"
import { useAuthStore } from "../store/auth"
import clsx from "clsx" import clsx from "clsx"
const OWNER_ID = 1
export default function Navigation() { export default function Navigation() {
const user = useAuthStore((s) => s.user)
const isOwner = user?.id === OWNER_ID
const navItems = [ const navItems = [
{ to: "/", icon: Home, label: "Сегодня" }, { to: "/", icon: Home, label: "Главная" },
{ to: "/habits", icon: ListChecks, label: "Привычки" }, { to: "/tracker", icon: BarChart3, label: "Трекер" },
{ to: "/tasks", icon: CheckSquare, label: "Задачи" }, isOwner && { to: "/finance", icon: Wallet, label: "Финансы" },
{ to: "/stats", icon: BarChart3, label: "Статистика" },
{ to: "/savings", icon: PiggyBank, label: "Накопления" }, { to: "/savings", icon: PiggyBank, label: "Накопления" },
{ to: "/settings", icon: Settings, label: "Настройки" }, { to: "/settings", icon: Settings, label: "Настройки" },
] ].filter(Boolean)
return ( return (
<nav className="fixed bottom-0 left-0 right-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-100 dark:border-gray-800 z-50 transition-colors duration-300"> <nav className="fixed bottom-0 left-0 right-0 bg-white/80 dark:bg-gray-900/80 backdrop-blur-xl border-t border-gray-100 dark:border-gray-800 z-50 transition-colors duration-300">
@@ -20,16 +25,17 @@ export default function Navigation() {
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
end={to === "/"}
className={({ isActive }) => className={({ isActive }) =>
clsx( clsx(
"flex flex-col items-center gap-0.5 px-1.5 py-1.5 rounded-xl transition-all", "flex flex-col items-center gap-0.5 px-2 py-2 rounded-xl transition-all",
isActive isActive
? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30" ? "text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30"
: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300" : "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
) )
} }
> >
<Icon size={18} /> <Icon size={20} />
<span className="text-[10px] font-medium">{label}</span> <span className="text-[10px] font-medium">{label}</span>
</NavLink> </NavLink>
))} ))}

View File

@@ -0,0 +1,207 @@
import { useState, useEffect } from "react"
import { financeApi } from "../../api/finance"
const quickTemplates = [
{ description: "Продукты", categoryName: "Еда", amount: 2000 },
{ description: "Такси", categoryName: "Транспорт", amount: 400 },
{ description: "Кофе", categoryName: "Еда", amount: 350 },
{ description: "Обед", categoryName: "Еда", amount: 600 },
{ description: "Метро", categoryName: "Транспорт", amount: 57 },
]
export default function AddTransactionModal({ onClose, onSaved }) {
const [type, setType] = useState("expense")
const [categories, setCategories] = useState([])
const [categoryId, setCategoryId] = useState(null)
const [amount, setAmount] = useState("")
const [description, setDescription] = useState("")
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const [saving, setSaving] = useState(false)
useEffect(() => {
financeApi.listCategories().then(setCategories).catch(console.error)
}, [])
const cats = categories.filter((c) => c.type === type)
const applyTemplate = (t) => {
setDescription(t.description)
setAmount(String(t.amount))
const found = categories.find(
(c) => c.name === t.categoryName && c.type === "expense"
)
if (found) setCategoryId(found.id)
}
const handleSubmit = async () => {
if (!amount || !categoryId) return
setSaving(true)
try {
await financeApi.createTransaction({
type,
category_id: categoryId,
amount: parseFloat(amount),
description,
date,
})
onSaved()
} catch (e) {
console.error(e)
alert("Ошибка при сохранении")
} finally {
setSaving(false)
}
}
return (
<div
className="fixed inset-0 bg-black/50 flex items-end justify-center z-50"
onClick={onClose}
>
<div
className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg flex flex-col"
style={{ maxHeight: "85vh" }}
onClick={(e) => e.stopPropagation()}
>
{/* Header - fixed */}
<div className="px-6 pt-4 pb-2 flex-shrink-0">
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto mb-4" />
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white">
Новая запись
</h2>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto px-6 pb-4" style={{ WebkitOverflowScrolling: "touch" }}>
<div className="space-y-5 pt-2">
{/* Type toggle */}
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
{[
["expense", "Расход"],
["income", "Доход"],
].map(([k, l]) => (
<button
key={k}
onClick={() => {
setType(k)
setCategoryId(null)
}}
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition ${
type === k
? k === "expense"
? "bg-red-500 text-white"
: "bg-green-500 text-white"
: "text-gray-500"
}`}
>
{l}
</button>
))}
</div>
{/* Quick templates */}
{type === "expense" && (
<div>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
Быстрые шаблоны
</p>
<div className="flex flex-wrap gap-2">
{quickTemplates.map((t, i) => (
<button
key={i}
onClick={() => applyTemplate(t)}
className="px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 text-sm text-gray-700 dark:text-gray-300 hover:bg-primary-100 dark:hover:bg-primary-900 transition"
>
{t.description}
</button>
))}
</div>
</div>
)}
{/* Amount */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Сумма
</label>
<div className="relative">
<input
type="number"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-2xl font-bold text-gray-900 dark:text-white outline-none"
placeholder="0"
/>
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-lg text-gray-400">
</span>
</div>
</div>
{/* Description */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Описание
</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
placeholder="Что купили?"
/>
</div>
{/* Category */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Категория
</label>
<div className="grid grid-cols-2 gap-2">
{cats.map((c) => (
<button
key={c.id}
onClick={() => setCategoryId(c.id)}
className={`px-3 py-2.5 rounded-xl text-sm text-left font-medium transition ${
categoryId === c.id
? "bg-primary-500 text-white ring-2 ring-primary-300"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300"
}`}
>
{c.emoji} {c.name}
</button>
))}
</div>
</div>
{/* Date */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">
Дата
</label>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
/>
</div>
</div>
</div>
{/* Sticky submit button */}
<div className="px-6 py-4 border-t border-gray-100 dark:border-gray-800 flex-shrink-0 bg-white dark:bg-gray-900">
<button
onClick={handleSubmit}
disabled={saving || !amount || !categoryId}
className="w-full py-3.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 text-white rounded-xl font-semibold text-base transition shadow-lg"
>
{saving
? "Сохраняю..."
: `Добавить ${type === "expense" ? "расход" : "доход"}`}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,238 @@
import { useState, useEffect } from "react"
import { financeApi } from "../../api/finance"
const EMOJI_OPTIONS = ["🏠","🍔","🚗","👕","🏥","🎮","📱","✈️","🎁","🛒","🛍️","💎","📦","💰","💼","📈","🎓","🏋️","🐶","🎵","💊","🏪","☕","🍕","🎬","📚","🔧","💡","🌐","🎯"]
const fmt = (n) => n?.toLocaleString("ru-RU") + " ₽"
export default function CategoriesManager({ refreshKey }) {
const [categories, setCategories] = useState([])
const [tab, setTab] = useState("expense")
const [showModal, setShowModal] = useState(false)
const [editing, setEditing] = useState(null)
const [loading, setLoading] = useState(true)
// Form state
const [emoji, setEmoji] = useState("📦")
const [name, setName] = useState("")
const [type, setType] = useState("expense")
const [budget, setBudget] = useState("")
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(null)
const load = async () => {
try {
setLoading(true)
const cats = await financeApi.listCategories()
setCategories(cats)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [refreshKey])
const cats = categories.filter(c => c.type === tab)
const openAdd = () => {
setEditing(null)
setEmoji("📦")
setName("")
setType(tab)
setBudget("")
setShowModal(true)
}
const openEdit = (cat) => {
setEditing(cat)
setEmoji(cat.emoji || "📦")
setName(cat.name)
setType(cat.type)
setBudget(cat.budget ? String(cat.budget) : "")
setShowModal(true)
}
const handleSave = async () => {
if (!name.trim()) return
setSaving(true)
try {
const data = { name: name.trim(), emoji, type, budget: budget ? parseFloat(budget) : 0 }
if (editing) {
await financeApi.updateCategory(editing.id, data)
} else {
await financeApi.createCategory(data)
}
setShowModal(false)
load()
} catch (e) {
console.error(e)
alert("Ошибка при сохранении")
} finally {
setSaving(false)
}
}
const handleDelete = async (id) => {
if (!confirm("Удалить категорию? Транзакции с ней останутся.")) return
setDeleting(id)
try {
await financeApi.deleteCategory(id)
load()
} catch (e) {
console.error(e)
alert("Ошибка при удалении")
} finally {
setDeleting(null)
}
}
if (loading) {
return (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="card p-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-gray-200 dark:bg-gray-700" />
<div className="flex-1"><div className="h-5 bg-gray-200 dark:bg-gray-700 rounded w-1/2" /></div>
</div>
</div>
))}
</div>
)
}
return (
<div>
{/* Sub-tabs: expense / income */}
<div className="flex gap-2 mb-4">
{[["expense","Расходы"],["income","Доходы"]].map(([k,l]) => (
<button key={k} onClick={() => setTab(k)}
className={`px-4 py-2 rounded-xl text-sm font-semibold transition ${
tab === k ? "bg-primary-500 text-white" : "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}>
{l}
</button>
))}
<div className="flex-1" />
<button onClick={openAdd} className="px-4 py-2 bg-primary-500 text-white rounded-xl text-sm font-medium hover:bg-primary-600 transition">
+ Добавить
</button>
</div>
{/* Category list */}
<div className="space-y-3">
{cats.length === 0 ? (
<div className="card p-8 text-center">
<p className="text-gray-500 dark:text-gray-400">Нет категорий</p>
<button onClick={openAdd} className="mt-3 text-sm text-primary-600 dark:text-primary-400 font-medium">+ Добавить</button>
</div>
) : cats.map(c => {
const pct = c.budget > 0 ? Math.min(Math.round((c.spent || 0) / c.budget * 100), 100) : 0
const isOver = c.budget > 0 && (c.spent || 0) > c.budget * 0.9
return (
<div key={c.id} className="card p-4">
<div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-xl bg-gray-100 dark:bg-gray-800 flex items-center justify-center text-2xl flex-shrink-0">
{c.emoji}
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center">
<span className="font-medium text-gray-900 dark:text-white truncate">{c.name}</span>
{c.budget > 0 && (
<span className={`text-xs font-semibold ml-2 whitespace-nowrap ${isOver ? "text-red-500" : "text-gray-500 dark:text-gray-400"}`}>
{fmt(c.spent || 0)} / {fmt(c.budget)}
</span>
)}
</div>
{c.budget > 0 && (
<div className="mt-2 h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div className={`h-full rounded-full transition-all ${isOver ? "bg-red-500" : pct > 70 ? "bg-yellow-500" : "bg-primary-500"}`}
style={{ width: pct + "%" }} />
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button onClick={() => openEdit(c)} className="p-2 text-gray-400 hover:text-primary-500 rounded-lg transition">
</button>
<button onClick={() => handleDelete(c.id)} disabled={deleting === c.id}
className="p-2 text-gray-400 hover:text-red-500 rounded-lg transition disabled:opacity-50">
{deleting === c.id ? "⏳" : "🗑️"}
</button>
</div>
</div>
</div>
)
})}
</div>
{/* Add/Edit Modal */}
{showModal && (
<div className="fixed inset-0 bg-black/50 flex items-end justify-center z-50" onClick={() => setShowModal(false)}>
<div className="bg-white dark:bg-gray-900 rounded-t-3xl w-full max-w-lg p-6" onClick={e => e.stopPropagation()}>
<div className="w-12 h-1 bg-gray-300 dark:bg-gray-700 rounded-full mx-auto mb-4" />
<h2 className="text-lg font-display font-bold text-gray-900 dark:text-white mb-4">
{editing ? "Редактировать категорию" : "Новая категория"}
</h2>
<div className="space-y-4">
{/* Emoji + Name */}
<div className="flex gap-3">
<button onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="w-14 h-14 rounded-xl bg-gray-100 dark:bg-gray-800 text-3xl flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition flex-shrink-0">
{emoji}
</button>
<input value={name} onChange={e => setName(e.target.value)}
className="flex-1 px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
placeholder="Название категории" />
</div>
{/* Emoji picker */}
{showEmojiPicker && (
<div className="grid grid-cols-8 gap-2 p-3 bg-gray-50 dark:bg-gray-800 rounded-xl">
{EMOJI_OPTIONS.map(e => (
<button key={e} onClick={() => { setEmoji(e); setShowEmojiPicker(false) }}
className={`text-2xl p-1 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition ${emoji === e ? "bg-primary-100 dark:bg-primary-900" : ""}`}>
{e}
</button>
))}
</div>
)}
{/* Type toggle */}
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1">
{[["expense","Расход"],["income","Доход"]].map(([k,l]) => (
<button key={k} onClick={() => setType(k)}
className={`flex-1 py-2.5 rounded-lg text-sm font-semibold transition ${
type === k ? "bg-primary-500 text-white" : "text-gray-500"
}`}>
{l}
</button>
))}
</div>
{/* Budget */}
<div>
<label className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase block mb-1">Бюджет (опц.)</label>
<div className="relative">
<input type="number" value={budget} onChange={e => setBudget(e.target.value)}
className="w-full px-4 py-3 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white outline-none"
placeholder="0" />
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400"></span>
</div>
</div>
{/* Save */}
<button onClick={handleSave} disabled={saving || !name.trim()}
className="w-full py-3.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 text-white rounded-xl font-semibold transition">
{saving ? "Сохраняю..." : editing ? "Сохранить" : "Создать"}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,231 @@
import { useState, useEffect } from "react"
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, LineChart, Line,
} from "recharts"
import { financeApi } from "../../api/finance"
const COLORS = [
"#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444",
"#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6",
"#64748b", "#a855f7", "#78716c",
]
const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
const MONTH_NAMES = [
"", "Янв", "Фев", "Мар", "Апр", "Май", "Июн",
"Июл", "Авг", "Сен", "Окт", "Ноя", "Дек",
]
export default function FinanceAnalytics({ month, year }) {
const [analytics, setAnalytics] = useState(null)
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
Promise.all([
financeApi.getAnalytics({ months: 6 }),
financeApi.getSummary({ month, year }),
])
.then(([a, s]) => {
setAnalytics(a)
setSummary(s)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [month, year])
if (loading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card p-8 animate-pulse">
<div className="h-40 bg-gray-200 dark:bg-gray-800 rounded" />
</div>
))}
</div>
)
}
if (!analytics || !summary) return null
const expenseCategories = (summary.by_category || []).filter(
(c) => c.type === "expense"
)
const barData = expenseCategories.map((c) => ({
name: c.category_emoji + " " + c.category_name,
value: c.amount,
}))
const pieData = barData
const monthlyData = (analytics.monthly_trend || []).map((m) => ({
month: MONTH_NAMES[parseInt(m.month.slice(5))] || m.month,
income: m.income,
expense: m.expense,
}))
const comp = analytics.comparison_prev_month
const diffPct = Math.abs(Math.round(comp.diff_percent))
const isUp = comp.diff_percent > 0
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-3">
<div className="card p-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
Всего расходов
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{fmt(summary.total_expense)}
</p>
<p
className={`text-xs mt-1 ${isUp ? "text-red-500" : "text-green-500"}`}
>
{isUp ? "↑" : "↓"} {diffPct}% vs пред. месяц
</p>
</div>
<div className="card p-4">
<p className="text-xs text-gray-500 dark:text-gray-400">
В среднем / день
</p>
<p className="text-xl font-bold text-gray-900 dark:text-white">
{fmt(Math.round(analytics.avg_daily_expense))}
</p>
</div>
</div>
{barData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Расходы по категориям
</h3>
<div className="h-64">
<ResponsiveContainer>
<BarChart data={barData} layout="vertical" margin={{ left: 10 }}>
<XAxis
type="number"
tick={{ fontSize: 10 }}
stroke="#94a3b8"
tickFormatter={(v) => v / 1000 + "к"}
/>
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 11 }}
stroke="#94a3b8"
width={120}
/>
<Tooltip formatter={(v) => fmt(v)} />
<Bar dataKey="value" radius={[0, 6, 6, 0]}>
{barData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{pieData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Доля категорий
</h3>
<div className="flex items-start gap-4">
<div className="w-44 h-44 flex-shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie
data={pieData}
innerRadius={45}
outerRadius={75}
dataKey="value"
stroke="none"
>
{pieData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => fmt(v)} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="space-y-1.5 pt-2">
{pieData.map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: COLORS[i] }}
/>
<span className="text-gray-600 dark:text-gray-400 truncate max-w-[100px]">
{c.name}
</span>
<span className="ml-auto font-semibold text-gray-900 dark:text-white">
{Math.round(
(c.value / summary.total_expense) * 100
)}
%
</span>
</div>
))}
</div>
</div>
</div>
)}
{monthlyData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Тренд по месяцам
</h3>
<div className="h-48">
<ResponsiveContainer>
<LineChart data={monthlyData}>
<XAxis
dataKey="month"
tick={{ fontSize: 12 }}
stroke="#94a3b8"
/>
<YAxis
tick={{ fontSize: 10 }}
stroke="#94a3b8"
tickFormatter={(v) => v / 1000 + "к"}
/>
<Tooltip formatter={(v) => fmt(v)} />
<Line
type="monotone"
dataKey="income"
stroke="#22c55e"
strokeWidth={2}
name="Доходы"
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="expense"
stroke="#ef4444"
strokeWidth={2}
name="Расходы"
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex justify-center gap-6 mt-3">
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-0.5 bg-green-500" />
<span className="text-gray-500">Доходы</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-0.5 bg-red-500" />
<span className="text-gray-500">Расходы</span>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect } from "react"
import {
PieChart, Pie, Cell, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from "recharts"
import { financeApi } from "../../api/finance"
const COLORS = [
"#0D4F4F", "#F7B538", "#6366f1", "#22c55e", "#ef4444",
"#8b5cf6", "#0ea5e9", "#f97316", "#ec4899", "#14b8a6",
"#64748b", "#a855f7", "#78716c",
]
const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
export default function FinanceDashboard({ month, year }) {
const [summary, setSummary] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
setLoading(true)
financeApi
.getSummary({ month, year })
.then(setSummary)
.catch(console.error)
.finally(() => setLoading(false))
}, [month, year])
if (loading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="card p-6 animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-800 rounded w-1/2" />
</div>
))}
</div>
)
}
if (!summary || (summary.total_income === 0 && summary.total_expense === 0)) {
return (
<div className="card p-12 text-center">
<span className="text-5xl block mb-4">📊</span>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2">
Нет данных
</h3>
<p className="text-gray-500 dark:text-gray-400">
Добавьте первую транзакцию
</p>
</div>
)
}
const expenseCategories = summary.by_category.filter((c) => c.type === "expense")
const pieData = expenseCategories.map((c) => ({
name: c.category_emoji + " " + c.category_name,
value: c.amount,
}))
const dailyData = summary.daily.map((d) => ({
day: d.date.slice(8, 10),
amount: d.amount,
}))
return (
<div className="space-y-6">
<div className="card p-6 bg-gradient-to-br from-primary-950 to-primary-800 text-white">
<p className="text-sm opacity-70">Баланс за месяц</p>
<p className="text-3xl font-bold mt-1">{fmt(summary.balance)}</p>
<div className="flex gap-6 mt-4">
<div>
<p className="text-xs opacity-60">Доходы</p>
<p className="text-lg font-semibold text-green-300">
+{fmt(summary.total_income)}
</p>
</div>
<div>
<p className="text-xs opacity-60">Расходы</p>
<p className="text-lg font-semibold text-red-300">
-{fmt(summary.total_expense)}
</p>
</div>
</div>
</div>
{expenseCategories.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Топ расходов
</h3>
<div className="space-y-3">
{expenseCategories.slice(0, 5).map((c, i) => (
<div key={i} className="flex items-center gap-3">
<span className="text-xl w-8 text-center">
{c.category_emoji}
</span>
<div className="flex-1">
<div className="flex justify-between mb-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{c.category_name}
</span>
<span className="text-sm font-semibold text-gray-900 dark:text-white">
{fmt(c.amount)}
</span>
</div>
<div className="h-2 bg-gray-100 dark:bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: Math.round(c.percentage) + "%",
backgroundColor: COLORS[i % COLORS.length],
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
{pieData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
По категориям
</h3>
<div className="flex items-center gap-4">
<div className="w-40 h-40">
<ResponsiveContainer>
<PieChart>
<Pie
data={pieData}
innerRadius={40}
outerRadius={70}
dataKey="value"
stroke="none"
>
{pieData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip formatter={(v) => fmt(v)} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-1">
{pieData.slice(0, 6).map((c, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: COLORS[i] }}
/>
<span className="text-gray-600 dark:text-gray-400 truncate">
{c.name}
</span>
<span className="ml-auto font-medium text-gray-900 dark:text-white">
{Math.round(
(c.value / summary.total_expense) * 100
)}
%
</span>
</div>
))}
</div>
</div>
</div>
)}
{dailyData.length > 0 && (
<div className="card p-5">
<h3 className="font-display font-bold text-gray-900 dark:text-white mb-4">
Расходы по дням
</h3>
<div className="h-48">
<ResponsiveContainer>
<LineChart data={dailyData}>
<XAxis dataKey="day" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis
tick={{ fontSize: 10 }}
stroke="#94a3b8"
tickFormatter={(v) => v / 1000 + "к"}
/>
<Tooltip formatter={(v) => fmt(v)} />
<Line
type="monotone"
dataKey="amount"
stroke="#0D4F4F"
strokeWidth={2}
dot={{ r: 4, fill: "#0D4F4F" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,167 @@
import { useState, useEffect } from "react"
import { financeApi } from "../../api/finance"
const fmt = (n) => Number(n).toLocaleString("ru-RU") + " ₽"
const formatDate = (d) => {
const dt = new Date(d)
return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long" })
}
export default function TransactionList({ onAdd, month, year }) {
const [transactions, setTransactions] = useState([])
const [categories, setCategories] = useState([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState("all")
const [catFilter, setCatFilter] = useState(null)
const [search, setSearch] = useState("")
useEffect(() => {
setLoading(true)
Promise.all([
financeApi.listCategories(),
financeApi.listTransactions({
month,
year,
limit: 100,
}),
])
.then(([cats, txs]) => {
setCategories(cats || [])
setTransactions(txs || [])
})
.catch(console.error)
.finally(() => setLoading(false))
}, [month, year])
const filtered = transactions.filter((t) => {
if (filter !== "all" && t.type !== filter) return false
if (catFilter && t.category_id !== catFilter) return false
if (search && !t.description.toLowerCase().includes(search.toLowerCase()))
return false
return true
})
const grouped = filtered.reduce((acc, t) => {
const d = t.date.slice(0, 10)
;(acc[d] = acc[d] || []).push(t)
return acc
}, {})
const handleDelete = async (id) => {
if (!confirm("Удалить транзакцию?")) return
await financeApi.deleteTransaction(id)
setTransactions((txs) => txs.filter((t) => t.id !== id))
}
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="card p-4 animate-pulse">
<div className="h-5 bg-gray-200 dark:bg-gray-800 rounded w-3/4" />
</div>
))}
</div>
)
}
return (
<div className="space-y-4">
<input
className="w-full px-4 py-2.5 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm text-gray-900 dark:text-white placeholder-gray-400 outline-none"
placeholder="Поиск по описанию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="flex gap-2">
{[
["all", "Все"],
["income", "Доходы"],
["expense", "Расходы"],
].map(([k, l]) => (
<button
key={k}
onClick={() => setFilter(k)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
filter === k
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{l}
</button>
))}
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
<button
onClick={() => setCatFilter(null)}
className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
!catFilter
? "bg-accent-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
Все
</button>
{categories.map((c) => (
<button
key={c.id}
onClick={() => setCatFilter(c.id)}
className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition ${
catFilter === c.id
? "bg-accent-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{c.emoji} {c.name}
</button>
))}
</div>
{Object.keys(grouped).length === 0 ? (
<div className="card p-12 text-center">
<span className="text-4xl block mb-3">🔍</span>
<p className="text-gray-500 dark:text-gray-400">Ничего не найдено</p>
</div>
) : (
Object.entries(grouped).map(([date, txs]) => (
<div key={date}>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">
{formatDate(date)}
</p>
<div className="card divide-y divide-gray-100 dark:divide-gray-800">
{txs.map((t) => (
<div
key={t.id}
className="px-4 py-3 flex items-center gap-3"
onClick={() => handleDelete(t.id)}
>
<span className="text-xl">{t.category_emoji}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{t.description || t.category_name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t.category_emoji} {t.category_name}
</p>
</div>
<span
className={`text-sm font-bold ${
t.type === "income" ? "text-green-500" : "text-red-500"
}`}
>
{t.type === "income" ? "+" : "-"}
{fmt(t.amount)}
</span>
</div>
))}
</div>
</div>
))
)}
</div>
)
}

122
src/pages/Finance.jsx Normal file
View File

@@ -0,0 +1,122 @@
import { useState } from "react"
import Navigation from "../components/Navigation"
import FinanceDashboard from "../components/finance/FinanceDashboard"
import TransactionList from "../components/finance/TransactionList"
import FinanceAnalytics from "../components/finance/FinanceAnalytics"
import CategoriesManager from "../components/finance/CategoriesManager"
import AddTransactionModal from "../components/finance/AddTransactionModal"
const tabs = [
{ key: "dashboard", label: "Обзор", icon: "📊" },
{ key: "transactions", label: "Транзакции", icon: "📋" },
{ key: "analytics", label: "Аналитика", icon: "📈" },
{ key: "categories", label: "Категории", icon: "🏷️" },
]
const MONTH_NAMES = [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
]
export default function Finance() {
const now = new Date()
const [activeTab, setActiveTab] = useState("dashboard")
const [showAdd, setShowAdd] = useState(false)
const [refreshKey, setRefreshKey] = useState(0)
const [month, setMonth] = useState(now.getMonth() + 1)
const [year, setYear] = useState(now.getFullYear())
const refresh = () => setRefreshKey((k) => k + 1)
const prevMonth = () => {
if (month === 1) { setMonth(12); setYear(y => y - 1) }
else setMonth(m => m - 1)
}
const nextMonth = () => {
if (month === 12) { setMonth(1); setYear(y => y + 1) }
else setMonth(m => m + 1)
}
const isCurrentMonth = month === now.getMonth() + 1 && year === now.getFullYear()
return (
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">
💰 Финансы
</h1>
</div>
<button
onClick={() => setShowAdd(true)}
className="w-10 h-10 rounded-xl bg-primary-500 text-white flex items-center justify-center text-xl shadow-lg hover:bg-primary-600 transition"
>
+
</button>
</div>
{/* Month Switcher */}
<div className="max-w-lg mx-auto px-4 pb-3">
<div className="flex items-center justify-center gap-4">
<button
onClick={prevMonth}
className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition text-sm font-bold"
>
</button>
<button
onClick={() => { setMonth(now.getMonth() + 1); setYear(now.getFullYear()) }}
className={"text-sm font-semibold min-w-[140px] text-center " + (isCurrentMonth ? "text-gray-900 dark:text-white" : "text-primary-600 dark:text-primary-400")}
>
{MONTH_NAMES[month - 1]} {year}
</button>
<button
onClick={nextMonth}
className="w-8 h-8 rounded-lg bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-700 transition text-sm font-bold"
>
</button>
</div>
</div>
<div className="max-w-lg mx-auto px-4 pb-3 flex gap-1.5 overflow-x-auto scrollbar-hide">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={`flex-1 min-w-0 py-2 rounded-xl text-xs sm:text-sm font-semibold transition whitespace-nowrap px-2 ${
activeTab === t.key
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{t.icon} {t.label}
</button>
))}
</div>
</header>
<div className="max-w-lg mx-auto px-4 py-6">
{activeTab === "dashboard" && <FinanceDashboard key={refreshKey + "-" + month + "-" + year} month={month} year={year} />}
{activeTab === "transactions" && (
<TransactionList key={refreshKey + "-" + month + "-" + year} month={month} year={year} onAdd={() => setShowAdd(true)} />
)}
{activeTab === "analytics" && <FinanceAnalytics key={refreshKey + "-" + month + "-" + year} month={month} year={year} />}
{activeTab === "categories" && <CategoriesManager refreshKey={refreshKey} />}
</div>
{showAdd && (
<AddTransactionModal
onClose={() => setShowAdd(false)}
onSaved={() => {
setShowAdd(false)
refresh()
}}
/>
)}
<Navigation />
</div>
)
}

View File

@@ -10,7 +10,7 @@ import EditHabitModal from '../components/EditHabitModal'
import Navigation from '../components/Navigation' import Navigation from '../components/Navigation'
import clsx from 'clsx' import clsx from 'clsx'
export default function Habits() { export default function Habits({ embedded = false }) {
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [editingHabit, setEditingHabit] = useState(null) const [editingHabit, setEditingHabit] = useState(null)
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
@@ -67,8 +67,8 @@ export default function Habits() {
const archivedList = habits.filter(h => h.is_archived) const archivedList = habits.filter(h => h.is_archived)
return ( return (
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"> <div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10"> {!embedded && <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between"> <div className="max-w-lg mx-auto px-4 py-4 flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1> <h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
@@ -79,7 +79,7 @@ export default function Habits() {
Новая Новая
</button> </button>
</div> </div>
</header> </header>}
<main className="max-w-lg mx-auto px-4 py-6 space-y-6"> <main className="max-w-lg mx-auto px-4 py-6 space-y-6">
{isLoading ? ( {isLoading ? (
@@ -168,7 +168,7 @@ export default function Habits() {
)} )}
</main> </main>
<Navigation /> {!embedded && <Navigation />}
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} /> <CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} /> <EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
</div> </div>

View File

@@ -6,6 +6,7 @@ import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, i
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
import { habitsApi } from '../api/habits' import { habitsApi } from '../api/habits'
import { tasksApi } from '../api/tasks' import { tasksApi } from '../api/tasks'
import { financeApi } from '../api/finance'
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth'
import Navigation from '../components/Navigation' import Navigation from '../components/Navigation'
import CreateTaskModal from '../components/CreateTaskModal' import CreateTaskModal from '../components/CreateTaskModal'
@@ -96,6 +97,11 @@ export default function Home() {
queryFn: tasksApi.today, queryFn: tasksApi.today,
}) })
const { data: financeSummary } = useQuery({
queryKey: ["finance-summary"],
queryFn: () => financeApi.getSummary(),
})
useEffect(() => { useEffect(() => {
if (habits.length > 0) { if (habits.length > 0) {
loadTodayLogs() loadTodayLogs()
@@ -301,6 +307,27 @@ export default function Home() {
)} )}
{/* Tasks */} {/* Tasks */}
{/* Finance Summary */}
{financeSummary && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="card p-5">
<h2 className="font-semibold text-gray-900 dark:text-white mb-3">💰 Баланс</h2>
<div className="grid grid-cols-3 gap-3">
<div className="text-center">
<p className="text-lg font-bold text-green-500">+{(financeSummary.total_income || 0).toLocaleString("ru-RU")} </p>
<p className="text-xs text-gray-500">Доходы</p>
</div>
<div className="text-center">
<p className="text-lg font-bold text-red-500">-{(financeSummary.total_expense || 0).toLocaleString("ru-RU")} </p>
<p className="text-xs text-gray-500">Расходы</p>
</div>
<div className="text-center">
<p className={"text-lg font-bold " + ((financeSummary.balance || 0) >= 0 ? "text-primary-500" : "text-red-500")}>{(financeSummary.balance || 0).toLocaleString("ru-RU")} </p>
<p className="text-xs text-gray-500">Баланс</p>
</div>
</div>
</motion.div>
)}
{(activeTasks.length > 0 || !tasksLoading) && ( {(activeTasks.length > 0 || !tasksLoading) && (
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">

View File

@@ -147,7 +147,7 @@ const SectionHeader = ({ icon: Icon, title, subtitle }) => (
</div> </div>
) )
export default function Stats() { export default function Stats({ embedded = false }) {
const [selectedHabitId, setSelectedHabitId] = useState(null) const [selectedHabitId, setSelectedHabitId] = useState(null)
const [allHabitLogs, setAllHabitLogs] = useState({}) const [allHabitLogs, setAllHabitLogs] = useState({})
const [allHabitStats, setAllHabitStats] = useState({}) const [allHabitStats, setAllHabitStats] = useState({})
@@ -349,14 +349,14 @@ export default function Stats() {
}, [heatmapData]) }, [heatmapData])
return ( return (
<div className="min-h-screen bg-gray-950 pb-24 transition-colors duration-300"> <div className={embedded ? "" : "min-h-screen bg-gray-950 pb-24 transition-colors duration-300"}>
{/* Gradient Background */} {/* Gradient Background */}
<div className="fixed inset-0 pointer-events-none"> <div className="fixed inset-0 pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" /> <div className="absolute top-0 left-1/4 w-96 h-96 bg-primary-500/10 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" /> <div className="absolute bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
</div> </div>
<header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20"> {!embedded && <header className="relative bg-gray-900/50 backdrop-blur-xl border-b border-gray-800/50 sticky top-0 z-20">
<div className="max-w-lg mx-auto px-5 py-5"> <div className="max-w-lg mx-auto px-5 py-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25"> <div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-primary-500 to-teal-600 flex items-center justify-center shadow-lg shadow-primary-500/25">
@@ -371,7 +371,7 @@ export default function Stats() {
</div> </div>
</div> </div>
</div> </div>
</header> </header>}
<main className="relative max-w-lg mx-auto px-5 py-6 space-y-8"> <main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
@@ -689,7 +689,7 @@ export default function Stats() {
)} )}
</main> </main>
<Navigation /> {!embedded && <Navigation />}
</div> </div>
) )
} }

View File

@@ -32,7 +32,7 @@ function formatDueDate(dateStr) {
return format(date, 'd MMM', { locale: ru }) return format(date, 'd MMM', { locale: ru })
} }
export default function Tasks() { export default function Tasks({ embedded = false }) {
const [showCreate, setShowCreate] = useState(false) const [showCreate, setShowCreate] = useState(false)
const [editingTask, setEditingTask] = useState(null) const [editingTask, setEditingTask] = useState(null)
const [filter, setFilter] = useState('active') const [filter, setFilter] = useState('active')
@@ -68,8 +68,8 @@ export default function Tasks() {
} }
return ( return (
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"> <div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10"> {!embedded && <header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4"> <div className="max-w-lg mx-auto px-4 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</h1> <h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Задачи</h1>
@@ -99,7 +99,7 @@ export default function Tasks() {
))} ))}
</div> </div>
</div> </div>
</header> </header>}
<main className="max-w-lg mx-auto px-4 py-6"> <main className="max-w-lg mx-auto px-4 py-6">
{isLoading ? ( {isLoading ? (
@@ -145,7 +145,7 @@ export default function Tasks() {
)} )}
</main> </main>
<Navigation /> {!embedded && <Navigation />}
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} /> <CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} /> <EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
</div> </div>

52
src/pages/Tracker.jsx Normal file
View File

@@ -0,0 +1,52 @@
import { useState, lazy, Suspense } from "react"
import Navigation from "../components/Navigation"
// Import pages as components (they render their own content but we strip their Navigation)
import HabitsContent from "./Habits"
import TasksContent from "./Tasks"
import StatsContent from "./Stats"
const tabs = [
{ key: "habits", label: "Привычки", icon: "🎯" },
{ key: "tasks", label: "Задачи", icon: "✅" },
{ key: "stats", label: "Статистика", icon: "📊" },
]
export default function Tracker() {
const [activeTab, setActiveTab] = useState("habits")
return (
<div className="min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24">
<header className="bg-white/70 dark:bg-gray-900/70 backdrop-blur-xl border-b border-gray-100/50 dark:border-gray-800 sticky top-0 z-10">
<div className="max-w-lg mx-auto px-4 py-4">
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">
📊 Трекер
</h1>
</div>
<div className="max-w-lg mx-auto px-4 pb-3 flex gap-2">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setActiveTab(t.key)}
className={`flex-1 py-2 rounded-xl text-sm font-semibold transition ${
activeTab === t.key
? "bg-primary-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
}`}
>
{t.icon} {t.label}
</button>
))}
</div>
</header>
<div>
{activeTab === "habits" && <HabitsContent embedded />}
{activeTab === "tasks" && <TasksContent embedded />}
{activeTab === "stats" && <StatsContent embedded />}
</div>
<Navigation />
</div>
)
}

11
vitest.config.js Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/__tests__/setup.js',
},
})