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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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"]
|
||||
410
frontend/INTEGRATION_GUIDE.md
Normal 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
@ -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.
|
||||
18
frontend/eslint.config.mjs
Normal 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
@ -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
27
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
frontend/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
frontend/public/file.svg
Normal 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 |
1
frontend/public/globe.svg
Normal 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
|
After Width: | Height: | Size: 5.8 KiB |
BIN
frontend/public/logo.webp
Normal file
|
After Width: | Height: | Size: 104 KiB |
195
frontend/public/logo1.svg
Normal 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
@ -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 |
1
frontend/public/vercel.svg
Normal 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 |
1
frontend/public/window.svg
Normal 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 |
10
frontend/src/app/departments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/app/documents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
frontend/src/app/employees/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
frontend/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
10
frontend/src/app/finances/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
34
frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/app/messages/page.tsx
Normal 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
@ -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>
|
||||
);
|
||||
}
|
||||
262
frontend/src/app/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/app/requests/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
218
frontend/src/hooks/useApi.ts
Normal 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
@ -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
@ -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
@ -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;
|
||||
}
|
||||
}
|
||||