diff --git a/DOCKER_ORCHESTRATION.md b/DOCKER_ORCHESTRATION.md index 4f7a5693..f1abcf74 100644 --- a/DOCKER_ORCHESTRATION.md +++ b/DOCKER_ORCHESTRATION.md @@ -285,5 +285,5 @@ docker-compose ps --- -**Дата создания**: 19 февраля 2025 +**Дата создания**: 19 февраля 2025 **Версия**: 1.0 diff --git a/corp.front/.dockerignore b/corp.front/.dockerignore new file mode 100644 index 00000000..13e59b48 --- /dev/null +++ b/corp.front/.dockerignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Next.js +.next/ +out/ +build +dist + +# Environment +.env*.local +.env + +# Testing +coverage + +# Misc +.DS_Store +*.pem + +# Debug +*.log + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Vercel +.vercel diff --git a/corp.front/.env.example b/corp.front/.env.example new file mode 100644 index 00000000..f0f5164d --- /dev/null +++ b/corp.front/.env.example @@ -0,0 +1,5 @@ +# Backend API URL +NEXT_PUBLIC_API_URL=http://localhost:8000 + +# WebSocket URL for real-time features +NEXT_PUBLIC_WS_URL=ws://localhost:8000 diff --git a/corp.front/Dockerfile b/corp.front/Dockerfile new file mode 100644 index 00000000..75ed2dd4 --- /dev/null +++ b/corp.front/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json package-lock.json* ./ +RUN npm ci + +# Copy source files +COPY . . + +# Set environment variables for build +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# Build the application +RUN npm run build + +# Production stage +FROM node:20-alpine AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +# Copy necessary files from builder +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json + +# Set correct permissions +RUN chown -R nextjs:nodejs /app + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# Start Next.js server +CMD ["npm", "start"] diff --git a/corp.front/INTEGRATION_GUIDE.md b/corp.front/INTEGRATION_GUIDE.md new file mode 100644 index 00000000..31c6835d --- /dev/null +++ b/corp.front/INTEGRATION_GUIDE.md @@ -0,0 +1,410 @@ +# Frontend Integration Guide + +## Обзор + +Фронтенд corp.front полностью интегрирован с Django Backend через REST API. Используется Next.js 16 с TypeScript и Tailwind CSS. + +## Структура проекта + +``` +corp.front/ +├── src/ +│ ├── app/ # Next.js страницы +│ │ ├── page.tsx # Главная (Лента) ✅ Интегрирован +│ │ ├── employees/ # Сотрудники ✅ Интегрирован +│ │ ├── departments/ # Отделы +│ │ ├── documents/ # Документы +│ │ ├── messages/ # Сообщения +│ │ ├── requests/ # Заявки +│ │ ├── finances/ # Финансы +│ │ └── login/ # Вход +│ ├── lib/ +│ │ └── api.ts # API клиент ✅ +│ ├── types/ +│ │ └── api.ts # TypeScript типы ✅ +│ └── hooks/ +│ └── useApi.ts # React хуки для API ✅ +``` + +## API клиент + +### Базовая конфигурация + +API клиент автоматически: +- Добавляет JWT токен к запросам +- Обрабатывает ошибки авторизации +- Сохраняет токены в localStorage +- Перенаправляет на /login при 401 + +### Использование + +```typescript +import apiClient from '@/lib/api'; + +// Авторизация +const { access, refresh, user } = await apiClient.login({ + username: 'user@example.com', + password: 'password' +}); + +// Получение данных +const employees = await apiClient.getEmployees(); +const posts = await apiClient.getPosts(); +``` + +## React хуки + +Используйте готовые хуки из `@/hooks/useApi`: + +```tsx +import { useCurrentUser, useEmployees, usePosts } from '@/hooks/useApi'; + +function MyComponent() { + const { user, loading, error } = useCurrentUser(); + const { employees } = useEmployees({ search: 'John' }); + const { posts } = usePosts({ page: 1 }); + + if (loading) return
Загрузка...
; + if (error) return
Ошибка: {error.message}
; + + return
{/* UI */}
; +} +``` + +### Доступные хуки + +- `useCurrentUser()` - текущий пользователь +- `useEmployees(params?)` - список сотрудников +- `useDepartments()` - список отделов +- `usePosts(params?)` - посты ленты +- `useDocuments(params?)` - документы +- `useRequests(params?)` - заявки +- `useChats()` - чаты +- `useNotifications()` - уведомления + +## TypeScript типы + +Все типы данных определены в `@/types/api.ts`: + +```typescript +import type { User, Post, Department, Document } from '@/types/api'; +``` + +## Интегрированные страницы + +### ✅ Главная страница (Лента) + +**Файл**: `src/app/page.tsx` + +**Функции**: +- Загрузка постов из API +- Отображение автора, времени, контента +- Лайки и комментарии +- Теги постов + +**Используемые хуки**: +- `usePosts()` - получение постов +- `useCurrentUser()` - текущий пользователь + +### ✅ Сотрудники + +**Файл**: `src/app/employees/page.tsx` + +**Функции**: +- Список всех сотрудников +- Поиск по имени +- Статус (онлайн/оффлайн) +- Должность и отдел + +**Используемые хуки**: +- `useEmployees({ search })` - поиск сотрудников +- `useCurrentUser()` - текущий пользователь + +## Переменные окружения + +### Для локальной разработки + +`.env.local`: +```env +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_WS_URL=ws://localhost:8000 +``` + +### Для Docker + +`.env.local` (автоматически используется в docker-compose): +```env +NEXT_PUBLIC_API_URL=http://web:8000 +NEXT_PUBLIC_WS_URL=ws://web:8000 +``` + +## Запуск + +### Локальная разработка + +```bash +cd corp.front +npm install +npm run dev +``` + +Доступно на: http://localhost:3000 + +### Docker + +```bash +# Из корня проекта +docker-compose up -d frontend +``` + +Доступно через nginx: http://localhost + +## Интеграция новых страниц + +### Шаг 1: Создать страницу + +```tsx +// src/app/mypage/page.tsx +"use client"; + +import { useMyData } from '@/hooks/useApi'; + +export default function MyPage() { + const { data, loading, error } = useMyData(); + + if (loading) return
Загрузка...
; + if (error) return
Ошибка: {error.message}
; + + return ( +
+ {data.map(item => ( +
{item.name}
+ ))} +
+ ); +} +``` + +### Шаг 2: Добавить метод в API клиент + +```typescript +// src/lib/api.ts +class ApiClient { + // ... existing methods + + async getMyData() { + return this.request('/mydata/'); + } +} +``` + +### Шаг 3: Создать хук (опционально) + +```typescript +// src/hooks/useApi.ts +export function useMyData() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchData() { + try { + const result = await apiClient.getMyData(); + setData(result); + } catch (err) { + setError(err); + } finally { + setLoading(false); + } + } + fetchData(); + }, []); + + return { data, loading, error }; +} +``` + +## API Endpoints + +Полный список доступных эндпоинтов: + +### Auth +- `POST /api/auth/token/` - Login +- `POST /api/auth/token/refresh/` - Refresh token +- `POST /api/v1/auth/register/` - Регистрация + +### Employees +- `GET /api/v1/employees/` - Список сотрудников +- `GET /api/v1/employees/{id}/` - Детали сотрудника +- `GET /api/v1/employees/me/` - Текущий пользователь + +### Departments +- `GET /api/v1/departments/` - Список отделов +- `GET /api/v1/departments/{id}/` - Детали отдела + +### Feed +- `GET /api/v1/posts/` - Посты ленты +- `POST /api/v1/posts/` - Создать пост +- `POST /api/v1/posts/{id}/like/` - Лайк поста +- `GET /api/v1/comments/` - Комментарии +- `POST /api/v1/comments/` - Создать комментарий + +### Documents +- `GET /api/v1/documents/` - Документы +- `GET /api/v1/documents/{id}/` - Детали документа + +### Requests +- `GET /api/v1/requests/` - Заявки +- `POST /api/v1/requests/` - Создать заявку +- `GET /api/v1/requests/{id}/` - Детали заявки + +### Communications +- `GET /api/v1/communications/chats/` - Чаты +- `GET /api/v1/communications/messages/` - Сообщения +- `POST /api/v1/communications/messages/` - Отправить сообщение + +### Calendar +- `GET /api/v1/calendar/events/` - События календаря +- `GET /api/v1/calendar/calendars/` - Календари + +### Notifications +- `GET /api/v1/notifications/` - Уведомления +- `POST /api/v1/notifications/{id}/mark_read/` - Отметить прочитанным +- `POST /api/v1/notifications/mark_all_read/` - Отметить все прочитанными + +## Аутентификация + +### Вход + +```typescript +import apiClient from '@/lib/api'; + +async function handleLogin(username: string, password: string) { + try { + const { access, refresh, user } = await apiClient.login({ + username, + password + }); + + // Токен автоматически сохранён + // Перенаправить пользователя + router.push('/'); + } catch (error) { + console.error('Login failed:', error); + } +} +``` + +### Выход + +```typescript +import apiClient from '@/lib/api'; + +function handleLogout() { + apiClient.clearToken(); + router.push('/login'); +} +``` + +### Защищённые страницы + +```tsx +"use client"; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import apiClient from '@/lib/api'; + +export default function ProtectedPage() { + const router = useRouter(); + + useEffect(() => { + const token = apiClient.getToken(); + if (!token) { + router.push('/login'); + } + }, []); + + return
Protected content
; +} +``` + +## Обработка ошибок + +API клиент автоматически обрабатывает: + +- **401 Unauthorized**: Очищает токены и перенаправляет на `/login` +- **Network errors**: Выбрасывает исключение с сообщением + +Пример обработки в компонентах: + +```tsx +const { data, loading, error } = useMyData(); + +if (loading) return ; + +if (error) { + return ( +
+

Ошибка загрузки данных

+

{error.message}

+ +
+ ); +} +``` + +## WebSocket (для будущей реализации) + +Для real-time обновлений можно добавить WebSocket соединение: + +```typescript +const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000'; + +const socket = new WebSocket(`${WS_URL}/ws/notifications/`); + +socket.onmessage = (event) => { + const data = JSON.parse(event.data); + // Обработать уведомление +}; +``` + +## Рекомендации + +1. **Всегда используйте хуки** для получения данных из API +2. **Обрабатывайте loading и error** состояния в UI +3. **Используйte TypeScript типы** для type-safety +4. **Не сохраняйте чувствительные данные** в localStorage кроме токенов +5. **Валидируйте данные** перед отправкой на сервер + +## Troubleshooting + +###CORS ошибки + +Убедитесь, что backend настроен с правильными CORS заголовками: + +```python +# backend/eusrr_backend/settings.py +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://localhost", +] +``` + +### 401 Unauthorized + +- Проверьте, что токен действителен +- Убедитесь, что backend запущен +- Проверьте переменные окружения + +### Ошибка сети + +- Проверьте, что backend доступен +- Проверьте URL в `NEXT_PUBLIC_API_URL` +- Проверьте docker network, если используете docker-compose + +--- + +**Дата создания**: 19 февраля 2026 +**Версия**: 1.0 diff --git a/corp.front/next.config.ts b/corp.front/next.config.ts new file mode 100644 index 00000000..1d2435c6 --- /dev/null +++ b/corp.front/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + // Rewrites не нужны - в production используйте nginx (http://localhost) + // Для dev можно запустить backend локально на localhost:8000 + + // Environment variables + env: { + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL, + }, +}; + +export default nextConfig; diff --git a/corp.front/src/app/employees/page.tsx b/corp.front/src/app/employees/page.tsx new file mode 100644 index 00000000..be6aacfc --- /dev/null +++ b/corp.front/src/app/employees/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { Bell, Building2, FileSignature, FileText, Home as HomeIcon, MessageSquare, Search, Users, Wallet } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { useEmployees, useCurrentUser } from "@/hooks/useApi"; +import { useState, useEffect } from "react"; +import { apiClient } from "@/lib/api"; + +const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; + +const calendarWeeks = [ + ["", "", "", "", "1", "2", "3"], + ["4", "5", "6", "7", "8", "9", "10"], + ["11", "12", "13", "14", "15", "16", "17"], + ["18", "19", "20", "21", "22", "23", "24"], + ["25", "26", "27", "28", "", "", ""], +]; + +export default function EmployeesPage() { + const pathname = usePathname(); + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + const { employees, loading, error } = useEmployees({ search: searchQuery }); + const { user } = useCurrentUser(); + + // Проверка авторизации + useEffect(() => { + const token = apiClient.getToken(); + if (!token) { + router.push('/login'); + } + }, [router]); + + const sortedEmployees = employees.sort((a, b) => + `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`, "ru") + ); + + const navLinkClass = (href: string) => + `flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700" + }`; + + const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400"); + + return ( +
+
+
+
+ Логотип +
+
+
+ + setSearchQuery(e.target.value)} + className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + placeholder="Поиск сотрудников..." + /> +
+
+
+ +
КМ
+
+
+
+ +
+ + +
+
+
+
+

Команда

+

Сотрудники

+
+
Всего: {employees.length}
+
+
+ +
+ {loading && ( +
+

Загрузка сотрудников...

+
+ )} + {error && ( +
+

Ошибка загрузки: {error.message}

+
+ )} + {!loading && !error && employees.length === 0 && ( +
+

Сотрудники не найдены

+
+ )} + {!loading && sortedEmployees.map((employee) => { + const isOnline = employee.is_active; + const statusText = isOnline ? "Онлайн" : "Оффлайн"; + const statusColor = isOnline ? "text-green-600" : "text-gray-500"; + const dotColor = isOnline ? "bg-green-500" : "bg-gray-400"; + const fullName = `${employee.first_name} ${employee.last_name}`; + + return ( +
+
+ {employee.first_name[0]}{employee.last_name[0]} +
+
+

{fullName}

+

{employee.position?.name || 'Не указана'}

+
+ + {statusText} + {employee.department && ( + <> + • + {employee.department.name} + + )} +
+
+
+ ); + })} +
+
+ + +
+
+ ); +} diff --git a/corp.front/src/app/login/page.tsx b/corp.front/src/app/login/page.tsx new file mode 100644 index 00000000..69387fa2 --- /dev/null +++ b/corp.front/src/app/login/page.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { apiClient } from '@/lib/api'; + +export default function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const response = await apiClient.login({ email, password }); + console.log('Успешная авторизация:', response.user); + + // Перенаправляем на главную страницу + router.push('/'); + router.refresh(); // Обновляем данные + } catch (err) { + console.error('Ошибка авторизации:', err); + setError('Неверный email или пароль'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

+ Войти в систему EUSRR +

+

+ Используйте учетные данные LDAP для входа +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + placeholder="example@company.com или +79001234567" + required + disabled={loading} + /> +
+
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100" + placeholder="••••••••" + required + disabled={loading} + /> +
+ +
+ +
+ + Создать аккаунт + +
+ +

+ Для входа используйте корпоративные учетные данные +

+
+
+
+ ); +} diff --git a/corp.front/src/app/page.tsx b/corp.front/src/app/page.tsx new file mode 100644 index 00000000..b6401d00 --- /dev/null +++ b/corp.front/src/app/page.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { Bell, Bookmark, Building2, FileSignature, FileText, Heart, HeartHandshake, Home as HomeIcon, MessageSquare, Search, Star, Users, Wallet } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter } from "next/navigation"; +import { usePosts, useCurrentUser } from "@/hooks/useApi"; +import { useEffect } from "react"; +import { apiClient } from "@/lib/api"; + +function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInMs = now.getTime() - date.getTime(); + const diffInMinutes = Math.floor(diffInMs / (1000 * 60)); + const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60)); + const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24)); + + if (diffInMinutes < 60) return `${diffInMinutes} минут назад`; + if (diffInHours < 24) return `${diffInHours} часов назад`; + return `${diffInDays} дней назад`; +} + +const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]; + +const calendarWeeks = [ + ["", "", "", "", "1", "2", "3"], + ["4", "5", "6", "7", "8", "9", "10"], + ["11", "12", "13", "14", "15", "16", "17"], + ["18", "19", "20", "21", "22", "23", "24"], + ["25", "26", "27", "28", "", "", ""], +]; + +export default function Home() { + const pathname = usePathname(); + const router = useRouter(); + const { posts, loading, error } = usePosts(); + const { user } = useCurrentUser(); + + // Проверка авторизации при загрузке + useEffect(() => { + const token = apiClient.getToken(); + if (!token) { + router.push('/login'); + } + }, [router]); + + const navLinkClass = (href: string) => + `flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700" + }`; + + const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400"); + + return ( +
+
+
+
+ Логотип + +
+
+
+ + +
+
+ +
+ +
+ КМ +
+
+
+
+ +
+ + +
+
+
+
+ КМ +
+
+ + +
+
+
+ Статья + Фото + Ссылка + Опрос + Событие +
+
+ +
+ {loading && ( +
+

Загрузка...

+
+ )} + {error && ( +
+

Ошибка загрузки: {error.message}

+

Проверьте подключение к серверу

+
+ )} + {!loading && !error && posts.length === 0 && ( +
+

Нет постов в ленте

+
+ )} + {!loading && posts.map((post) => ( +
+
+
+
+ {post.author.first_name[0]}{post.author.last_name[0]} +
+
+

{post.author.first_name} {post.author.last_name}

+

{formatTimeAgo(post.created_at)}

+
+
+
+

{post.content}

+ {post.tags && post.tags.length > 0 && ( +
+ {post.tags.map((tag) => ( + + #{tag} + + ))} +
+ )} +
+ + + +
+
+ ))} +
+
+ + +
+
+ ); +} diff --git a/corp.front/src/app/register/page.tsx b/corp.front/src/app/register/page.tsx new file mode 100644 index 00000000..3e46b971 --- /dev/null +++ b/corp.front/src/app/register/page.tsx @@ -0,0 +1,262 @@ +'use client'; + +import { useState, FormEvent } from 'react'; +import { useRouter } from 'next/navigation'; +import { apiClient } from '@/lib/api'; +import Link from "next/link"; + +export default function Register() { + const [formData, setFormData] = useState({ + first_name: '', + last_name: '', + middle_name: '', + email: '', + phone_number: '', + telegram: '', + password: '', + confirmPassword: '', + }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value, + }); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(''); + + // Валидация + if (formData.password !== formData.confirmPassword) { + setError('Пароли не совпадают'); + return; + } + + if (!formData.email && !formData.phone_number && !formData.telegram) { + setError('Укажите хотя бы один способ связи: email, телефон или Telegram'); + return; + } + + setLoading(true); + + try { + const registerData = { + email: formData.email, + phone_number: formData.phone_number, + password: formData.password, + first_name: formData.first_name, + last_name: formData.last_name, + middle_name: formData.middle_name || undefined, + telegram: formData.telegram || undefined, + }; + + await apiClient.register(registerData); + + // После успешной регистрации авторизуемся + const loginData = { + email: formData.email, + phone_number: formData.phone_number, + password: formData.password + }; + await apiClient.login(loginData); + + router.push('/'); + } catch (err: any) { + console.error('Ошибка регистрации:', err); + setError(err.message || 'Ошибка при регистрации. Проверьте введенные данные.'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

+ Регистрация +

+

+ Создайте новый аккаунт, указав имя и контактные данные. +

+ +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ Обязательные поля: Имя, Фамилия, Пароль
+ Контакты: Укажите хотя бы один (Email, Телефон или Telegram) +
+ + +
+ +
+ + Уже есть аккаунт? Войти + +
+ +

+ Регистрируясь, вы принимаете условия использования и подтверждаете ознакомление с политикой конфиденциальности. +

+
+
+
+ ); +} diff --git a/corp.front/src/hooks/useApi.ts b/corp.front/src/hooks/useApi.ts new file mode 100644 index 00000000..7e814b56 --- /dev/null +++ b/corp.front/src/hooks/useApi.ts @@ -0,0 +1,218 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import apiClient from '@/lib/api'; +import type { User, Post, PaginatedResponse } from '@/types/api'; + +export function useCurrentUser() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchUser() { + try { + const data = await apiClient.getCurrentUser(); + setUser(data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchUser(); + }, []); + + return { user, loading, error }; +} + +export function useEmployees(params?: { search?: string; department?: string }) { + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchEmployees() { + try { + setLoading(true); + const data = await apiClient.getEmployees(params); + setEmployees(data.results || data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchEmployees(); + }, [params?.search, params?.department]); + + return { employees, loading, error, refetch: () => { } }; +} + +export function useDepartments() { + const [departments, setDepartments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchDepartments() { + try { + const data = await apiClient.getDepartments(); + setDepartments(data.results || data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchDepartments(); + }, []); + + return { departments, loading, error }; +} + +export function usePosts(params?: { page?: number; search?: string }) { + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + + useEffect(() => { + async function fetchPosts() { + try { + setLoading(true); + const data: PaginatedResponse = await apiClient.getPosts(params); + setPosts(data.results); + setHasMore(!!data.next); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchPosts(); + }, [params?.page, params?.search]); + + return { posts, loading, error, hasMore, refetch: () => { } }; +} + +export function useDocuments(params?: { search?: string; type?: string }) { + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchDocuments() { + try { + const data = await apiClient.getDocuments(params); + setDocuments(data.results || data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchDocuments(); + }, [params?.search, params?.type]); + + return { documents, loading, error }; +} + +export function useRequests(params?: { status?: string; type?: string }) { + const [requests, setRequests] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchRequests() { + try { + const data = await apiClient.getRequests(params); + setRequests(data.results || data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchRequests(); + }, [params?.status, params?.type]); + + return { requests, loading, error }; +} + +export function useChats() { + const [chats, setChats] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchChats() { + try { + const data = await apiClient.getChats(); + setChats(data.results || data); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchChats(); + }, []); + + return { chats, loading, error }; +} + +export function useNotifications() { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [unreadCount, setUnreadCount] = useState(0); + + useEffect(() => { + async function fetchNotifications() { + try { + const data = await apiClient.getNotifications(); + const notifs = data.results || data; + setNotifications(notifs); + setUnreadCount(notifs.filter((n: any) => !n.is_read).length); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + } + + fetchNotifications(); + }, []); + + const markAsRead = async (id: number) => { + try { + await apiClient.markNotificationAsRead(id); + setNotifications(prev => + prev.map(n => (n.id === id ? { ...n, is_read: true } : n)) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + } catch (err) { + console.error('Failed to mark notification as read:', err); + } + }; + + const markAllAsRead = async () => { + try { + await apiClient.markAllNotificationsAsRead(); + setNotifications(prev => prev.map(n => ({ ...n, is_read: true }))); + setUnreadCount(0); + } catch (err) { + console.error('Failed to mark all notifications as read:', err); + } + }; + + return { notifications, loading, error, unreadCount, markAsRead, markAllAsRead }; +} diff --git a/corp.front/src/types/api.ts b/corp.front/src/types/api.ts new file mode 100644 index 00000000..e1acd0a4 --- /dev/null +++ b/corp.front/src/types/api.ts @@ -0,0 +1,164 @@ +// Common types +export interface User { + id: number; + email: string; + phone_number?: string; + first_name: string; + last_name: string; + middle_name?: string; + position?: Position; + department?: Department; + avatar?: string; + is_active: boolean; + telegram?: string; +} + +export interface Department { + id: number; + name: string; + description?: string; + parent?: number; + head?: User; + employees_count?: number; +} + +export interface Position { + id: number; + name: string; + description?: string; +} + +// Feed types +export interface Post { + id: number; + author: User; + content: string; + tags?: string[]; + created_at: string; + updated_at: string; + likes_count: number; + comments_count: number; + is_liked?: boolean; +} + +export interface Comment { + id: number; + post: number; + author: User; + content: string; + created_at: string; + updated_at: string; +} + +// Documents types +export interface Document { + id: number; + title: string; + description?: string; + file?: string; + document_type: string; + created_by: User; + created_at: string; + updated_at: string; + tags?: string[]; +} + +// Requests types +export interface Request { + id: number; + title: string; + description: string; + request_type: string; + status: 'pending' | 'approved' | 'rejected' | 'in_progress' | 'completed'; + created_by: User; + assigned_to?: User; + created_at: string; + updated_at: string; +} + +// Communications types +export interface Chat { + id: number; + name?: string; + chat_type: 'direct' | 'group' | 'department'; + participants: User[]; + last_message?: Message; + unread_count?: number; + created_at: string; +} + +export interface Message { + id: number; + chat: number; + sender: User; + content: string; + is_read: boolean; + created_at: string; +} + +// Calendar types +export interface CalendarEvent { + id: number; + title: string; + description?: string; + start_time: string; + end_time: string; + location?: string; + calendar: number; + participants?: User[]; + created_by: User; +} + +export interface Calendar { + id: number; + name: string; + description?: string; + color?: string; + is_public: boolean; + owner: User; +} + +// Notifications types +export interface Notification { + id: number; + title: string; + message: string; + notification_type: string; + is_read: boolean; + created_at: string; + link?: string; +} + +// API Response types +export interface PaginatedResponse { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +// Auth types +export interface LoginCredentials { + email?: string; + phone?: string; + phone_number?: string; + password: string; +} + +export interface LoginResponse { + access: string; + refresh: string; + user: User; +} + +export interface RegisterData { + email: string; + phone_number: string; + password: string; + first_name: string; + last_name: string; + middle_name?: string; + telegram?: string; + whatsapp?: string; + wechat?: string; +} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index e67cd57f..b1bd11d0 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,13 +1,15 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - // Rewrites не нужны - в production используйте nginx (http://localhost) - // Для dev можно запустить backend локально на localhost:8000 - + // Отключаем автоматический редирект trailing slash + // Django требует слэш в конце URL, а Next.js по умолчанию его убирает + skipTrailingSlashRedirect: true, + // Environment variables env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL: process.env.NEXT_PUBLIC_WS_URL, + NEXT_PUBLIC_BACKEND_URL: process.env.NEXT_PUBLIC_BACKEND_URL, }, }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 23e44ed7..925ed6d6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", @@ -291,6 +292,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2457,6 +2465,24 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 13ed780f..0225cef5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", + "dev": "cross-env TURBOPACK=0 next dev", + "dev:turbo": "next dev --turbo", "build": "next build", "start": "next start", "lint": "eslint" @@ -19,6 +20,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "cross-env": "^10.1.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", diff --git a/frontend/src/app/api/[...path]/route.ts b/frontend/src/app/api/[...path]/route.ts new file mode 100644 index 00000000..42a42071 --- /dev/null +++ b/frontend/src/app/api/[...path]/route.ts @@ -0,0 +1,86 @@ +// API Route для проксирования всех /api/* запросов на backend +// Это позволяет избежать CORS при локальной разработке + +import { NextRequest, NextResponse } from 'next/server'; + +const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'https://corp.robotail.pro'; + +export async function GET(request: NextRequest) { + return proxyRequest(request, 'GET'); +} + +export async function POST(request: NextRequest) { + return proxyRequest(request, 'POST'); +} + +export async function PUT(request: NextRequest) { + return proxyRequest(request, 'PUT'); +} + +export async function PATCH(request: NextRequest) { + return proxyRequest(request, 'PATCH'); +} + +export async function DELETE(request: NextRequest) { + return proxyRequest(request, 'DELETE'); +} + +async function proxyRequest(request: NextRequest, method: string) { + try { + // Получаем путь из URL запроса + const url = new URL(request.url); + // Убираем /api из начала пути + const apiPath = url.pathname.replace(/^\/api\//, ''); + + // Формируем полный URL к backend + const backendUrl = `${BACKEND_URL}/api/${apiPath}`; + + // Копируем query параметры + const searchParams = url.searchParams.toString(); + const fullUrl = searchParams ? `${backendUrl}?${searchParams}` : backendUrl; + + console.log(`[Proxy] ${method} ${fullUrl}`); + + // Копируем заголовки из оригинального запроса + const headers = new Headers(); + request.headers.forEach((value, key) => { + // Пропускаем заголовки, которые не нужно передавать + if (!['host', 'connection', 'content-length'].includes(key.toLowerCase())) { + headers.set(key, value); + } + }); + + // Получаем тело запроса если есть + const body = method !== 'GET' && method !== 'HEAD' + ? await request.text() + : undefined; + + // Делаем запрос к backend + const response = await fetch(fullUrl, { + method, + headers, + body, + }); + + // Копируем заголовки из ответа backend + const responseHeaders = new Headers(); + response.headers.forEach((value, key) => { + responseHeaders.set(key, value); + }); + + // Возвращаем ответ от backend + const responseBody = await response.text(); + + return new NextResponse(responseBody, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (error: any) { + console.error('[Proxy Error]', error.message); + return NextResponse.json( + { error: 'Ошибка при обращении к backend', message: error.message }, + { status: 502 } + ); + } +} diff --git a/frontend/src/app/employees/page.tsx b/frontend/src/app/employees/page.tsx index c80975c5..93dd4f9f 100644 --- a/frontend/src/app/employees/page.tsx +++ b/frontend/src/app/employees/page.tsx @@ -1,13 +1,60 @@ -import { AppShell, PageHeader } from "../../components/AppShell"; +"use client"; + +import { AppShell, PageHeader } from "../../components/AppShell"; +import { apiClient } from "@/lib/api"; +import { useEffect, useState } from "react"; +import type { User } from "@/types/api"; -const employees = [ - { id: 1, name: "Константин Макаев", role: "Руководитель проектов", status: "Онлайн", department: "ИТ" }, - { id: 2, name: "Виктория Зацарина", role: "Дизайнер", status: "Недавно", department: "Маркетинг" }, - { id: 3, name: "Алексей Пономарёв", role: "Разработчик", status: "Оффлайн", department: "ИТ" }, - { id: 4, name: "Эдуард Иванов", role: "Директор", status: "Онлайн", department: "Финансы" }, -]; export default function EmployeesPage() { - const sortedEmployees = [...employees].sort((a, b) => a.name.localeCompare(b.name, "ru")); + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadEmployees() { + try { + const response = await apiClient.getEmployees(); + setEmployees(response.results); + } catch (err: any) { + console.error('Ошибка загрузки сотрудников:', err); + setError('Не удалось загрузить список сотрудников'); + } finally { + setLoading(false); + } + } + loadEmployees(); + }, []); + + if (loading) { + return ( + + +
+
+
+

Загрузка...

+
+
+
+ ); + } + + if (error) { + return ( + + +
+

{error}

+
+
+ ); + } + + const sortedEmployees = [...employees].sort((a, b) => { + const nameA = `${a.last_name} ${a.first_name}`.trim(); + const nameB = `${b.last_name} ${b.first_name}`.trim(); + return nameA.localeCompare(nameB, "ru"); + }); return ( @@ -15,27 +62,35 @@ export default function EmployeesPage() {
{sortedEmployees.map((employee) => { - const isOnline = employee.status === "Онлайн"; - const statusColor = isOnline ? "text-green-600" : employee.status === "Недавно" ? "text-amber-600" : "text-gray-500"; - const dotColor = isOnline ? "bg-green-500" : employee.status === "Недавно" ? "bg-amber-500" : "bg-gray-400"; + const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim(); + const initials = `${employee.last_name?.[0] || ''}${employee.first_name?.[0] || ''}`; + const position = employee.position?.name || 'Сотрудник'; + const isOnline = employee.is_active; + const statusText = isOnline ? 'Активен' : 'Неактивен'; + const dotColor = isOnline ? 'bg-green-500' : 'bg-gray-400'; + const statusColor = isOnline ? 'text-green-600' : 'text-gray-500'; return (
-
- {employee.name - .split(" ") - .slice(0, 2) - .map((part) => part[0]) - .join("")} +
+ {employee.avatar ? ( + {fullName} + ) : ( + initials + )}
-

{employee.name}

-

{employee.role}

+

{fullName}

+

{position}

- {employee.status} - • - {employee.department} + {statusText} + {employee.email && ( + <> + • + {employee.email} + + )}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index f7fa87eb..c7ee372c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Providers } from "@/components/Providers"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -23,11 +24,11 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} + {children} ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index eef6e319..43c65495 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -1,6 +1,45 @@ +"use client"; + import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { apiClient } from "@/lib/api"; +import { useUser } from "@/contexts/UserContext"; export default function Login() { + const router = useRouter(); + const { refreshUser } = useUser(); + const [login, setLogin] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + + try { + // Определяем email это или телефон + const credentials = login.includes("@") + ? { email: login, password } + : { phone: login, password }; + + await apiClient.login(credentials); + + // Обновляем данные пользователя в контексте + await refreshUser(); + + // Перенаправляем на главную + router.push("/"); + } catch (err: any) { + console.error("Ошибка авторизации:", err); + setError("Неверный логин или пароль"); + } finally { + setLoading(false); + } + }; + return (
@@ -12,7 +51,13 @@ export default function Login() { Используйте телефон или почту, чтобы продолжить.

-
+ {error && ( +
+ {error} +
+ )} + +
([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const userInitials = user + ? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}` + : 'Г'; + + useEffect(() => { + async function loadPosts() { + try { + const response = await apiClient.getPosts(); + setPosts(response.results); + } catch (err: any) { + console.error('Ошибка загрузки ленты:', err); + setError('Не удалось загрузить ленту'); + } finally { + setLoading(false); + } + } + loadPosts(); + }, []); + + if (loading) { + return ( + +
+
+
+

Загрузка ленты...

+
+
+
+ ); + } + + if (error) { + return ( + +
+

{error}

+
+
+ ); + } return (
-
КМ
+
+ {user?.avatar ? ( + {user + ) : ( + userInitials + )} +
- {feedItems.map((item) => ( -
-
-
-
- {item.author[0]} + {posts.length === 0 ? ( +
+

Пока нет постов в ленте

+
+ ) : ( + posts.map((post) => { + const authorName = post.author + ? `${post.author.last_name} ${post.author.first_name}`.trim() + : 'Аноним'; + const authorInitials = post.author + ? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}` + : 'А'; + + // Форматируем дату + const postDate = new Date(post.created_at); + const now = new Date(); + const diffMs = now.getTime() - postDate.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const timeAgo = diffHours < 1 + ? 'только что' + : diffHours < 24 + ? `${diffHours} ч. назад` + : `${Math.floor(diffHours / 24)} дн. назад`; + + return ( +
+
+
+
+ {post.author?.avatar ? ( + {authorName} + ) : ( + authorInitials + )} +
+
+

{authorName}

+

{timeAgo}

+
+
+
+

{post.content}

+ {post.image && ( +
+ +
+ )} +
+ +
-
-

{item.author}

-

{item.time}

-
-
-
-

{item.text}

-
- {item.tags.map((tag) => ( - - {tag} - - ))} -
-
- - - -
-
- ))} + + ); + }) + )}
); diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 42b5e4b2..8360d352 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -2,8 +2,9 @@ import { Bell, Building2, FileSignature, FileText, Home as HomeIcon, MessageSquare, Search, Users, Wallet } from "lucide-react"; import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { ReactNode } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { ReactNode, useEffect } from "react"; +import { useUser } from "@/contexts/UserContext"; type AppShellProps = { children: ReactNode; @@ -37,6 +38,15 @@ const calendarWeeks = [ ]; function Header() { + const { user, logout } = useUser(); + + const userInitials = user + ? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}` + : 'Г'; + const userName = user + ? `${user.last_name} ${user.first_name}`.trim() + : 'Гость'; + return (
@@ -57,7 +67,21 @@ function Header() { -
КМ
+
{ + if (confirm('Выйти из системы?')) { + logout(); + } + }} + > + {user?.avatar ? ( + {userName} + ) : ( + userInitials + )} +
@@ -66,10 +90,17 @@ function Header() { function LeftNav() { const pathname = usePathname(); + const { user } = useUser(); + + const userInitials = user + ? `${user.last_name?.[0] || ''}${user.first_name?.[0] || ''}` + : 'Г'; + const userName = user + ? `${user.last_name} ${user.first_name}`.trim() + : 'Гость'; const navLinkClass = (href: string) => - `flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${ - pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700" + `flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700" }`; const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400"); @@ -79,10 +110,16 @@ function LeftNav() {
-
КМ
+
+ {user?.avatar ? ( + {userName} + ) : ( + userInitials + )} +
-

Константин Макаев

-

Онлайн

+

{userName}

+

{user?.is_active ? 'Онлайн' : 'Оффлайн'}

@@ -122,13 +159,12 @@ function Calendar() { return (
{day}
@@ -160,6 +196,33 @@ export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: } export function AppShell({ children }: AppShellProps) { + const { user, loading } = useUser(); + const router = useRouter(); + + useEffect(() => { + // Если загрузка завершена и пользователь не авторизован - редирект на логин + if (!loading && !user) { + router.push('/login'); + } + }, [user, loading, router]); + + // Показываем загрузку, пока проверяем авторизацию + if (loading) { + return ( +
+
+
+

Загрузка...

+
+
+ ); + } + + // Если пользователь не авторизован, ничего не показываем (уже редиректим) + if (!user) { + return null; + } + return (
diff --git a/frontend/src/components/Providers.tsx b/frontend/src/components/Providers.tsx new file mode 100644 index 00000000..111347b8 --- /dev/null +++ b/frontend/src/components/Providers.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { UserProvider } from "@/contexts/UserContext"; +import { ReactNode } from "react"; + +export function Providers({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/frontend/src/contexts/UserContext.tsx b/frontend/src/contexts/UserContext.tsx new file mode 100644 index 00000000..8dc0268b --- /dev/null +++ b/frontend/src/contexts/UserContext.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, ReactNode } from "react"; +import { apiClient } from "@/lib/api"; +import type { User } from "@/types/api"; + +interface UserContextType { + user: User | null; + loading: boolean; + error: string | null; + refreshUser: () => Promise; + logout: () => void; +} + +const UserContext = createContext(undefined); + +export function UserProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadUser = async () => { + try { + setLoading(true); + setError(null); + + // Проверяем наличие токена + const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; + if (!token) { + setUser(null); + setLoading(false); + return; + } + + // Загружаем данные пользователя + const userData = await apiClient.getCurrentUser(); + setUser(userData); + } catch (err: any) { + console.error('Ошибка загрузки пользователя:', err); + setError(err.message); + setUser(null); + + // Если ошибка 401 - токен невалиден, очищаем + if (err.message?.includes('401')) { + apiClient.clearToken(); + } + } finally { + setLoading(false); + } + }; + + const refreshUser = async () => { + await loadUser(); + }; + + const logout = () => { + apiClient.clearToken(); + setUser(null); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + }; + + // Загружаем пользователя при монтировании + useEffect(() => { + loadUser(); + }, []); + + const value: UserContextType = { + user, + loading, + error, + refreshUser, + logout, + }; + + return {children}; +} + +export function useUser() { + const context = useContext(UserContext); + if (context === undefined) { + throw new Error('useUser must be used within a UserProvider'); + } + return context; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index aa11503a..91df4095 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,16 +1,52 @@ // Common types +export interface Skill { + id: number; + name: string; + description?: string; +} + +export interface EmployeeAction { + id: number; + employee: number; + action_type: string; + description?: string; + date: string; + created_at: string; +} + +export interface EmployeeDepartment { + id: number; + name: string; + role_id?: number; + role_name?: string; + is_head: boolean; + via_assignment?: boolean; +} + export interface User { id: number; email: string; phone_number?: string; first_name: string; last_name: string; - middle_name?: string; + patronymic?: string; // отчество (было middle_name) position?: Position; - department?: Department; + departments?: EmployeeDepartment[]; // массив отделов с ролями (было department) avatar?: string; is_active: boolean; telegram?: string; + whatsapp?: string; + wechat?: string; + gender?: string; + birth_date?: string; + skills?: Skill[]; + actions?: EmployeeAction[]; + email_verified?: boolean; + created_at?: string; + updated_at?: string; + last_login?: string; + date_joined?: string; + auth?: Record; // глобальные права пользователя } export interface Department { @@ -63,7 +99,7 @@ export interface Document { tags?: string[]; } -// Requests types +// Requests types export interface Request { id: number; title: string; @@ -157,7 +193,7 @@ export interface RegisterData { password: string; first_name: string; last_name: string; - middle_name?: string; + patronymic?: string; // было middle_name telegram?: string; whatsapp?: string; wechat?: string;