feat: Модуль Финансы + Трекер + CI/CD #1
@@ -25,7 +25,3 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to dev
|
||||
run: |
|
||||
echo "Build successful - dev deploy would happen via docker"
|
||||
|
||||
@@ -19,7 +19,3 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Production deploy would happen via docker"
|
||||
|
||||
13
Dockerfile.dev
Normal file
13
Dockerfile.dev
Normal 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;"]
|
||||
16
docker-compose.dev.yml
Normal file
16
docker-compose.dev.yml
Normal 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
1739
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
33
package.json
33
package.json
@@ -11,31 +11,36 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"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-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.5",
|
||||
"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"
|
||||
"recharts": "^2.12.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"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-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.33",
|
||||
"storybook": "^8.5.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"vite": "^5.0.12",
|
||||
"@storybook/react": "^8.5.0",
|
||||
"@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"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
27
src/App.jsx
27
src/App.jsx
@@ -12,6 +12,8 @@ import ResetPassword from "./pages/ResetPassword"
|
||||
import ForgotPassword from "./pages/ForgotPassword"
|
||||
import Stats from "./pages/Stats"
|
||||
import Settings from "./pages/Settings"
|
||||
import Finance from "./pages/Finance"
|
||||
import Tracker from "./pages/Tracker"
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
@@ -92,11 +94,20 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tracker"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Tracker />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Legacy routes redirect to tracker */}
|
||||
<Route
|
||||
path="/habits"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Habits />
|
||||
<Navigate to="/tracker" replace />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -104,7 +115,15 @@ export default function App() {
|
||||
path="/tasks"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Tasks />
|
||||
<Navigate to="/tracker" replace />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/stats"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Navigate to="/tracker" replace />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
@@ -117,10 +136,10 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/stats"
|
||||
path="/finance"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Stats />
|
||||
<Finance />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
267
src/__tests__/api.test.js
Normal file
267
src/__tests__/api.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
134
src/__tests__/auth.store.test.js
Normal file
134
src/__tests__/auth.store.test.js
Normal 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)
|
||||
})
|
||||
})
|
||||
116
src/__tests__/finance-api.test.js
Normal file
116
src/__tests__/finance-api.test.js
Normal 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
1
src/__tests__/setup.js
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
64
src/__tests__/tracker.test.jsx
Normal file
64
src/__tests__/tracker.test.jsx
Normal 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
47
src/api/finance.js
Normal 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
|
||||
},
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
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"
|
||||
|
||||
const OWNER_ID = 1
|
||||
|
||||
export default function Navigation() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isOwner = user?.id === OWNER_ID
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", icon: Home, label: "Сегодня" },
|
||||
{ to: "/habits", icon: ListChecks, label: "Привычки" },
|
||||
{ to: "/tasks", icon: CheckSquare, label: "Задачи" },
|
||||
{ to: "/stats", icon: BarChart3, label: "Статистика" },
|
||||
{ to: "/", icon: Home, label: "Главная" },
|
||||
{ to: "/tracker", icon: BarChart3, label: "Трекер" },
|
||||
isOwner && { to: "/finance", icon: Wallet, label: "Финансы" },
|
||||
{ to: "/savings", icon: PiggyBank, label: "Накопления" },
|
||||
{ to: "/settings", icon: Settings, label: "Настройки" },
|
||||
]
|
||||
].filter(Boolean)
|
||||
|
||||
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">
|
||||
@@ -20,16 +25,17 @@ export default function Navigation() {
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) =>
|
||||
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
|
||||
? "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"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={18} />
|
||||
<Icon size={20} />
|
||||
<span className="text-[10px] font-medium">{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
207
src/components/finance/AddTransactionModal.jsx
Normal file
207
src/components/finance/AddTransactionModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
238
src/components/finance/CategoriesManager.jsx
Normal file
238
src/components/finance/CategoriesManager.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
231
src/components/finance/FinanceAnalytics.jsx
Normal file
231
src/components/finance/FinanceAnalytics.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
197
src/components/finance/FinanceDashboard.jsx
Normal file
197
src/components/finance/FinanceDashboard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
167
src/components/finance/TransactionList.jsx
Normal file
167
src/components/finance/TransactionList.jsx
Normal 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
122
src/pages/Finance.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import EditHabitModal from '../components/EditHabitModal'
|
||||
import Navigation from '../components/Navigation'
|
||||
import clsx from 'clsx'
|
||||
|
||||
export default function Habits() {
|
||||
export default function Habits({ embedded = false }) {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [editingHabit, setEditingHabit] = useState(null)
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
@@ -67,8 +67,8 @@ export default function Habits() {
|
||||
const archivedList = habits.filter(h => h.is_archived)
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
|
||||
{!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>
|
||||
<h1 className="text-xl font-display font-bold text-gray-900 dark:text-white">Мои привычки</h1>
|
||||
@@ -79,7 +79,7 @@ export default function Habits() {
|
||||
Новая
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</header>}
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6 space-y-6">
|
||||
{isLoading ? (
|
||||
@@ -168,7 +168,7 @@ export default function Habits() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
{!embedded && <Navigation />}
|
||||
<CreateHabitModal open={showCreateModal} onClose={() => setShowCreateModal(false)} />
|
||||
<EditHabitModal open={!!editingHabit} onClose={() => setEditingHabit(null)} habit={editingHabit} />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { format, startOfWeek, differenceInDays, parseISO, isToday, isTomorrow, i
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { habitsApi } from '../api/habits'
|
||||
import { tasksApi } from '../api/tasks'
|
||||
import { financeApi } from '../api/finance'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import Navigation from '../components/Navigation'
|
||||
import CreateTaskModal from '../components/CreateTaskModal'
|
||||
@@ -96,6 +97,11 @@ export default function Home() {
|
||||
queryFn: tasksApi.today,
|
||||
})
|
||||
|
||||
|
||||
const { data: financeSummary } = useQuery({
|
||||
queryKey: ["finance-summary"],
|
||||
queryFn: () => financeApi.getSummary(),
|
||||
})
|
||||
useEffect(() => {
|
||||
if (habits.length > 0) {
|
||||
loadTodayLogs()
|
||||
@@ -301,6 +307,27 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{/* 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) && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
@@ -147,7 +147,7 @@ const SectionHeader = ({ icon: Icon, title, subtitle }) => (
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function Stats() {
|
||||
export default function Stats({ embedded = false }) {
|
||||
const [selectedHabitId, setSelectedHabitId] = useState(null)
|
||||
const [allHabitLogs, setAllHabitLogs] = useState({})
|
||||
const [allHabitStats, setAllHabitStats] = useState({})
|
||||
@@ -349,14 +349,14 @@ export default function Stats() {
|
||||
}, [heatmapData])
|
||||
|
||||
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 */}
|
||||
<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 bottom-1/4 right-0 w-80 h-80 bg-teal-500/5 rounded-full blur-3xl" />
|
||||
</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="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">
|
||||
@@ -371,7 +371,7 @@ export default function Stats() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>}
|
||||
|
||||
<main className="relative max-w-lg mx-auto px-5 py-6 space-y-8">
|
||||
|
||||
@@ -689,7 +689,7 @@ export default function Stats() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
{!embedded && <Navigation />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function formatDueDate(dateStr) {
|
||||
return format(date, 'd MMM', { locale: ru })
|
||||
}
|
||||
|
||||
export default function Tasks() {
|
||||
export default function Tasks({ embedded = false }) {
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState(null)
|
||||
const [filter, setFilter] = useState('active')
|
||||
@@ -68,8 +68,8 @@ export default function Tasks() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="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">
|
||||
<div className={embedded ? "" : "min-h-screen bg-surface-50 dark:bg-gray-950 gradient-mesh pb-24 transition-colors duration-300"}>
|
||||
{!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="flex items-center justify-between">
|
||||
<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>
|
||||
</header>
|
||||
</header>}
|
||||
|
||||
<main className="max-w-lg mx-auto px-4 py-6">
|
||||
{isLoading ? (
|
||||
@@ -145,7 +145,7 @@ export default function Tasks() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Navigation />
|
||||
{!embedded && <Navigation />}
|
||||
<CreateTaskModal open={showCreate} onClose={() => setShowCreate(false)} />
|
||||
<EditTaskModal open={!!editingTask} onClose={() => setEditingTask(null)} task={editingTask} />
|
||||
</div>
|
||||
|
||||
52
src/pages/Tracker.jsx
Normal file
52
src/pages/Tracker.jsx
Normal 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
11
vitest.config.js
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user