feat: Полная интеграция Next.js frontend с Docker orchestration

- Создан Next.js 16 frontend приложение с TypeScript и Tailwind
- Настроена Docker инфраструктура (docker-compose с 7 сервисами)
- Добавлен nginx reverse proxy для маршрутизации frontend/backend
- Реализован полный API client с JWT авторизацией
- Интегрированы страницы: login, register, feed, employees
- Переименована папка corp.front → frontend
- Исправлены проблемы с trailing slash в Django APPEND_SLASH
- Добавлена документация: DOCKER_ORCHESTRATION.md, INTEGRATION_GUIDE.md

Архитектура:
- nginx:80 → frontend:3000 (Next.js) + web:8000 (Django API)
- PostgreSQL 16 + Redis 7 для backend
- Celery worker + beat для фоновых задач
- JWT authentication через SimpleJWT
This commit is contained in:
igor_nadein 2026-02-19 13:51:19 +03:00
parent 66ab7fb672
commit d19bb560ea
38 changed files with 8862 additions and 183 deletions

View File

@ -1,7 +1,7 @@
# Django Settings
DEBUG=False
SECRET_KEY=change-me-django-secret-key
ALLOWED_HOSTS=localhost,127.0.0.1,web
ALLOWED_HOSTS=localhost,127.0.0.1,web,nginx
# Database Settings
USE_SQLITE=False
@ -21,6 +21,11 @@ CELERY_RESULT_BACKEND=redis://redis:6379/1
# Ports (for development)
WEB_PORT=8000
FRONTEND_PORT=3000
NGINX_PORT=80
# Frontend Settings
NODE_ENV=production
# LDAP Settings (if used)
# LDAP_SERVER_URI=ldap://your-ldap-server

289
DOCKER_ORCHESTRATION.md Normal file
View File

@ -0,0 +1,289 @@
# EUSRR Docker Orchestration Guide
## Обзор архитектуры
Проект EUSRR теперь имеет полную оркестрацию с использованием Docker Compose:
```
┌─────────────┐
│ Nginx │ :80 - Reverse Proxy
│ (Gateway) │
└──────┬──────┘
│
├──────────┐
│ │
┌──────▼──────┐ ┌─▼────────────┐
│ Frontend │ │ Backend │
│ (Next.js) │ │ (Django) │
│ :3000 │ │ :8000 │
└──────┬──────┘ └──┬─────┬─────┘
│ │ │
└───────┬───┘ │
│ │
┌──────▼───┐ ┌──▼────┐
│ Redis │ │ DB │
│ :6379 │ │ :5432 │
└──────────┘ └────────┘
```
## Сервисы
### 1. **db** (PostgreSQL 16)
- Порт: `5432`
- База данных Django
- Persistent volume: `postgres_data`
### 2. **redis** (Redis 7)
- Порт: `6379`
- Кэш и Channels backend
- Persistent volume: `redis_data`
### 3. **web** (Django Backend)
- Порт: `8000`
- ASGI сервер (Daphne)
- REST API + WebSocket
- Подключается к `db` и `redis`
### 4. **celery** (Celery Worker)
- Фоновые задачи
- Подключается к `redis` и `db`
### 5. **celery-beat** (Celery Scheduler)
- Планировщик задач
- Django Celery Beat
### 6. **frontend** (Next.js)
- Порт: `3000`
- Корпоративный фронтенд
- Использует `web` backend через proxy
### 7. **nginx** (Reverse Proxy)
- Порт: `80` (основной вход)
- Маршрутизация:
- `/` → Frontend (Next.js)
- `/api/` → Backend API
- `/admin/` → Django Admin
- `/ws/` → WebSocket
- `/static/`, `/media/` → Статика
## Быстрый старт
### Запуск всех сервисов
```bash
# Создайте .env файл из примера
cp .env.example .env
# Запустите все сервисы
docker-compose up -d
# Просмотр логов
docker-compose logs -f
```
### Доступ к приложению
- **Основной вход (через Nginx)**: http://localhost
- **Frontend напрямую**: http://localhost:3000
- **Backend API**: http://localhost:8000/api/
- **Django Admin**: http://localhost/admin/
### Остановка
```bash
# Остановка всех сервисов
docker-compose down
# Остановка с удалением volumes
docker-compose down -v
```
## Конфигурация
### Environment Variables
Основные переменные в `.env`:
```env
# Database
POSTGRES_DB=eusrr
POSTGRES_USER=eusrr_user
POSTGRES_PASSWORD=change-me-postgres-password
# Django
DEBUG=True
SECRET_KEY=dev-secret-key
ALLOWED_HOSTS=localhost,127.0.0.1,web,nginx
# Ports
WEB_PORT=8000
FRONTEND_PORT=3000
NGINX_PORT=80
```
### Frontend Environment
Файл `corp.front/.env.local`:
```env
NEXT_PUBLIC_API_URL=http://web:8000
NEXT_PUBLIC_WS_URL=ws://web:8000
```
## Разработка
### Режим разработки
```bash
# Backend отдельно (с hot-reload)
cd backend
../.venv/Scripts/python manage.py runserver
# Frontend отдельно (с hot-reload)
cd corp.front
npm run dev
```
### Режим Production
```bash
# Полная оркестрация через Docker
docker-compose up -d
```
## Миграции и первичная настройка
```bash
# Выполнить миграции
docker-compose exec web python manage.py migrate
# Создать суперпользователя
docker-compose exec web python manage.py createsuperuser
# Собрать статику
docker-compose exec web python manage.py collectstatic --noinput
```
## Мониторинг
```bash
# Статус сервисов
docker-compose ps
# Логи конкретного сервиса
docker-compose logs -f web
docker-compose logs -f frontend
docker-compose logs -f nginx
# Использование ресурсов
docker stats
```
## Отладка
### Проверка сети
```bash
# Проверить связь frontend → backend
docker-compose exec frontend ping web
# Проверить связь backend → db
docker-compose exec web pg_isready -h db -U eusrr_user
```
### Доступ к контейнерам
```bash
# Backend shell
docker-compose exec web bash
docker-compose exec web python manage.py shell
# Frontend shell
docker-compose exec frontend sh
# Database
docker-compose exec db psql -U eusrr_user -d eusrr
```
## Volumes
Persistent данные сохраняются в volumes:
- `postgres_data` - база данных
- `redis_data` - Redis данные
- `static_volume` - статические файлы Django
- `media_volume` - загруженные медиафайлы
## Сеть
Все сервисы находятся в сети `eusrr_network` и могут обращаться друг к другу по именам сервисов:
- `web` - Django backend
- `frontend` - Next.js frontend
- `db` - PostgreSQL
- `redis` - Redis
- `nginx` - Nginx
## Nginx маршрутизация
- `location /` - Next.js фронтенд
- `location /api/` - Django REST API
- `location /admin/` - Django Admin
- `location /ws/` - WebSocket (Django Channels)
- `location /static/` - Статические файлы
- `location /media/` - Медиа файлы
- `location /health` - Health check endpoint
## Production Deployment
Для production окружения:
1. Обновите `.env`:
- Установите `DEBUG=False`
- Смените `SECRET_KEY`
- Обновите `ALLOWED_HOSTS`
- Используйте надежные пароли БД
2. Настройте HTTPS (добавьте SSL в nginx)
3. Используйте внешние managed сервисы для БД и Redis (опционально)
4. Настройте backup для volumes
## Troubleshooting
### Frontend не может подключиться к backend
Проверьте переменные окружения в `corp.front/.env.local` и убедитесь, что используется правильный URL для Docker сети.
### Backend не запускается
```bash
# Проверьте логи
docker-compose logs web
# Проверьте подключение к БД
docker-compose exec web python manage.py check --database default
```
### Nginx 502 Bad Gateway
Убедитесь, что `web` и `frontend` сервисы запущены:
```bash
docker-compose ps
```
## Файлы конфигурации
- `docker-compose.yml` - Оркестрация сервисов
- `nginx.conf` - Конфигурация Nginx
- `backend/Dockerfile` - Django image
- `corp.front/Dockerfile` - Next.js image
- `.env.example` - Шаблон переменных окружения
- `corp.front/.env.example` - Шаблон для frontend
---
**Дата создания**: 19 февраля 2025
**Версия**: 1.0

35
frontend/.dockerignore Normal file
View File

@ -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

110
frontend/.github/copilot-instructions.md vendored Normal file
View File

@ -0,0 +1,110 @@
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
- [x] Verify that the copilot-instructions.md file in the .github directory is created.
- [x] Clarify Project Requirements
<!-- Ask for project type, language, and frameworks if not specified. Skip if already provided. -->
- [x] Scaffold the Project
<!--
Ensure that the previous step has been marked as completed.
Call project setup tool with projectType parameter.
Run scaffolding command to create project files and folders.
Use '.' as the working directory.
If no appropriate projectType is available, search documentation using available tools.
Otherwise, create the project structure manually using available file creation tools.
-->
- [ ] Customize the Project
<!--
Verify that all previous steps have been completed successfully and you have marked the step as completed.
Develop a plan to modify codebase according to user requirements.
Apply modifications using appropriate tools and user-provided references.
Skip this step for "Hello World" projects.
-->
- [ ] Install Required Extensions
<!-- ONLY install extensions provided mentioned in the get_project_setup_info. Skip this step otherwise and mark as completed. -->
- [x] Compile the Project
<!--
Verify that all previous steps have been completed.
Install any missing dependencies.
Run diagnostics and resolve any issues.
Check for markdown files in project folder for relevant instructions on how to do this.
-->
- [x] Create and Run Task
<!--
Verify that all previous steps have been completed.
Check https://code.visualstudio.com/docs/debugtest/tasks to determine if the project needs a task. If so, use the create_and_run_task to create and launch a task based on package.json, README.md, and project structure.
Skip this step otherwise.
-->
- [x] Launch the Project
<!--
Verify that all previous steps have been completed.
Prompt user for debug mode, launch only if confirmed.
-->
- [ ] Ensure Documentation is Complete
<!--
Verify that all previous steps have been completed.
Verify that README.md and the copilot-instructions.md file in the .github directory exists and contains current project information.
Clean up the copilot-instructions.md file in the .github directory by removing all HTML comments.
-->
<!--
## Execution Guidelines
PROGRESS TRACKING:
- If any tools are available to manage the above todo list, use it to track progress through this checklist.
- After completing each step, mark it complete and add a summary.
- Read current todo list status before starting each new step.
COMMUNICATION RULES:
- Avoid verbose explanations or printing full command outputs.
- If a step is skipped, state that briefly (e.g. "No extensions needed").
- Do not explain project structure unless asked.
- Keep explanations concise and focused.
DEVELOPMENT RULES:
- Use '.' as the working directory unless user specifies otherwise.
- Avoid adding media or external links unless explicitly requested.
- Use placeholders only with a note that they should be replaced.
- If the user has not specified project details, assume they want a "Hello World" project as a starting point.
- Avoid adding links of any type (URLs, files, folders, etc.) or integrations that are not explicitly required.
- Avoid generating images, videos, or any other media files unless explicitly requested.
- If you need to use any media assets as placeholders, let the user know that these are placeholders and should be replaced with the actual assets later.
- Ensure all generated components serve a clear purpose within the user's requested workflow.
- If a feature is assumed but not confirmed, prompt the user for clarification before including it.
- If you are working on a VS Code extension, use the VS Code API tool with a query to find relevant VS Code API references and samples related to that query.
FOLDER CREATION RULES:
- Always use the current directory as the project root.
- If you are running any terminal commands, use the '.' argument to ensure that the current working directory is used ALWAYS.
- Do not create a new folder unless the user explicitly requests it besides a .vscode folder for a tasks.json file.
- If any of the scaffolding commands mention that the folder name is not correct, let the user know to create a new folder with the correct name and then reopen it again in vscode.
EXTENSION INSTALLATION RULES:
- Only install extension specified by the get_project_setup_info tool. DO NOT INSTALL any other extensions.
PROJECT CONTENT RULES:
- If the user has not specified project details, assume they want a "Hello World" project as a starting point.
- Avoid adding links of any type (URLs, files, folders, etc.) or integrations that are not explicitly required.
- Avoid generating images, videos, or any other media files unless explicitly requested.
- If you need to use any media assets as placeholders, let the user know that these are placeholders and should be replaced with the actual assets later.
- Ensure all generated components serve a clear purpose within the user's requested workflow.
- If a feature is assumed but not confirmed, prompt the user for clarification before including it.
- If you are working on a VS Code extension, use the VS Code API tool with a query to find relevant VS Code API references and samples related to that query.
TASK COMPLETION RULES:
- Your task is complete when:
- Project is successfully scaffolded and compiled without errors
- copilot-instructions.md file in the .github directory exists in the project
- README.md file exists and is up to date
- User is provided with clear instructions to debug/launch the project
Before starting a new task in the above plan, update progress in the plan.
-->
- Work through each checklist item systematically.
- Keep communication concise and focused.
- Follow development best practices.

41
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

48
frontend/Dockerfile Normal file
View File

@ -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"]

View File

@ -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 <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return <div>{/* UI */}</div>;
}
```
### Доступные хуки
- `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 <div>Загрузка...</div>;
if (error) return <div>Ошибка: {error.message}</div>;
return (
<div>
{data.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
```
### Шаг 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 <div>Protected content</div>;
}
```
## Обработка ошибок
API клиент автоматически обрабатывает:
- **401 Unauthorized**: Очищает токены и перенаправляет на `/login`
- **Network errors**: Выбрасывает исключение с сообщением
Пример обработки в компонентах:
```tsx
const { data, loading, error } = useMyData();
if (loading) return <LoadingSpinner />;
if (error) {
return (
<div className="error-message">
<h3>Ошибка загрузки данных</h3>
<p>{error.message}</p>
<button onClick={() => refetch()}>Попробовать снова</button>
</div>
);
}
```
## 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

36
frontend/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

14
frontend/next.config.ts Normal file
View File

@ -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;

6158
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "corp.front",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"lucide-react": "^0.574.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

BIN
frontend/public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

195
frontend/public/logo1.svg Normal file
View File

@ -0,0 +1,195 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.16, written by Peter Selinger 2001-2019
</metadata>
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M963 4786 c-81 -26 -170 -92 -220 -162 -49 -71 -53 -101 -55 -468 -1
-188 2 -348 6 -355 5 -7 5 -28 1 -48 -3 -19 -1 -93 6 -164 12 -125 12 -130 -8
-157 -15 -19 -29 -27 -49 -26 -175 8 -266 14 -274 19 -14 8 -120 -12 -137 -27
-11 -9 -16 -9 -23 2 -6 10 -14 9 -38 -8 -37 -27 -54 -53 -75 -116 -19 -56 -20
-59 -10 -53 11 7 15 -882 5 -1103 -9 -194 -3 -249 32 -302 24 -37 94 -81 118
-74 12 2 45 0 75 -5 37 -7 100 -7 195 0 148 11 171 7 183 -30 4 -11 2 -109 -4
-217 -7 -128 -8 -320 -3 -547 l7 -350 29 -58 c51 -99 162 -180 284 -206 70
-15 2994 -16 3073 -1 111 21 206 84 266 177 58 91 62 127 63 501 0 185 0 339
0 342 0 3 -2 71 -4 151 -2 81 -6 151 -9 156 -7 12 21 70 37 76 7 3 51 1 97 -4
152 -17 269 -16 326 4 61 22 126 82 144 135 17 49 18 194 1 200 -7 2 -12 22
-12 50 0 26 -7 74 -15 107 -8 33 -15 76 -15 95 0 19 -10 55 -21 80 -34 75 -36
162 -5 249 70 200 32 422 -98 559 -27 29 -63 61 -80 71 -46 28 -207 81 -273
89 -50 6 -60 11 -80 41 -12 18 -21 37 -18 41 19 30 33 917 16 1025 -20 134
-90 228 -211 288 l-65 32 -1560 2 c-1271 1 -1568 -1 -1602 -11z m3140 -165
c13 -5 49 -34 80 -64 l57 -55 0 -605 c0 -393 -4 -608 -10 -612 -6 -3 -10 -12
-10 -19 0 -10 29 -16 108 -21 253 -17 377 -77 446 -218 20 -42 27 -75 32 -153
6 -101 -7 -216 -26 -222 -6 -2 -10 -12 -10 -23 0 -11 -11 -31 -24 -43 -33 -34
-38 -54 -18 -74 9 -10 30 -42 46 -72 27 -52 29 -63 34 -205 4 -121 8 -157 23
-185 32 -59 22 -138 -19 -158 -37 -17 -397 -17 -432 1 -42 21 -52 58 -57 210
-2 78 -9 154 -15 170 -10 26 -12 27 -89 27 l-79 0 0 -192 c0 -106 -3 -203 -6
-214 -5 -20 -2 -22 37 -22 29 -1 48 -7 56 -18 10 -14 13 -148 13 -626 0 -606
0 -608 -21 -626 -12 -9 -39 -35 -61 -57 l-40 -40 -1566 -5 -1567 -5 -32 28
c-18 16 -31 34 -30 40 1 6 -5 12 -13 12 -8 0 -14 7 -12 15 2 8 0 12 -4 8 -4
-4 -13 -3 -20 3 -11 9 -14 125 -14 614 l0 602 26 28 c25 28 27 28 116 21 50
-3 113 -6 139 -6 81 0 79 16 79 -552 l0 -488 1335 0 1335 0 0 504 c0 435 2
507 15 520 25 25 17 36 -25 36 l-40 0 0 -99 c0 -147 -6 -151 -251 -151 l-170
0 3 -72 3 -73 133 -5 c184 -7 172 13 172 -287 0 -230 0 -236 -22 -259 l-23
-24 -1142 0 -1142 0 -21 22 c-19 21 -20 34 -20 263 0 191 3 245 14 260 12 17
30 20 120 23 l106 4 0 74 0 74 -134 0 c-130 0 -173 7 -199 31 -8 8 -11 44 -9
124 l4 112 -63 6 c-34 4 -94 7 -133 7 -68 0 -72 1 -89 29 -9 16 -17 37 -17 46
0 9 -4 14 -9 10 -14 -8 -24 93 -19 204 2 65 0 99 -7 98 -5 -1 -9 6 -7 16 3 15
-6 17 -68 17 l-70 0 0 -183 c0 -215 -5 -233 -65 -242 -22 -4 -122 -5 -222 -3
-168 3 -183 5 -198 23 l-15 20 6 -20 c6 -20 5 -20 -10 -1 -14 18 -16 91 -16
662 l0 642 25 16 c21 14 63 16 302 16 l278 0 -2 627 c-2 344 0 624 3 620 7 -7
64 49 64 64 0 6 4 8 9 5 4 -3 17 4 27 15 11 12 28 25 39 30 28 11 3068 11
3098 0z"/>
<path d="M1017 4479 l-37 -40 0 -537 c0 -295 4 -543 9 -550 5 -9 24 -12 57
-10 l49 3 3 524 c1 367 6 528 13 538 10 11 257 13 1436 13 963 0 1431 -3 1444
-10 18 -10 19 -27 19 -530 l0 -520 60 0 60 0 0 541 0 541 -40 39 -40 39 -1498
0 -1497 -1 -38 -40z"/>
<path d="M1220 3805 c0 -461 -1 -497 -18 -527 -21 -37 -19 -58 6 -58 31 -1
142 -78 142 -99 0 -6 9 -11 20 -11 15 0 20 7 20 29 0 100 151 181 338 181 64
0 81 3 86 16 13 35 0 274 -15 274 -5 0 -9 -24 -9 -53 0 -83 -21 -101 -121
-106 -179 -8 -182 -3 -177 288 2 158 6 212 17 224 11 14 34 17 132 17 110 0
119 -1 133 -22 9 -12 16 -39 16 -60 0 -20 5 -40 10 -43 11 -7 12 12 19 213 1
12 9 26 19 29 9 4 20 11 23 15 3 5 37 15 75 21 38 7 74 15 79 19 14 9 123 28
161 28 18 0 34 4 36 9 2 5 80 14 174 21 132 9 209 9 340 0 93 -6 178 -15 189
-20 11 -4 61 -14 110 -20 50 -7 92 -16 95 -19 3 -4 37 -12 77 -18 39 -6 83
-19 97 -29 27 -20 30 -42 28 -189 0 -33 2 -49 4 -35 18 99 20 100 157 100 62
0 117 -4 122 -8 30 -22 35 -56 35 -253 0 -268 -1 -269 -164 -269 -39 0 -80 3
-91 6 -31 8 -49 42 -55 104 -4 37 -7 11 -8 -80 0 -74 1 -143 4 -152 4 -14 16
-18 52 -18 118 -1 215 -28 273 -77 14 -11 18 -11 29 2 11 13 34 16 122 14 60
-1 111 1 114 4 4 3 -1 16 -10 29 -14 20 -16 80 -16 520 l0 498 -1335 0 -1335
0 0 -495z m2505 335 c12 -13 15 -27 11 -45 -8 -32 -26 -32 -64 0 -17 14 -39
25 -50 25 -11 0 -25 7 -32 15 -11 14 -10 14 12 6 14 -5 29 -6 34 -1 9 8 36 16
60 19 6 0 19 -8 29 -19z m-2242 1 c18 -2 4 -23 -31 -46 -32 -21 -44 -24 -65
-16 -32 12 -34 24 -7 51 14 14 29 18 57 15 21 -2 41 -4 46 -4z m-86 -749 c-5
-37 -34 -53 -53 -27 -13 17 -11 73 3 98 10 17 53 -45 50 -71z m2351 31 c-5
-23 -23 -20 -26 4 -3 18 0 21 13 17 9 -4 15 -13 13 -21z"/>
<path d="M2285 4088 c-49 -5 -97 -13 -106 -18 -9 -4 -51 -13 -95 -20 -43 -7
-83 -16 -89 -20 -5 -4 -21 -10 -35 -12 -25 -3 -25 -3 -28 -127 -2 -93 -6 -127
-18 -138 -13 -14 -13 -18 1 -39 12 -19 15 -63 15 -252 0 -164 -3 -231 -12
-240 -8 -8 -57 -12 -153 -12 -78 0 -145 -4 -151 -9 -5 -5 -26 -14 -47 -21 -62
-20 -67 -42 -67 -304 0 -139 -4 -227 -10 -231 -6 -4 -10 -134 -10 -356 l0
-349 -27 -11 c-15 -6 -35 -8 -45 -6 -38 10 -49 -14 -46 -98 l3 -80 167 -3
c132 -2 167 -5 171 -17 8 -19 11 -334 4 -341 -3 -3 -56 -7 -119 -7 l-113 -2
-3 -173 -2 -172 485 0 485 0 0 163 c0 89 -4 167 -8 174 -5 8 -43 13 -107 15
l-100 3 -3 163 c-1 101 2 170 8 182 10 19 22 20 285 20 150 0 276 -3 278 -7 3
-5 7 -87 8 -183 l4 -175 -105 -5 -105 -5 -3 -173 -2 -172 510 0 510 0 -2 172
-3 173 -145 5 -145 5 -2 164 c-1 107 2 170 9 178 8 10 57 13 204 13 l194 0 0
84 c0 91 -3 97 -54 103 -58 6 -55 -22 -58 597 l-3 573 -28 36 c-19 25 -40 40
-70 47 -23 7 -58 16 -78 21 -20 5 -72 9 -116 9 -48 0 -84 5 -91 12 -17 17 -17
448 1 496 10 28 10 36 0 39 -10 4 -13 38 -13 128 0 130 -2 135 -51 135 -10 0
-19 4 -19 9 0 4 -41 14 -91 20 -49 6 -98 16 -107 20 -51 27 -461 39 -657 19z
m50 -138 c11 -5 31 -11 45 -14 42 -9 110 -78 125 -128 14 -48 12 -139 -5 -183
-12 -33 -84 -105 -104 -105 -8 0 -16 -6 -19 -12 -6 -20 -207 -18 -231 2 -48
39 -108 105 -117 127 -5 13 -9 54 -9 90 0 52 5 75 24 107 45 75 69 90 211 131
13 4 54 -4 80 -15z m630 -10 c22 -6 45 -17 50 -23 6 -7 19 -17 30 -23 11 -6
30 -30 42 -54 28 -52 32 -160 8 -218 -16 -37 -80 -102 -102 -102 -6 0 -13 -6
-16 -12 -3 -9 -34 -13 -109 -13 -86 0 -108 3 -122 18 -10 9 -21 17 -25 17 -15
0 -65 52 -85 89 -27 49 -29 170 -3 198 9 11 17 24 17 29 0 6 17 26 38 45 32
31 89 56 167 73 10 3 40 -4 110 -24z m-308 -445 c80 -53 165 -78 238 -70 32 3
75 13 93 21 38 15 90 18 110 5 15 -9 42 -93 42 -131 0 -42 46 -123 85 -148 19
-12 56 -27 82 -33 164 -40 179 -48 215 -127 17 -38 19 -66 18 -280 0 -130 0
-250 -1 -267 -2 -85 20 -505 29 -536 12 -47 -7 -96 -40 -103 -13 -3 -54 -13
-92 -21 -127 -28 -169 -72 -186 -196 -12 -85 -1 -188 24 -237 21 -41 69 -76
133 -97 104 -33 126 -120 41 -156 -26 -11 -105 -14 -371 -14 l-339 0 -30 34
c-49 54 -28 105 70 173 55 38 81 90 88 180 13 163 -30 269 -121 300 -68 23
-386 25 -461 3 -98 -29 -134 -98 -134 -259 0 -120 18 -168 76 -212 120 -91
140 -135 83 -190 l-30 -29 -211 -3 c-234 -3 -423 9 -457 29 -26 17 -37 53 -25
85 5 13 40 45 79 71 89 61 108 103 107 241 -1 171 -37 235 -154 276 -29 11
-63 28 -76 40 -21 18 -23 27 -18 71 4 27 10 214 15 415 15 620 18 665 41 710
27 52 78 78 182 91 153 19 205 67 229 209 20 121 40 134 154 101 40 -11 89
-21 107 -21 50 0 136 28 188 60 114 71 131 72 217 15z"/>
<path d="M2220 3843 c-8 -3 -23 -14 -34 -25 -11 -10 -23 -18 -28 -18 -4 0 -12
-18 -18 -41 -19 -70 11 -124 87 -154 40 -16 45 -16 87 0 31 12 53 30 70 56 38
56 34 91 -14 140 -35 36 -48 42 -88 45 -26 2 -54 1 -62 -3z"/>
<path d="M2788 3823 c-87 -66 -74 -164 29 -217 37 -19 56 -20 81 -6 9 5 28 14
42 19 16 5 35 27 49 55 24 45 24 46 6 85 -26 58 -61 84 -120 89 -43 3 -55 0
-87 -25z"/>
<path d="M2358 2866 c-26 -7 -62 -25 -80 -40 -103 -83 -144 -96 -318 -96 -167
0 -248 -33 -271 -109 -9 -29 -9 -47 1 -75 22 -69 61 -92 285 -173 82 -29 159
-58 170 -63 26 -11 162 -59 343 -121 213 -73 282 -68 372 26 66 68 104 85 197
85 238 1 337 28 376 102 34 68 -2 132 -104 184 -97 49 -789 273 -889 287 -19
3 -56 0 -82 -7z m111 -63 c3 -6 34 -16 69 -23 34 -7 62 -16 62 -20 0 -4 27
-13 60 -19 33 -7 60 -15 60 -20 0 -4 29 -13 65 -20 36 -7 65 -16 65 -20 0 -3
26 -12 58 -20 31 -7 62 -16 67 -21 6 -4 35 -13 65 -20 30 -7 57 -16 60 -20 3
-3 31 -12 63 -19 31 -7 57 -16 57 -20 0 -4 29 -13 65 -20 36 -7 65 -17 65 -21
0 -4 11 -11 25 -14 37 -9 45 -69 14 -102 l-22 -24 -273 0 -273 0 -3 -77 -3
-77 -38 -20 c-24 -13 -41 -16 -45 -10 -4 5 -34 15 -66 22 -33 6 -62 15 -65 20
-3 4 -32 14 -63 22 -32 7 -58 16 -58 20 0 4 -25 12 -55 19 -30 7 -55 16 -55
20 0 4 -27 13 -60 20 -33 8 -60 17 -60 21 0 4 -27 13 -60 20 -33 7 -60 16 -60
20 0 4 -25 12 -55 19 -30 7 -55 16 -55 20 0 4 -27 13 -60 20 -33 8 -60 17 -60
21 0 4 -15 10 -34 13 -18 3 -54 15 -79 26 -35 16 -47 28 -52 50 -6 30 7 69 29
83 6 4 90 8 186 9 96 0 213 4 260 7 l85 7 7 52 c4 29 16 62 25 74 16 18 26 20
77 16 32 -3 62 -9 65 -14z"/>
<path d="M2418 2693 c-3 -4 -11 -28 -18 -53 -20 -65 -26 -70 -89 -70 -31 0
-63 -4 -71 -10 -9 -5 -19 -5 -27 2 -9 7 -13 7 -13 -1 0 -14 -57 -15 -62 -1 -2
7 -12 6 -29 -2 -24 -11 -24 -13 -8 -29 9 -9 35 -20 56 -24 22 -3 51 -12 65
-19 14 -7 48 -19 77 -26 28 -6 51 -15 51 -19 0 -4 27 -13 60 -20 33 -8 60 -17
60 -21 0 -4 17 -11 38 -15 20 -4 51 -13 67 -20 29 -13 102 -35 118 -35 4 0 7
29 7 63 0 93 7 97 157 97 146 0 166 5 126 31 -15 11 -39 19 -53 19 -14 0 -37
6 -52 14 -15 8 -52 20 -83 27 -30 7 -55 16 -55 20 0 4 -19 10 -42 14 -24 4
-56 13 -73 20 -16 8 -50 19 -75 25 -25 7 -49 15 -55 20 -16 12 -73 22 -77 13z
m157 -177 c0 -47 -66 -45 -73 3 -3 20 0 22 35 19 29 -2 38 -7 38 -22z"/>
<path d="M1604 3867 c-2 -7 -3 -78 -2 -157 l3 -145 29 -3 c36 -4 46 10 46 72
0 27 6 65 13 84 10 28 10 36 0 39 -8 3 -13 22 -13 48 0 24 -3 50 -6 59 -7 19
-62 22 -70 3z"/>
<path d="M3450 3820 c0 -33 -4 -60 -10 -60 -5 0 -10 -11 -10 -25 0 -14 5 -25
10 -25 6 0 10 -30 10 -70 0 -65 1 -70 24 -76 14 -3 30 -3 35 1 7 4 11 63 11
161 l0 154 -35 0 -35 0 0 -60z"/>
<path d="M390 2586 l0 -576 140 0 c127 0 140 2 140 18 0 14 -2 15 -10 2 -6
-10 -9 49 -9 175 1 105 4 179 9 165 6 -22 8 -21 11 15 l4 40 145 0 c141 0 147
-1 176 -25 45 -37 64 -100 64 -216 0 -55 3 -116 6 -136 l7 -38 132 0 132 0 1
33 c2 55 -9 177 -16 173 -4 -2 -5 20 -4 50 3 33 -1 60 -8 69 -7 8 -10 22 -6
30 15 40 -49 118 -113 135 -36 9 -41 14 -41 40 0 20 7 33 22 41 35 19 83 61
115 102 25 31 34 164 20 271 -8 58 -59 126 -109 147 -18 7 -35 16 -38 20 -14
20 -162 32 -452 36 l-318 5 0 -576z m211 444 c18 0 8 -46 -20 -97 -37 -66 -49
-165 -27 -208 9 -17 16 -37 16 -46 0 -9 16 -37 36 -63 53 -70 54 -100 6 -161
-51 -65 -63 -88 -83 -155 l-17 -55 -17 40 c-14 32 -18 73 -19 215 -2 281 4
471 15 492 12 22 68 50 87 43 8 -3 18 -5 23 -5z m551 -46 c40 -39 51 -60 59
-119 7 -55 -7 -97 -41 -115 -16 -8 -22 -6 -35 13 -8 13 -18 39 -21 58 -8 39
-33 91 -69 145 -13 20 -22 41 -19 46 13 20 94 2 126 -28z m-164 -60 c4 -6 17
-14 30 -18 12 -4 22 -13 22 -21 0 -7 5 -15 11 -17 6 -2 13 -33 16 -68 6 -88
-16 -131 -75 -144 -23 -6 -42 -13 -42 -18 0 -4 -60 -8 -134 -8 -147 0 -145 -1
-147 63 l-2 32 -7 -35 c-5 -22 -7 14 -8 95 0 79 3 120 8 105 8 -24 8 -24 9 6
1 17 4 34 9 38 12 12 302 3 310 -10z m76 -376 c7 -13 17 -39 20 -59 4 -22 19
-48 42 -70 64 -60 94 -116 94 -174 0 -62 -25 -94 -58 -76 -15 8 -27 39 -47
126 -27 112 -29 116 -67 143 -22 15 -54 39 -72 55 l-31 27 28 25 c35 31 72 32
91 3z"/>
<path d="M663 2790 c0 -30 2 -43 4 -27 2 15 2 39 0 55 -2 15 -4 2 -4 -28z"/>
<path d="M780 2785 l0 -45 73 0 c39 0 77 4 83 8 6 4 10 21 10 37 -1 36 -16 43
-103 44 l-63 1 0 -45z"/>
<path d="M3765 3128 c-3 -7 -4 -265 -3 -573 l3 -560 138 -3 137 -3 0 195 c0
107 3 201 6 210 10 26 246 23 301 -4 45 -21 73 -68 73 -120 0 -18 4 -29 10
-25 6 3 10 2 10 -4 0 -5 -4 -13 -10 -16 -13 -8 -13 -139 -1 -163 6 -9 12 -29
16 -44 l5 -28 136 0 136 0 -7 23 c-3 12 -11 91 -15 176 -8 125 -13 161 -31 195
-29 56 -46 74 -80 81 -16 4 -29 10 -29 15 0 4 -9 10 -20 13 -41 11 -9 74 45 91
11 3 38 32 60 64 l40 57 0 125 c0 120 -1 127 -28 168 -34 52 -95 98 -144 107
-21 4 -44 12 -52 18 -22 19 -689 23 -696 5z m260 -100 c30 -13 32 -40 6 -76
-51 -73 -69 -183 -43 -273 8 -25 33 -72 58 -105 24 -32 44 -64 44 -71 0 -6 -18
-33 -40 -58 -57 -66 -68 -91 -80 -191 -9 -70 -16 -94 -34 -112 -28 -29 -68
-22 -85 15 -15 33 -26 437 -16 636 6 137 10 161 29 189 36 55 102 73 161 46z
m505 -77 c32 -32 46 -72 35 -98 -5 -10 -7 -24 -6 -30 4 -26 -9 -12 -54 57 -48
76 -52 86 -32 93 22 10 28 7 57 -22z m-174 -50 c40 -11 74 -40 67 -57 -3 -8 0
-14 6 -14 14 0 15 -126 1 -135 -6 -4 -8 -10 -6 -14 11 -17 -48 -53 -102 -62
-77 -12 -239 -11 -263 1 -17 10 -19 22 -19 144 0 73 3 136 7 139 10 10 273 8
309 -2z m82 -391 c7 -12 10 -25 7 -30 -10 -15 -41 -12 -56 6 -11 14 -10 18 6
30 25 18 28 18 43 -6z"/>
<path d="M4147 2803 c-14 -14 -7 -81 9 -87 30 -11 111 -6 142 10 34 16 43 49
20 72 -13 13 -159 17 -171 5z"/>
<path d="M664 2235 c0 -49 1 -71 3 -48 2 23 2 64 0 90 -2 26 -3 8 -3 -42z"/>
<path d="M943 2140 c0 -47 2 -66 4 -42 2 23 2 61 0 85 -2 23 -4 4 -4 -43z"/>
<path d="M4010 1259 c0 -333 -4 -517 -10 -530 -11 -19 -37 -19 -1445 -19
-1408 0 -1434 0 -1445 19 -6 13 -10 196 -10 526 l0 506 -57 -3 -58 -3 -3 -533
c-2 -584 -6 -550 59 -598 l33 -24 1482 0 1483 0 45 37 45 38 0 548 1 547 -60
0 -60 0 0 -511z"/>
<path d="M2340 1561 l0 -79 88 -4 c48 -2 126 -2 175 0 l87 4 0 79 0 79 -175 0
-175 0 0 -79z"/>
<path d="M956 537 c3 -10 9 -15 12 -12 3 3 0 11 -7 18 -10 9 -11 8 -5 -6z"/>
<path d="M4481 3697 c1 -21 3 -25 6 -12 3 11 10 23 15 26 5 3 3 9 -6 12 -13 5
-16 0 -15 -26z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,10 @@
export default function DepartmentsPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-8">
<h1 className="text-2xl font-bold text-gray-900">Отделы</h1>
<p className="mt-2 text-sm text-gray-600">Раздел в разработке. Здесь будут списки отделов и контакты.</p>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function DocumentsPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-8">
<h1 className="text-2xl font-bold text-gray-900">Документы</h1>
<p className="mt-2 text-sm text-gray-600">Раздел в разработке. Здесь будут договоры, отчёты и файлы.</p>
</div>
</div>
);
}

View File

@ -0,0 +1,222 @@
"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 (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<header className="sticky top-0 z-30 border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 sm:px-8">
<div className="flex items-center gap-2">
<img src="/logo.png" alt="Логотип" className="h-11 w-auto" />
</div>
<div className="hidden flex-1 items-center justify-center md:flex">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => 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="Поиск сотрудников..."
/>
</div>
</div>
<div className="ml-auto flex items-center gap-1 sm:gap-2">
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
<Bell size={18} className="text-gray-600" />
</button>
<div className="ml-1 flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
</div>
</div>
</header>
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-8 sm:px-8">
<aside className="hidden w-64 flex-shrink-0 lg:block">
<div className="sticky top-8 space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">КМ</div>
<div>
<p className="text-sm font-semibold text-gray-900">Константин Макаев</p>
<p className="text-xs text-gray-500">Онлайн</p>
</div>
</div>
<div className="space-y-2 text-sm text-gray-700">
<Link href="/" className={navLinkClass("/")}>
<HomeIcon size={18} className={pathname === "/" ? "text-sky-700" : "text-sky-600"} />
Лента
</Link>
<Link href="/messages" className={navLinkClass("/messages")}>
<MessageSquare size={18} className={navIconClass("/messages")} />
Сообщения
</Link>
<Link href="/employees" className={navLinkClass("/employees")}>
<Users size={18} className={navIconClass("/employees")} />
Сотрудники
</Link>
<Link href="/departments" className={navLinkClass("/departments")}>
<Building2 size={18} className={navIconClass("/departments")} />
Отделы
</Link>
<Link href="/requests" className={navLinkClass("/requests")}>
<FileSignature size={18} className={navIconClass("/requests")} />
Заявления
</Link>
<Link href="/documents" className={navLinkClass("/documents")}>
<FileText size={18} className={navIconClass("/documents")} />
Документы
</Link>
<Link href="/finances" className={navLinkClass("/finances")}>
<Wallet size={18} className={navIconClass("/finances")} />
Финансы
</Link>
</div>
</div>
</div>
</aside>
<main className="flex-1 space-y-6">
<div className="flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-sky-600">Команда</p>
<h1 className="text-2xl font-bold text-gray-900">Сотрудники</h1>
</div>
<div className="rounded-full bg-sky-50 px-4 py-2 text-xs font-medium text-sky-700 ring-1 ring-sky-100">Всего: {employees.length}</div>
</div>
</div>
<div className="space-y-3">
{loading && (
<div className="rounded-2xl bg-white p-10 text-center shadow-sm ring-1 ring-gray-100">
<p className="text-gray-500">Загрузка сотрудников...</p>
</div>
)}
{error && (
<div className="rounded-2xl bg-red-50 p-5 shadow-sm ring-1 ring-red-100">
<p className="text-red-600">Ошибка загрузки: {error.message}</p>
</div>
)}
{!loading && !error && employees.length === 0 && (
<div className="rounded-2xl bg-white p-10 text-center shadow-sm ring-1 ring-gray-100">
<p className="text-gray-500">Сотрудники не найдены</p>
</div>
)}
{!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 (
<article
key={employee.id}
className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100"
>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">
{employee.first_name[0]}{employee.last_name[0]}
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">{fullName}</p>
<p className="text-xs text-gray-500">{employee.position?.name || 'Не указана'}</p>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
<span className={statusColor}>{statusText}</span>
{employee.department && (
<>
<span className="mx-1 text-gray-300">•</span>
<span className="text-gray-500">{employee.department.name}</span>
</>
)}
</div>
</div>
</article>
);
})}
</div>
</main>
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
<div className="sticky top-8 space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
<span>Календарь</span>
<span className="text-xs font-normal text-gray-500">Февраль 2026</span>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{calendarWeeks.map((week, wi) =>
week.map((day, di) => {
const isToday = day === "18";
return (
<div
key={`${wi}-${di}`}
className={`flex h-9 items-center justify-center rounded-full ${
day
? isToday
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
: "hover:bg-sky-50"
: ""
}`}
>
{day}
</div>
);
})
)}
</div>
</div>
</div>
</aside>
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,10 @@
export default function FinancesPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-8">
<h1 className="text-2xl font-bold text-gray-900">Финансы</h1>
<p className="mt-2 text-sm text-gray-600">Раздел в разработке. Здесь появится аналитика и отчётность.</p>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

View File

@ -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 (
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-8 shadow-xl ring-1 ring-gray-100">
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl">
Войти в систему EUSRR
</h1>
<p className="mb-8 text-sm text-gray-600">
Используйте учетные данные LDAP для входа
</p>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="email">
Email или телефон
</label>
<input
id="email"
name="email"
type="text"
autoComplete="email"
value={email}
onChange={(e) => 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}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="password">
Пароль
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => 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}
/>
</div>
<button
type="submit"
disabled={loading}
className="flex w-full items-center justify-center rounded-lg bg-sky-400 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Вход...' : 'Войти'}
</button>
</form>
<div className="mt-4">
<a
href="/register"
className="flex w-full items-center justify-center rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-sky-700 transition hover:border-sky-200 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-100"
>
Создать аккаунт
</a>
</div>
<p className="mt-6 text-center text-xs text-gray-500">
Для входа используйте корпоративные учетные данные
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function MessagesPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-8">
<h1 className="text-2xl font-bold text-gray-900">Сообщения</h1>
<p className="mt-2 text-sm text-gray-600">Раздел в разработке. Здесь появятся чаты и диалоги.</p>
</div>
</div>
);
}

259
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,259 @@
"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 (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<header className="sticky top-0 z-30 border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 sm:px-8">
<div className="flex items-center gap-2">
<img
src="/logo.png"
alt="Логотип"
className="h-11 w-auto"
/>
</div>
<div className="hidden flex-1 items-center justify-center md:flex">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
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="Поиск"
/>
</div>
</div>
<div className="ml-auto flex items-center gap-1 sm:gap-2">
<button className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100">
<Bell size={18} className="text-gray-600" />
</button>
<div className="ml-1 flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">
КМ
</div>
</div>
</div>
</header>
<div className="mx-auto flex min-h-screen max-w-6xl gap-6 px-4 py-8 sm:px-8">
<aside className="hidden w-64 flex-shrink-0 lg:block">
<div className="sticky top-8 space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="mb-4 flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">
{user ? `${user.first_name[0]}${user.last_name[0]}` : 'ГК'}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{user ? `${user.first_name} ${user.last_name}` : 'Гость'}</p>
<p className="text-xs text-gray-500">{user?.is_active ? 'Онлайн' : 'Оффлайн'}</p>
</div>
</div>
<div className="space-y-2 text-sm text-gray-700">
<Link href="/" className={navLinkClass("/")}>
<HomeIcon size={18} className={pathname === "/" ? "text-sky-700" : "text-sky-600"} />
Лента
</Link>
<Link href="/messages" className={navLinkClass("/messages")}>
<MessageSquare size={18} className={navIconClass("/messages")} />
Сообщения
</Link>
<Link href="/employees" className={navLinkClass("/employees")}>
<Users size={18} className={navIconClass("/employees")} />
Сотрудники
</Link>
<Link href="/departments" className={navLinkClass("/departments")}>
<Building2 size={18} className={navIconClass("/departments")} />
Отделы
</Link>
<Link href="/requests" className={navLinkClass("/requests")}>
<FileSignature size={18} className={navIconClass("/requests")} />
Заявления
</Link>
<Link href="/documents" className={navLinkClass("/documents")}>
<FileText size={18} className={navIconClass("/documents")} />
Документы
</Link>
<Link href="/finances" className={navLinkClass("/finances")}>
<Wallet size={18} className={navIconClass("/finances")} />
Финансы
</Link>
</div>
</div>
</div>
</aside>
<main className="flex-1 space-y-6">
<div className="flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3">
<div className="flex h-11 w-13 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">
КМ
</div>
<div className="relative w-full">
<Search size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-10 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="Поиск по ленте"
/>
</div>
</div>
<div className="flex flex-wrap gap-2 text-xs text-sky-700">
<span className="rounded-full bg-sky-50 px-3 py-1">Статья</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Фото</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Ссылка</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Опрос</span>
<span className="rounded-full bg-sky-50 px-3 py-1">Событие</span>
</div>
</div>
<div className="space-y-4">
{loading && (
<div className="rounded-2xl bg-white p-10 text-center shadow-sm ring-1 ring-gray-100">
<p className="text-gray-500">Загрузка...</p>
</div>
)}
{error && (
<div className="rounded-2xl bg-red-50 p-5 shadow-sm ring-1 ring-red-100">
<p className="text-red-600">Ошибка загрузки: {error.message}</p>
<p className="mt-2 text-sm text-red-500">Проверьте подключение к серверу</p>
</div>
)}
{!loading && !error && posts.length === 0 && (
<div className="rounded-2xl bg-white p-10 text-center shadow-sm ring-1 ring-gray-100">
<p className="text-gray-500">Нет постов в ленте</p>
</div>
)}
{!loading && posts.map((post) => (
<article
key={post.id}
className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100"
>
<header className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-sky-400 text-sm font-semibold text-white">
{post.author.first_name[0]}{post.author.last_name[0]}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{post.author.first_name} {post.author.last_name}</p>
<p className="text-xs text-gray-500">{formatTimeAgo(post.created_at)}</p>
</div>
</div>
</header>
<p className="text-sm leading-6 text-gray-800 whitespace-pre-wrap">{post.content}</p>
{post.tags && post.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2 text-xs text-sky-700">
{post.tags.map((tag) => (
<span key={tag} className="rounded-full bg-sky-50 px-3 py-1">
#{tag}
</span>
))}
</div>
)}
<div className="mt-4 flex items-center gap-4 text-sm text-gray-600">
<button className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-gray-50">
<Heart size={16} className={post.is_liked ? "text-red-500 fill-red-500" : "text-gray-400"} /> {post.likes_count}
</button>
<button className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-gray-50">
<MessageSquare size={16} className="text-gray-400" /> {post.comments_count}
</button>
<button className="flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-gray-50">
<Bookmark size={16} className="text-gray-400" />
</button>
</div>
</article>
))}
</div>
</main>
<aside className="hidden w-72 flex-shrink-0 space-y-4 lg:block">
<div className="sticky top-8 space-y-4">
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between text-sm font-semibold text-gray-900">
<span>Календарь</span>
<span className="text-xs font-normal text-gray-500">Февраль 2026</span>
</div>
<div className="mt-4 grid grid-cols-7 gap-1 text-center text-xs text-gray-500">
{weekdays.map((day) => (
<div key={day} className="py-1 font-medium">
{day}
</div>
))}
</div>
<div className="mt-1 grid grid-cols-7 gap-1 text-sm text-gray-800">
{calendarWeeks.map((week, wi) =>
week.map((day, di) => {
const isToday = day === "18";
return (
<div
key={`${wi}-${di}`}
className={`flex h-9 items-center justify-center rounded-full ${
day
? isToday
? "bg-sky-100 text-sky-800 font-semibold ring-1 ring-sky-200"
: "hover:bg-sky-50"
: ""
}`}
>
{day}
</div>
);
})
)}
</div>
</div>
</div>
</aside>
</div>
</div>
);
}

View File

@ -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<HTMLInputElement>) => {
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 (
<div className="min-h-screen bg-gradient-to-b from-sky-50 to-white text-gray-900">
<div className="mx-auto flex min-h-screen max-w-4xl flex-col justify-center px-6 py-12 sm:px-10">
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white p-8 shadow-xl ring-1 ring-gray-100">
<h1 className="mb-2 text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl">
Регистрация
</h1>
<p className="mb-8 text-sm text-gray-600">
Создайте новый аккаунт, указав имя и контактные данные.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="first_name">
Имя *
</label>
<input
id="first_name"
name="first_name"
type="text"
value={formData.first_name}
onChange={handleChange}
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}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="last_name">
Фамилия *
</label>
<input
id="last_name"
name="last_name"
type="text"
value={formData.last_name}
onChange={handleChange}
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}
/>
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="middle_name">
Отчество
</label>
<input
id="middle_name"
name="middle_name"
type="text"
value={formData.middle_name}
onChange={handleChange}
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="Иванович (необязательно)"
disabled={loading}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
autoComplete="email"
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@mail.com"
disabled={loading}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="phone_number">
Телефон
</label>
<input
id="phone_number"
name="phone_number"
type="tel"
value={formData.phone_number}
onChange={handleChange}
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="+79001234567"
disabled={loading}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="telegram">
Telegram (@username)
</label>
<input
id="telegram"
name="telegram"
type="text"
value={formData.telegram}
onChange={handleChange}
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="@username"
disabled={loading}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="password">
Пароль *
</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
autoComplete="new-password"
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}
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-gray-700" htmlFor="confirmPassword">
Повторите пароль *
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
autoComplete="new-password"
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}
/>
</div>
</div>
<div className="text-sm text-gray-600 bg-sky-50 border border-sky-200 rounded-lg p-3">
<strong>Обязательные поля:</strong> Имя, Фамилия, Пароль<br />
<strong>Контакты:</strong> Укажите хотя бы один (Email, Телефон или Telegram)
</div>
<button
type="submit"
disabled={loading}
className="flex w-full items-center justify-center rounded-lg bg-sky-400 px-4 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-700 focus:outline-none focus:ring-2 focus:ring-sky-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Регистрация...' : 'Создать аккаунт'}
</button>
</form>
<div className="mt-4">
<Link
href="/login"
className="flex w-full items-center justify-center rounded-lg border border-gray-200 px-4 py-3 text-sm font-semibold text-sky-700 transition hover:border-sky-200 hover:bg-sky-50 focus:outline-none focus:ring-2 focus:ring-sky-100"
>
Уже есть аккаунт? Войти
</Link>
</div>
<p className="mt-6 text-center text-xs text-gray-500">
Регистрируясь, вы принимаете условия использования и подтверждаете ознакомление с политикой конфиденциальности.
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function RequestsPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white text-gray-900">
<div className="mx-auto max-w-4xl px-4 py-10 sm:px-8">
<h1 className="text-2xl font-bold text-gray-900">Заявления</h1>
<p className="mt-2 text-sm text-gray-600">Раздел в разработке. Здесь появятся заявки и согласования.</p>
</div>
</div>
);
}

View File

@ -1,182 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { feedApi } from '@/api/feed';
import type { Comment } from '@/types';
import { Button } from '@/components/ui/Button';
const commentSchema = z.object({
text: z.string().min(1, 'Комментарий не может быть пустым').max(1000, 'Максимум 1000 символов'),
});
type CommentFormData = z.infer<typeof commentSchema>;
interface CommentsListProps {
postId: number;
}
export function CommentsList({ postId }: CommentsListProps) {
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const { register, handleSubmit, formState: { errors }, reset } = useForm<CommentFormData>({
resolver: zodResolver(commentSchema),
});
const { data: comments, isLoading } = useQuery({
queryKey: ['comments', postId],
queryFn: () => feedApi.getComments(postId),
});
const createMutation = useMutation({
mutationFn: (data: CommentFormData) => feedApi.createComment(postId, data.text),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
queryClient.invalidateQueries({ queryKey: ['posts'] });
reset();
setShowForm(false);
},
});
const likeMutation = useMutation({
mutationFn: (comment: Comment) =>
comment.liked_by_me
? feedApi.unlikeComment(comment.id)
: feedApi.likeComment(comment.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
if (diffMins < 1) return 'только что';
if (diffMins < 60) return `${diffMins} мин назад`;
if (diffHours < 24) return `${diffHours} ч назад`;
return date.toLocaleDateString('ru-RU');
};
const onSubmit = (data: CommentFormData) => {
createMutation.mutate(data);
};
if (isLoading) {
return <div className="text-center py-4 text-gray-500">Загрузка...</div>;
}
return (
<div className="space-y-4">
{/* Форма добавления */}
{showForm ? (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-2">
<textarea
{...register('text')}
placeholder="Напишите комментарий..."
rows={3}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
{errors.text && (
<p className="text-sm text-red-600">{errors.text.message}</p>
)}
<div className="flex space-x-2">
<Button
type="submit"
size="sm"
isLoading={createMutation.isPending}
>
Отправить
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setShowForm(false);
reset();
}}
>
Отмена
</Button>
</div>
</form>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setShowForm(true)}
className="w-full"
>
💬 Добавить комментарий
</Button>
)}
{/* Список комментариев */}
{comments && comments.length > 0 ? (
<div className="space-y-3">
{comments.map((comment) => (
<div key={comment.id} className="bg-gray-50 rounded-lg p-3">
<div className="flex items-start space-x-3">
{/* Аватар автора */}
{comment.author.photo ? (
<img
src={comment.author.photo}
alt={comment.author.full_name}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<span className="text-blue-600 font-medium text-xs">
{comment.author.first_name?.[0]}{comment.author.last_name?.[0]}
</span>
</div>
)}
<div className="flex-1 min-w-0">
{/* Заголовок */}
<div className="flex items-center space-x-2 mb-1">
<span className="font-medium text-sm text-gray-900">
{comment.author.full_name}
</span>
<span className="text-xs text-gray-500">
{formatDate(comment.created_at)}
</span>
</div>
{/* Текст комментария */}
<p className="text-sm text-gray-700 whitespace-pre-wrap break-words">
{comment.text}
</p>
{/* Лайк */}
<button
onClick={() => likeMutation.mutate(comment)}
disabled={likeMutation.isPending}
className={`mt-2 flex items-center space-x-1 text-xs ${
comment.liked_by_me ? 'text-blue-600' : 'text-gray-500'
} hover:text-blue-600 transition-colors`}
>
<span>{comment.liked_by_me ? '❤️' : '🤍'}</span>
{comment.likes_count > 0 && (
<span>{comment.likes_count}</span>
)}
</button>
</div>
</div>
</div>
))}
</div>
) : (
<p className="text-center text-sm text-gray-500 py-4">
Комментариев пока нет. Будьте первым!
</p>
)}
</div>
);
}

View File

@ -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<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [hasMore, setHasMore] = useState(false);
useEffect(() => {
async function fetchPosts() {
try {
setLoading(true);
const data: PaginatedResponse<Post> = 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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
}

164
frontend/src/types/api.ts Normal file
View File

@ -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<T> {
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;
}

34
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

83
nginx.conf Normal file
View File

@ -0,0 +1,83 @@
upstream backend {
server web:8000;
}
upstream frontend {
server frontend:3000;
}
# WebSocket upgrade handling
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name localhost;
client_max_body_size 100M;
# Frontend - Next.js application
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Backend API
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Django Admin
location /admin/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket connections
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
}
# Static files
location /static/ {
alias /app/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# Media files
location /media/ {
alias /app/media/;
expires 30d;
}
# Health check
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}