mirror of
https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git
synced 2025-08-14 00:25:46 +02:00
Проверка
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Database
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# System
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# FastAPI application package
|
68
backend/app/bot.py
Normal file
68
backend/app/bot.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from aiogram import Bot, Dispatcher, types
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..database import get_db
|
||||||
|
from .. import models
|
||||||
|
|
||||||
|
# Создаем роутер для обработки callback'ов
|
||||||
|
from aiogram import Router
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
# Обработчик нажатия кнопки
|
||||||
|
@router.callback_query(lambda c: c.data.startswith('status_'))
|
||||||
|
async def process_status_button(callback_query: types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
print(f"Hello world: {callback_query.data}")
|
||||||
|
# Получаем ID заявки и новый статус из callback_data
|
||||||
|
_, request_id, new_status = callback_query.data.split('_')
|
||||||
|
request_id = int(request_id)
|
||||||
|
|
||||||
|
# Получаем сессию базы данных
|
||||||
|
db = next(get_db())
|
||||||
|
|
||||||
|
# Обновляем статус в базе данных
|
||||||
|
request = db.query(models.Request).filter(models.Request.id == request_id).first()
|
||||||
|
if request:
|
||||||
|
request.status = new_status
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Обновляем сообщение в боте
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"Заявка №{request_id}\nСтатус: {new_status}",
|
||||||
|
reply_markup=get_updated_keyboard(request_id, new_status)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отправляем уведомление о успешном обновлении
|
||||||
|
await callback_query.answer("Статус успешно обновлен!")
|
||||||
|
else:
|
||||||
|
await callback_query.answer("Заявка не найдена!", show_alert=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in process_status_button: {e}")
|
||||||
|
await callback_query.answer("Произошла ошибка при обновлении статуса", show_alert=True)
|
||||||
|
|
||||||
|
def get_updated_keyboard(request_id: int, current_status: str) -> InlineKeyboardMarkup:
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[])
|
||||||
|
|
||||||
|
if current_status != "in_progress":
|
||||||
|
keyboard.inline_keyboard.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="В работе",
|
||||||
|
callback_data=f"status_{request_id}_in_progress"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
if current_status != "completed":
|
||||||
|
keyboard.inline_keyboard.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Завершено",
|
||||||
|
callback_data=f"status_{request_id}_completed"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
# В основном файле бота (где создается диспетчер)
|
||||||
|
dp = Dispatcher()
|
||||||
|
dp.include_router(router)
|
19
backend/app/bot/__init__.py
Normal file
19
backend/app/bot/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Bot initialization module"""
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from .config import settings
|
||||||
|
from .handlers import callbacks_router, start_router
|
||||||
|
|
||||||
|
# Initialize bot and dispatcher
|
||||||
|
bot = Bot(token=settings.bot_token)
|
||||||
|
dp = Dispatcher()
|
||||||
|
|
||||||
|
# Include routers only once during initialization
|
||||||
|
dp.include_router(callbacks_router)
|
||||||
|
dp.include_router(start_router)
|
||||||
|
|
||||||
|
async def start_bot():
|
||||||
|
"""Start the bot"""
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot, skip_updates=True)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error starting bot: {e}")
|
69
backend/app/bot/bot.py
Normal file
69
backend/app/bot/bot.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from app.bot.config import settings
|
||||||
|
|
||||||
|
bot = Bot(token="7677506032:AAHduD5EePz3bE23DKlo35KoOp2_9lZuS34")
|
||||||
|
dp = Dispatcher()
|
||||||
|
from .handlers import start, status
|
||||||
|
|
||||||
|
|
||||||
|
async def start_bot():
|
||||||
|
"""Start the bot"""
|
||||||
|
try:
|
||||||
|
await dp.start_polling(bot, skip_updates=True)
|
||||||
|
finally:
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher, types
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..database import get_db
|
||||||
|
from .. import models
|
||||||
|
|
||||||
|
# Создаем обработчик нажатия кнопки
|
||||||
|
@dp.callback_query_handler(lambda c: c.data.startswith('status_'))
|
||||||
|
async def process_status_button(callback_query: types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
# Получаем ID заявки и новый статус из callback_data
|
||||||
|
_, request_id, new_status = callback_query.data.split('_')
|
||||||
|
request_id = int(request_id)
|
||||||
|
|
||||||
|
# Получаем сессию базы данных
|
||||||
|
db = next(get_db())
|
||||||
|
|
||||||
|
# Обновляем статус в базе данных
|
||||||
|
request = db.query(models.Request).filter(models.Request.id == request_id).first()
|
||||||
|
if request:
|
||||||
|
request.status = new_status
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Обновляем сообщение в боте
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"Заявка №{request_id}\nСтатус: {new_status}",
|
||||||
|
reply_markup=get_updated_keyboard(request_id, new_status)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Отправляем уведомление о успешном обновлении
|
||||||
|
await callback_query.answer("Статус успешно обновлен!")
|
||||||
|
else:
|
||||||
|
await callback_query.answer("Заявка не найдена!", show_alert=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in process_status_button: {e}")
|
||||||
|
await callback_query.answer("Произошла ошибка при обновлении статуса", show_alert=True)
|
||||||
|
|
||||||
|
def get_updated_keyboard(request_id: int, current_status: str) -> InlineKeyboardMarkup:
|
||||||
|
keyboard = InlineKeyboardMarkup()
|
||||||
|
|
||||||
|
if current_status != "in_progress":
|
||||||
|
keyboard.add(InlineKeyboardButton(
|
||||||
|
"В работе",
|
||||||
|
callback_data=f"status_{request_id}_in_progress"
|
||||||
|
))
|
||||||
|
|
||||||
|
if current_status != "completed":
|
||||||
|
keyboard.add(InlineKeyboardButton(
|
||||||
|
"Завершено",
|
||||||
|
callback_data=f"status_{request_id}_completed"
|
||||||
|
))
|
||||||
|
|
||||||
|
return keyboard
|
26
backend/app/bot/config.py
Normal file
26
backend/app/bot/config.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""
|
||||||
|
Configuration module for the Telegram bot.
|
||||||
|
Contains all necessary settings and constants.
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Bot configuration settings"""
|
||||||
|
bot_token: str = Field("7677506032:AAHduD5EePz3bE23DKlo35KoOp2_9lZuS34", env="TELEGRAM_BOT_TOKEN")
|
||||||
|
chat_id: str = Field("5057752127", env="TELEGRAM_CHAT_ID")
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
# Create settings instance
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Request status constants
|
||||||
|
class RequestStatus:
|
||||||
|
"""Constants for request statuses"""
|
||||||
|
NEW = "new"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
73
backend/app/bot/constants.py
Normal file
73
backend/app/bot/constants.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class RequestStatus(str, Enum):
|
||||||
|
NEW = "new"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class RequestPriority(str, Enum):
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class Department(str, Enum):
|
||||||
|
AHO = "aho"
|
||||||
|
GKH = "gkh"
|
||||||
|
GENERAL = "general"
|
||||||
|
|
||||||
|
|
||||||
|
class RequestType(str, Enum):
|
||||||
|
HARDWARE = "hardware"
|
||||||
|
SOFTWARE = "software"
|
||||||
|
NETWORK = "network"
|
||||||
|
ACCESS = "access"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
STATUS_LABELS = {
|
||||||
|
RequestStatus.NEW: "Новая",
|
||||||
|
RequestStatus.IN_PROGRESS: "В работе",
|
||||||
|
RequestStatus.RESOLVED: "Решена",
|
||||||
|
RequestStatus.CLOSED: "Закрыта",
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIORITY_LABELS = {
|
||||||
|
RequestPriority.LOW: "Низкий",
|
||||||
|
RequestPriority.MEDIUM: "Средний",
|
||||||
|
RequestPriority.HIGH: "Высокий",
|
||||||
|
RequestPriority.CRITICAL: "Критический",
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIORITY_EMOJI = {
|
||||||
|
RequestPriority.LOW: "🟢",
|
||||||
|
RequestPriority.MEDIUM: "🟡",
|
||||||
|
RequestPriority.HIGH: "🟠",
|
||||||
|
RequestPriority.CRITICAL: "🔴",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEPARTMENT_LABELS = {
|
||||||
|
Department.AHO: "Административно-хозяйственный отдел",
|
||||||
|
Department.GKH: "Жилищно-коммунальное хозяйство",
|
||||||
|
Department.GENERAL: "Общий отдел",
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUEST_TYPE_LABELS = {
|
||||||
|
RequestType.HARDWARE: "Проблемы с оборудованием",
|
||||||
|
RequestType.SOFTWARE: "Проблемы с ПО",
|
||||||
|
RequestType.NETWORK: "Проблемы с сетью",
|
||||||
|
RequestType.ACCESS: "Доступ к системам",
|
||||||
|
RequestType.OTHER: "Другое",
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUEST_TYPE_EMOJI = {
|
||||||
|
RequestType.HARDWARE: "🖥️",
|
||||||
|
RequestType.SOFTWARE: "💿",
|
||||||
|
RequestType.NETWORK: "🌐",
|
||||||
|
RequestType.ACCESS: "🔑",
|
||||||
|
RequestType.OTHER: "📝",
|
||||||
|
}
|
6
backend/app/bot/handlers/__init__.py
Normal file
6
backend/app/bot/handlers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Handlers initialization"""
|
||||||
|
from .callbacks import router as callbacks_router
|
||||||
|
from .start import router as start_router
|
||||||
|
from .callbacks import get_updated_keyboard
|
||||||
|
|
||||||
|
__all__ = ['callbacks_router', 'start_router', 'get_updated_keyboard']
|
70
backend/app/bot/handlers/callbacks.py
Normal file
70
backend/app/bot/handlers/callbacks.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Handlers for callback queries"""
|
||||||
|
from aiogram import Router, types
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ...database import get_db
|
||||||
|
from ... import models
|
||||||
|
from ..config import RequestStatus
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
@router.callback_query(lambda c: c.data and c.data.startswith('status_'))
|
||||||
|
async def process_status_button(callback_query: types.CallbackQuery):
|
||||||
|
"""
|
||||||
|
Handle status update button clicks.
|
||||||
|
Updates request status in database and updates message in Telegram.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse callback data
|
||||||
|
_, request_id, new_status = callback_query.data.split('_')
|
||||||
|
request_id = int(request_id)
|
||||||
|
|
||||||
|
# Get database session
|
||||||
|
db = next(get_db())
|
||||||
|
|
||||||
|
# Update request status
|
||||||
|
request = db.query(models.Request).filter(models.Request.id == request_id).first()
|
||||||
|
if request:
|
||||||
|
request.status = new_status
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Update message in Telegram
|
||||||
|
await callback_query.message.edit_text(
|
||||||
|
f"Заявка №{request_id}\n"
|
||||||
|
f"Статус: {new_status}\n"
|
||||||
|
f"Описание: {request.description}",
|
||||||
|
reply_markup=get_updated_keyboard(request_id, new_status)
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback_query.answer("Статус успешно обновлен!")
|
||||||
|
else:
|
||||||
|
await callback_query.answer("Заявка не найдена!", show_alert=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in process_status_button: {e}")
|
||||||
|
await callback_query.answer(
|
||||||
|
"Произошла ошибка при обновлении статуса",
|
||||||
|
show_alert=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_updated_keyboard(request_id: int, current_status: str) -> InlineKeyboardMarkup:
|
||||||
|
"""Create keyboard with status update buttons"""
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=[])
|
||||||
|
|
||||||
|
if current_status != RequestStatus.IN_PROGRESS:
|
||||||
|
keyboard.inline_keyboard.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="В работе",
|
||||||
|
callback_data=f"status_{request_id}_{RequestStatus.IN_PROGRESS}"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
if current_status != RequestStatus.COMPLETED:
|
||||||
|
keyboard.inline_keyboard.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Завершено",
|
||||||
|
callback_data=f"status_{request_id}_{RequestStatus.COMPLETED}"
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
return keyboard
|
11
backend/app/bot/handlers/start.py
Normal file
11
backend/app/bot/handlers/start.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"""Handler for start command and other basic commands"""
|
||||||
|
from aiogram import Router, types
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
@router.message(Command("start"))
|
||||||
|
async def cmd_start(message: types.Message):
|
||||||
|
"""Handle /start command"""
|
||||||
|
await message.answer("Бот для обработки заявок запущен!")
|
61
backend/app/bot/handlers/status.py
Normal file
61
backend/app/bot/handlers/status.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from aiogram import types, F
|
||||||
|
from logging import getLogger
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ...database import get_db
|
||||||
|
from ...crud import requests
|
||||||
|
from ..bot import dp
|
||||||
|
from ..keyboards import create_status_keyboard
|
||||||
|
from ..messages import format_request_message
|
||||||
|
from ..constants import STATUS_LABELS
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
@dp.callback_query(F.data.startswith("status_"))
|
||||||
|
async def process_status_update(callback: types.CallbackQuery):
|
||||||
|
try:
|
||||||
|
parts = callback.data.split("_")
|
||||||
|
logger.info(f"Received callback data: {callback.data}")
|
||||||
|
|
||||||
|
if len(parts) < 3:
|
||||||
|
logger.error(f"Invalid callback data format: {parts}")
|
||||||
|
await callback.answer("Неверный формат данных", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
request_id = int(parts[1])
|
||||||
|
new_status = "_".join(parts[2:]) if len(parts) > 3 else parts[2]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Processing status update: request_id={request_id}, new_status={new_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
updated_request = requests.update_request_status(db, request_id, new_status)
|
||||||
|
if not updated_request:
|
||||||
|
logger.warning(f"Request not found: {request_id}")
|
||||||
|
await callback.answer("Заявка не найдена", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
new_message = format_request_message(updated_request)
|
||||||
|
new_keyboard = create_status_keyboard(request_id, new_status)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
text=new_message, parse_mode="HTML", reply_markup=new_keyboard
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.answer(f"Статус обновлен: {STATUS_LABELS[new_status]}")
|
||||||
|
logger.info(
|
||||||
|
f"Successfully updated request {request_id} to status {new_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"Value error while updating status: {e}")
|
||||||
|
await callback.answer(str(e), show_alert=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing callback: {e}", exc_info=True)
|
||||||
|
await callback.answer(
|
||||||
|
"Произошла ошибка при обновлении статуса", show_alert=True
|
||||||
|
)
|
33
backend/app/bot/keyboards.py
Normal file
33
backend/app/bot/keyboards.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from logging import getLogger
|
||||||
|
from .constants import STATUS_LABELS
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
def create_status_keyboard(
|
||||||
|
request_id: int, current_status: str
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
status_transitions = {
|
||||||
|
"new": ["in_progress"],
|
||||||
|
"in_progress": ["resolved"],
|
||||||
|
"resolved": ["closed"],
|
||||||
|
"closed": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
available_statuses = status_transitions.get(current_status, [])
|
||||||
|
|
||||||
|
for status in available_statuses:
|
||||||
|
callback_data = f"status_{request_id}_{status}"
|
||||||
|
logger.debug(f"Creating button with callback_data: {callback_data}")
|
||||||
|
buttons.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=STATUS_LABELS[status], callback_data=callback_data
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
logger.debug(f"Created keyboard: {keyboard}")
|
||||||
|
return keyboard
|
37
backend/app/bot/messages.py
Normal file
37
backend/app/bot/messages.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from .constants import (
|
||||||
|
STATUS_LABELS,
|
||||||
|
PRIORITY_LABELS,
|
||||||
|
PRIORITY_EMOJI,
|
||||||
|
DEPARTMENT_LABELS,
|
||||||
|
REQUEST_TYPE_LABELS,
|
||||||
|
REQUEST_TYPE_EMOJI,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_request_message(request_data: dict) -> str:
|
||||||
|
created_at = datetime.fromisoformat(request_data["created_at"]).strftime(
|
||||||
|
"%d.%m.%Y %H:%M"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get translated values
|
||||||
|
department = DEPARTMENT_LABELS.get(
|
||||||
|
request_data["department"], request_data["department"]
|
||||||
|
)
|
||||||
|
request_type = REQUEST_TYPE_LABELS.get(
|
||||||
|
request_data["request_type"], request_data["request_type"]
|
||||||
|
)
|
||||||
|
priority = PRIORITY_LABELS.get(request_data["priority"], request_data["priority"])
|
||||||
|
status = STATUS_LABELS.get(request_data.get("status", "new"), "Неизвестно")
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"📋 <b>Заявка #{request_data['id']}</b>\n\n"
|
||||||
|
f"👤 <b>Сотрудник:</b> {request_data['employee_last_name']} {request_data['employee_first_name']}\n"
|
||||||
|
f"🏢 <b>Отдел:</b> {department}\n"
|
||||||
|
f"🚪 <b>Кабинет:</b> {request_data['office']}\n"
|
||||||
|
f"{REQUEST_TYPE_EMOJI.get(request_data['request_type'], '📝')} <b>Тип заявки:</b> {request_type}\n"
|
||||||
|
f"{PRIORITY_EMOJI.get(request_data['priority'], '⚪')} <b>Приоритет:</b> {priority}\n\n"
|
||||||
|
f"📝 <b>Описание:</b>\n<blockquote>{request_data['description']}</blockquote>\n\n"
|
||||||
|
f"🕒 <b>Создана:</b> {created_at}\n"
|
||||||
|
f"📊 <b>Статус:</b> {status}"
|
||||||
|
)
|
23
backend/app/bot/notifications.py
Normal file
23
backend/app/bot/notifications.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Notifications module for the Telegram bot"""
|
||||||
|
from .config import settings
|
||||||
|
from . import bot
|
||||||
|
from .handlers import get_updated_keyboard
|
||||||
|
|
||||||
|
async def send_notification(request_data: dict):
|
||||||
|
"""Send notification about new request to Telegram chat"""
|
||||||
|
message_text = (
|
||||||
|
f"Новая заявка №{request_data['id']}\n"
|
||||||
|
f"Отдел: {request_data['department']}\n"
|
||||||
|
f"Тип: {request_data['request_type']}\n"
|
||||||
|
f"Приоритет: {request_data['priority']}\n"
|
||||||
|
f"Описание: {request_data['description']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=settings.chat_id,
|
||||||
|
text=message_text,
|
||||||
|
reply_markup=get_updated_keyboard(request_data['id'], "new")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error sending notification: {e}")
|
26
backend/app/crud/auth.py
Normal file
26
backend/app/crud/auth.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..schemas import tables
|
||||||
|
from ..utils.auth import verify_password
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_employee(db: Session, last_name: str, password: str):
|
||||||
|
employee = db.query(tables.Employee).filter(tables.Employee.last_name == last_name).first()
|
||||||
|
if not employee:
|
||||||
|
return None
|
||||||
|
if not verify_password(password, employee.password):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": employee.id,
|
||||||
|
"firstName": employee.first_name,
|
||||||
|
"lastName": employee.last_name,
|
||||||
|
"department": employee.department,
|
||||||
|
"office": employee.office,
|
||||||
|
"createdAt": employee.created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_admin(db: Session, username: str, password: str):
|
||||||
|
# Здесь можно добавить логику для админа, пока используем хардкод
|
||||||
|
if username == "admin" and password == "admin66":
|
||||||
|
return True
|
||||||
|
return False
|
45
backend/app/crud/employees.py
Normal file
45
backend/app/crud/employees.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..models import employee as models
|
||||||
|
from ..schemas import tables
|
||||||
|
from ..utils.auth import get_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
def get_employee(db: Session, employee_id: int):
|
||||||
|
return db.query(tables.Employee).filter(tables.Employee.id == employee_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_employee_by_lastname(db: Session, last_name: str):
|
||||||
|
return (
|
||||||
|
db.query(tables.Employee).filter(tables.Employee.last_name == last_name).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_employees(db: Session, skip: int = 0, limit: int = 100):
|
||||||
|
return db.query(tables.Employee).offset(skip).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
def create_employee(db: Session, employee: models.EmployeeCreate):
|
||||||
|
hashed_password = get_password_hash(employee.password)
|
||||||
|
db_employee = tables.Employee(
|
||||||
|
first_name=employee.first_name,
|
||||||
|
last_name=employee.last_name,
|
||||||
|
department=employee.department,
|
||||||
|
office=employee.office,
|
||||||
|
password=hashed_password,
|
||||||
|
)
|
||||||
|
db.add(db_employee)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_employee)
|
||||||
|
return db_employee
|
||||||
|
|
||||||
|
|
||||||
|
def update_employee(db: Session, employee_id: int, data: dict):
|
||||||
|
db_employee = get_employee(db, employee_id)
|
||||||
|
if db_employee:
|
||||||
|
for key, value in data.items():
|
||||||
|
if key == "password":
|
||||||
|
value = get_password_hash(value)
|
||||||
|
setattr(db_employee, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_employee)
|
||||||
|
return db_employee
|
207
backend/app/crud/requests.py
Normal file
207
backend/app/crud/requests.py
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..models import request as models
|
||||||
|
from ..schemas import tables
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..schemas import tables
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def create_request(db: Session, request: models.RequestCreate):
|
||||||
|
db_request = tables.Request(
|
||||||
|
employee_id=request.employee_id,
|
||||||
|
department=request.department,
|
||||||
|
request_type=request.request_type,
|
||||||
|
priority=request.priority,
|
||||||
|
description=request.description,
|
||||||
|
status="new",
|
||||||
|
)
|
||||||
|
db.add(db_request)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_request)
|
||||||
|
return db_request
|
||||||
|
|
||||||
|
|
||||||
|
def get_requests(db: Session, skip: int = 0, limit: int = 100):
|
||||||
|
requests = (
|
||||||
|
db.query(
|
||||||
|
tables.Request,
|
||||||
|
tables.Employee.last_name.label("employee_last_name"),
|
||||||
|
tables.Employee.first_name.label("employee_first_name"),
|
||||||
|
)
|
||||||
|
.join(tables.Employee)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": req[0].id,
|
||||||
|
"employee_id": req[0].employee_id,
|
||||||
|
"department": req[0].department,
|
||||||
|
"request_type": req[0].request_type,
|
||||||
|
"priority": req[0].priority,
|
||||||
|
"status": req[0].status,
|
||||||
|
"description": req[0].description,
|
||||||
|
"created_at": req[0].created_at,
|
||||||
|
"employee_last_name": req[1],
|
||||||
|
"employee_first_name": req[2],
|
||||||
|
}
|
||||||
|
for req in requests
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_requests_by_employee_lastname(db: Session, last_name: str):
|
||||||
|
requests = (
|
||||||
|
db.query(
|
||||||
|
tables.Request,
|
||||||
|
tables.Employee.last_name.label("employee_last_name"),
|
||||||
|
tables.Employee.first_name.label("employee_first_name"),
|
||||||
|
)
|
||||||
|
.join(tables.Employee)
|
||||||
|
.filter(tables.Employee.last_name.ilike(f"%{last_name}%"))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": req[0].id,
|
||||||
|
"employee_id": req[0].employee_id,
|
||||||
|
"department": req[0].department,
|
||||||
|
"request_type": req[0].request_type,
|
||||||
|
"priority": req[0].priority,
|
||||||
|
"status": req[0].status,
|
||||||
|
"description": req[0].description,
|
||||||
|
"created_at": req[0].created_at,
|
||||||
|
"employee_last_name": req[1],
|
||||||
|
"employee_first_name": req[2],
|
||||||
|
}
|
||||||
|
for req in requests
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def update_request_status(
|
||||||
|
db: Session, request_id: int, new_status: models.RequestStatus
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
db_request = (
|
||||||
|
db.query(tables.Request).filter(tables.Request.id == request_id).first()
|
||||||
|
)
|
||||||
|
if not db_request:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Define valid status transitions
|
||||||
|
valid_transitions = {
|
||||||
|
models.RequestStatus.NEW: [models.RequestStatus.IN_PROGRESS],
|
||||||
|
models.RequestStatus.IN_PROGRESS: [models.RequestStatus.RESOLVED],
|
||||||
|
models.RequestStatus.RESOLVED: [models.RequestStatus.CLOSED],
|
||||||
|
models.RequestStatus.CLOSED: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
current_status = models.RequestStatus(db_request.status)
|
||||||
|
if new_status not in valid_transitions[current_status]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid status transition from {current_status} to {new_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_request.status = new_status
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_request)
|
||||||
|
return db_request
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_details(db: Session, request_id: int):
|
||||||
|
"""Get detailed request information including employee details"""
|
||||||
|
request = (
|
||||||
|
db.query(tables.Request)
|
||||||
|
.join(tables.Employee)
|
||||||
|
.filter(tables.Request.id == request_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not request:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": request.id,
|
||||||
|
"employee_last_name": request.employee.last_name,
|
||||||
|
"employee_first_name": request.employee.first_name,
|
||||||
|
"department": request.department,
|
||||||
|
"office": request.employee.office,
|
||||||
|
"request_type": request.request_type,
|
||||||
|
"priority": request.priority,
|
||||||
|
"description": request.description,
|
||||||
|
"status": request.status,
|
||||||
|
"created_at": request.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..schemas import tables
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def get_request_details(db: Session, request_id: int):
|
||||||
|
"""Get detailed request information including employee details"""
|
||||||
|
request = (
|
||||||
|
db.query(tables.Request)
|
||||||
|
.join(tables.Employee)
|
||||||
|
.filter(tables.Request.id == request_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not request:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": request.id,
|
||||||
|
"employee_last_name": request.employee.last_name,
|
||||||
|
"employee_first_name": request.employee.first_name,
|
||||||
|
"department": request.department,
|
||||||
|
"office": request.employee.office,
|
||||||
|
"request_type": request.request_type,
|
||||||
|
"priority": request.priority,
|
||||||
|
"description": request.description,
|
||||||
|
"status": request.status,
|
||||||
|
"created_at": request.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_request_status(db: Session, request_id: int, new_status: str):
|
||||||
|
"""Update request status with validation"""
|
||||||
|
try:
|
||||||
|
# Define valid status transitions
|
||||||
|
valid_transitions = {
|
||||||
|
"new": ["in_progress"],
|
||||||
|
"in_progress": ["resolved"],
|
||||||
|
"resolved": ["closed"],
|
||||||
|
"closed": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
db_request = (
|
||||||
|
db.query(tables.Request).filter(tables.Request.id == request_id).first()
|
||||||
|
)
|
||||||
|
if not db_request:
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_status = db_request.status
|
||||||
|
if new_status not in valid_transitions.get(current_status, []):
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid status transition from {current_status} to {new_status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
db_request.status = new_status
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_request)
|
||||||
|
|
||||||
|
# Get full request details after update
|
||||||
|
return get_request_details(db, request_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise e
|
89
backend/app/crud/statistics.py
Normal file
89
backend/app/crud/statistics.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from sqlalchemy import func, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from ..schemas import tables
|
||||||
|
from ..models.request import RequestStatus
|
||||||
|
|
||||||
|
|
||||||
|
def get_statistics(db: Session, period: str = "week"):
|
||||||
|
# Calculate date range based on period
|
||||||
|
now = datetime.now()
|
||||||
|
if period == "day":
|
||||||
|
start_date = now - timedelta(days=1)
|
||||||
|
elif period == "week":
|
||||||
|
start_date = now - timedelta(weeks=1)
|
||||||
|
elif period == "month":
|
||||||
|
start_date = now - timedelta(days=30)
|
||||||
|
else: # all time
|
||||||
|
start_date = datetime.min
|
||||||
|
|
||||||
|
# Total requests
|
||||||
|
total_requests = db.query(func.count(tables.Request.id)).scalar() or 0
|
||||||
|
|
||||||
|
# Resolved requests in period
|
||||||
|
resolved_requests = (
|
||||||
|
db.query(func.count(tables.Request.id))
|
||||||
|
.filter(tables.Request.status == RequestStatus.RESOLVED)
|
||||||
|
.filter(tables.Request.created_at >= start_date)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Average resolution time (in hours)
|
||||||
|
avg_resolution = (
|
||||||
|
db.query(
|
||||||
|
func.avg(func.julianday("now") - func.julianday(tables.Request.created_at))
|
||||||
|
* 24
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
tables.Request.status == RequestStatus.RESOLVED,
|
||||||
|
tables.Request.created_at >= start_date,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_resolution_time = f"{int(avg_resolution or 0)}ч" if avg_resolution else "0ч"
|
||||||
|
|
||||||
|
# Request volume over time
|
||||||
|
volume_data = (
|
||||||
|
db.query(
|
||||||
|
func.date(tables.Request.created_at).label("date"),
|
||||||
|
func.count(tables.Request.id).label("count"),
|
||||||
|
)
|
||||||
|
.filter(tables.Request.created_at >= start_date)
|
||||||
|
.group_by(text("date"))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Request types distribution
|
||||||
|
type_distribution = (
|
||||||
|
db.query(tables.Request.request_type, func.count(tables.Request.id))
|
||||||
|
.group_by(tables.Request.request_type)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status distribution
|
||||||
|
status_distribution = (
|
||||||
|
db.query(tables.Request.status, func.count(tables.Request.id))
|
||||||
|
.group_by(tables.Request.status)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure all statuses are represented
|
||||||
|
all_statuses = {status.value: 0 for status in RequestStatus}
|
||||||
|
for status, count in status_distribution:
|
||||||
|
all_statuses[status] = count
|
||||||
|
|
||||||
|
status_data = [(status, count) for status, count in all_statuses.items()]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalRequests": total_requests,
|
||||||
|
"resolvedRequests": resolved_requests,
|
||||||
|
"averageResolutionTime": avg_resolution_time,
|
||||||
|
"volumeLabels": [str(d[0]) for d in volume_data],
|
||||||
|
"volumeData": [d[1] for d in volume_data],
|
||||||
|
"typeLabels": [t[0] for t in type_distribution],
|
||||||
|
"typeData": [t[1] for t in type_distribution],
|
||||||
|
"statusLabels": [s[0] for s in status_data],
|
||||||
|
"statusData": [s[1] for s in status_data],
|
||||||
|
}
|
20
backend/app/database.py
Normal file
20
backend/app/database.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
56
backend/app/logging_config.py
Normal file
56
backend/app/logging_config.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Logging configuration for the application"""
|
||||||
|
|
||||||
|
logging_config = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"default": {
|
||||||
|
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S"
|
||||||
|
},
|
||||||
|
"access": {
|
||||||
|
"format": "%(asctime)s - %(name)s - %(levelname)s - %(client_addr)s - %(request_line)s - %(status_code)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"level": "INFO",
|
||||||
|
"formatter": "default",
|
||||||
|
"stream": "ext://sys.stdout"
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"class": "logging.handlers.RotatingFileHandler",
|
||||||
|
"level": "INFO",
|
||||||
|
"formatter": "default",
|
||||||
|
"filename": "logs/app.log",
|
||||||
|
"maxBytes": 10485760, # 10MB
|
||||||
|
"backupCount": 5
|
||||||
|
},
|
||||||
|
"access_file": {
|
||||||
|
"class": "logging.handlers.RotatingFileHandler",
|
||||||
|
"level": "INFO",
|
||||||
|
"formatter": "access",
|
||||||
|
"filename": "logs/access.log",
|
||||||
|
"maxBytes": 10485760, # 10MB
|
||||||
|
"backupCount": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"": { # Root logger
|
||||||
|
"handlers": ["console", "file"],
|
||||||
|
"level": "INFO"
|
||||||
|
},
|
||||||
|
"app": { # Application logger
|
||||||
|
"handlers": ["console", "file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False
|
||||||
|
},
|
||||||
|
"app.access": { # Access logger
|
||||||
|
"handlers": ["access_file"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
39
backend/app/main.py
Normal file
39
backend/app/main.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from fastapi import FastAPI, Depends, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.openapi.docs import get_swagger_ui_html
|
||||||
|
from fastapi.openapi.utils import get_openapi
|
||||||
|
import logging
|
||||||
|
from logging.config import dictConfig
|
||||||
|
from .logging_config import logging_config
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
dictConfig(logging_config)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Support Portal API",
|
||||||
|
description="API for managing support requests and employees",
|
||||||
|
version="1.0.0",
|
||||||
|
docs_url=None,
|
||||||
|
redoc_url=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Custom OpenAPI documentation
|
||||||
|
@app.get("/api/docs", include_in_schema=False)
|
||||||
|
async def custom_swagger_ui_html():
|
||||||
|
return get_swagger_ui_html(
|
||||||
|
openapi_url="/api/openapi.json",
|
||||||
|
title="Support Portal API Documentation",
|
||||||
|
swagger_favicon_url="/favicon.ico"
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/api/openapi.json", include_in_schema=False)
|
||||||
|
async def get_open_api_endpoint():
|
||||||
|
return get_openapi(
|
||||||
|
title="Support Portal API",
|
||||||
|
version="1.0.0",
|
||||||
|
description="API for managing support requests and employees",
|
||||||
|
routes=app.routes
|
||||||
|
)
|
||||||
|
|
||||||
|
# Existing middleware and routes...
|
3
backend/app/middleware/__init__.py
Normal file
3
backend/app/middleware/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .logging import LoggingMiddleware
|
||||||
|
|
||||||
|
__all__ = ['LoggingMiddleware']
|
39
backend/app/middleware/logging.py
Normal file
39
backend/app/middleware/logging.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Logging middleware for request/response tracking"""
|
||||||
|
import time
|
||||||
|
from fastapi import Request
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.access")
|
||||||
|
|
||||||
|
class LoggingMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Log request
|
||||||
|
logger.info(
|
||||||
|
"Request started",
|
||||||
|
extra={
|
||||||
|
"client_addr": request.client.host,
|
||||||
|
"request_line": f"{request.method} {request.url.path}",
|
||||||
|
"status_code": "PENDING"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
# Calculate processing time
|
||||||
|
process_time = time.time() - start_time
|
||||||
|
|
||||||
|
# Log response
|
||||||
|
logger.info(
|
||||||
|
"Request completed",
|
||||||
|
extra={
|
||||||
|
"client_addr": request.client.host,
|
||||||
|
"request_line": f"{request.method} {request.url.path}",
|
||||||
|
"status_code": response.status_code,
|
||||||
|
"process_time": f"{process_time:.2f}s"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
21
backend/app/models/employee.py
Normal file
21
backend/app/models/employee.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeBase(BaseModel):
|
||||||
|
last_name: str
|
||||||
|
first_name: str
|
||||||
|
department: str
|
||||||
|
office: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeCreate(EmployeeBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Employee(EmployeeBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
50
backend/app/models/request.py
Normal file
50
backend/app/models/request.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class RequestStatus(str, Enum):
|
||||||
|
NEW = "new"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
RESOLVED = "resolved"
|
||||||
|
CLOSED = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
class RequestPriority(str, Enum):
|
||||||
|
LOW = "low"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
HIGH = "high"
|
||||||
|
CRITICAL = "critical"
|
||||||
|
|
||||||
|
|
||||||
|
class StatusUpdate(BaseModel):
|
||||||
|
status: RequestStatus
|
||||||
|
|
||||||
|
|
||||||
|
class RequestBase(BaseModel):
|
||||||
|
department: str
|
||||||
|
request_type: str
|
||||||
|
priority: RequestPriority
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestCreate(RequestBase):
|
||||||
|
employee_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class Request(RequestBase):
|
||||||
|
id: int
|
||||||
|
status: RequestStatus
|
||||||
|
created_at: datetime
|
||||||
|
employee_id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class RequestWithEmployee(Request):
|
||||||
|
employee_last_name: str
|
||||||
|
employee_first_name: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
34
backend/app/schemas/tables.py
Normal file
34
backend/app/schemas/tables.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from ..database import Base
|
||||||
|
from ..models.request import RequestStatus, RequestPriority
|
||||||
|
|
||||||
|
|
||||||
|
class Employee(Base):
|
||||||
|
__tablename__ = "employees"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
first_name = Column(String, nullable=False)
|
||||||
|
last_name = Column(String, nullable=False)
|
||||||
|
department = Column(String, nullable=False)
|
||||||
|
office = Column(String, nullable=False)
|
||||||
|
password = Column(String, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
requests = relationship("Request", back_populates="employee")
|
||||||
|
|
||||||
|
|
||||||
|
class Request(Base):
|
||||||
|
__tablename__ = "requests"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
employee_id = Column(Integer, ForeignKey("employees.id"))
|
||||||
|
department = Column(String, nullable=False)
|
||||||
|
request_type = Column(String, nullable=False)
|
||||||
|
priority = Column(Enum(RequestPriority), nullable=False)
|
||||||
|
status = Column(Enum(RequestStatus), default=RequestStatus.NEW)
|
||||||
|
description = Column(String)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
employee = relationship("Employee", back_populates="requests")
|
11
backend/app/utils/auth.py
Normal file
11
backend/app/utils/auth.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_password_hash(password: str) -> str:
|
||||||
|
return pwd_context.hash(password)
|
45
backend/app/utils/constants.py
Normal file
45
backend/app/utils/constants.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
STATUS_LABELS = {
|
||||||
|
'new': 'Новая',
|
||||||
|
'in_progress': 'В работе',
|
||||||
|
'resolved': 'Решена',
|
||||||
|
'closed': 'Закрыта'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Priority translations and emoji
|
||||||
|
PRIORITY_LABELS = {
|
||||||
|
'low': 'Низкий',
|
||||||
|
'medium': 'Средний',
|
||||||
|
'high': 'Высокий',
|
||||||
|
'critical': 'Критический'
|
||||||
|
}
|
||||||
|
|
||||||
|
PRIORITY_EMOJI = {
|
||||||
|
'low': '🟢',
|
||||||
|
'medium': '🟡',
|
||||||
|
'high': '🟠',
|
||||||
|
'critical': '🔴'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Department translations
|
||||||
|
DEPARTMENT_LABELS = {
|
||||||
|
'aho': 'Административно-хозяйственный отдел',
|
||||||
|
'gkh': 'Жилищно-коммунальное хозяйство',
|
||||||
|
'general': 'Общий отдел'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Request type translations and emoji
|
||||||
|
REQUEST_TYPE_LABELS = {
|
||||||
|
'hardware': 'Проблемы с оборудованием',
|
||||||
|
'software': 'Проблемы с ПО',
|
||||||
|
'network': 'Проблемы с сетью',
|
||||||
|
'access': 'Доступ к системам',
|
||||||
|
'other': 'Другое'
|
||||||
|
}
|
||||||
|
|
||||||
|
REQUEST_TYPE_EMOJI = {
|
||||||
|
'hardware': '🖥️',
|
||||||
|
'software': '💿',
|
||||||
|
'network': '🌐',
|
||||||
|
'access': '🔑',
|
||||||
|
'other': '📝'
|
||||||
|
}
|
90
backend/app/utils/telegram.py
Normal file
90
backend/app/utils/telegram.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from logging import getLogger
|
||||||
|
from .constants import (
|
||||||
|
STATUS_LABELS, PRIORITY_LABELS, PRIORITY_EMOJI,
|
||||||
|
DEPARTMENT_LABELS, REQUEST_TYPE_LABELS, REQUEST_TYPE_EMOJI
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize logger
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
# Initialize bot with token
|
||||||
|
bot = Bot(token="7677506032:AAHEqNUr1lIUfNVbLwaWIaPeKKShsCyz3eo")
|
||||||
|
|
||||||
|
# Chat ID for notifications
|
||||||
|
CHAT_ID = "5057752127"
|
||||||
|
|
||||||
|
def create_status_keyboard(request_id: int, current_status: str) -> InlineKeyboardMarkup:
|
||||||
|
"""Create inline keyboard with status buttons"""
|
||||||
|
status_transitions = {
|
||||||
|
'new': ['in_progress'],
|
||||||
|
'in_progress': ['resolved'],
|
||||||
|
'resolved': ['closed'],
|
||||||
|
'closed': []
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
available_statuses = status_transitions.get(current_status, [])
|
||||||
|
|
||||||
|
for status in available_statuses:
|
||||||
|
callback_data = f"status_{request_id}_{status}"
|
||||||
|
logger.debug(f"Creating button with callback_data: {callback_data}")
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=STATUS_LABELS[status],
|
||||||
|
callback_data=callback_data
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
logger.debug(f"Created keyboard: {keyboard}")
|
||||||
|
return keyboard
|
||||||
|
|
||||||
|
def format_request_message(request_data: dict) -> str:
|
||||||
|
"""Format request data into a message"""
|
||||||
|
created_at = datetime.fromisoformat(request_data['created_at']).strftime('%d.%m.%Y %H:%M')
|
||||||
|
|
||||||
|
# Get translated values
|
||||||
|
department = DEPARTMENT_LABELS.get(request_data['department'], request_data['department'])
|
||||||
|
request_type = REQUEST_TYPE_LABELS.get(request_data['request_type'], request_data['request_type'])
|
||||||
|
priority = PRIORITY_LABELS.get(request_data['priority'], request_data['priority'])
|
||||||
|
status = STATUS_LABELS.get(request_data.get('status', 'new'), 'Неизвестно')
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"📋 <b>Заявка #{request_data['id']}</b>\n\n"
|
||||||
|
f"👤 <b>Сотрудник:</b> {request_data['employee_last_name']} {request_data['employee_first_name']}\n"
|
||||||
|
f"🏢 <b>Отдел:</b> {department}\n"
|
||||||
|
f"🚪 <b>Кабинет:</b> {request_data['office']}\n"
|
||||||
|
f"{REQUEST_TYPE_EMOJI.get(request_data['request_type'], '📝')} <b>Тип заявки:</b> {request_type}\n"
|
||||||
|
f"{PRIORITY_EMOJI.get(request_data['priority'], '⚪')} <b>Приоритет:</b> {priority}\n\n"
|
||||||
|
f"📝 <b>Описание:</b>\n{request_data['description']}\n\n"
|
||||||
|
f"🕒 <b>Создана:</b> {created_at}\n"
|
||||||
|
f"📊 <b>Статус:</b> {status}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_request_notification(request_data: dict):
|
||||||
|
"""Send notification about request to Telegram"""
|
||||||
|
try:
|
||||||
|
message = format_request_message(request_data)
|
||||||
|
keyboard = create_status_keyboard(request_data['id'], request_data.get('status', 'new'))
|
||||||
|
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=CHAT_ID,
|
||||||
|
text=message,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_markup=keyboard
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error sending Telegram notification: {e}", exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def send_notification(request_data: dict):
|
||||||
|
"""Wrapper to run async notification in sync context"""
|
||||||
|
try:
|
||||||
|
asyncio.run(send_request_notification(request_data))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send notification: {e}", exc_info=True)
|
||||||
|
raise
|
55
backend/crud.py
Normal file
55
backend/crud.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from models import request as models
|
||||||
|
from schemas import tables
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
def create_request(db: Session, request_data: dict):
|
||||||
|
db_request = tables.Request(
|
||||||
|
employee_id=request_data["employee_id"],
|
||||||
|
department=request_data["department"],
|
||||||
|
request_type=request_data["request_type"],
|
||||||
|
priority=request_data["priority"],
|
||||||
|
description=request_data["description"],
|
||||||
|
status="new",
|
||||||
|
)
|
||||||
|
db.add(db_request)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_request)
|
||||||
|
return db_request
|
||||||
|
|
||||||
|
|
||||||
|
def get_requests(db: Session, skip: int = 0, limit: int = 100) -> List[tables.Request]:
|
||||||
|
return (
|
||||||
|
db.query(tables.Request)
|
||||||
|
.join(tables.Employee)
|
||||||
|
.add_columns(
|
||||||
|
tables.Employee.last_name.label("employee_last_name"),
|
||||||
|
tables.Employee.first_name.label("employee_first_name"),
|
||||||
|
)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_requests_by_employee_lastname(
|
||||||
|
db: Session, last_name: str
|
||||||
|
) -> List[tables.Request]:
|
||||||
|
return (
|
||||||
|
db.query(tables.Request)
|
||||||
|
.join(tables.Employee)
|
||||||
|
.filter(tables.Employee.last_name.ilike(f"%{last_name}%"))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def update_request_status(db: Session, request_id: int, new_status: str):
|
||||||
|
db_request = (
|
||||||
|
db.query(tables.Request).filter(tables.Request.id == request_id).first()
|
||||||
|
)
|
||||||
|
if db_request:
|
||||||
|
db_request.status = new_status
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_request)
|
||||||
|
return db_request
|
20
backend/database.py
Normal file
20
backend/database.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./sql_app.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
23
backend/models.py
Normal file
23
backend/models.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from .database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Employee(Base):
|
||||||
|
__tablename__ = "employees"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
last_name = Column(String, index=True)
|
||||||
|
# другие поля...
|
||||||
|
|
||||||
|
requests = relationship("Request", back_populates="employee")
|
||||||
|
|
||||||
|
|
||||||
|
class Request(Base):
|
||||||
|
__tablename__ = "requests"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
employee_id = Column(Integer, ForeignKey("employees.id"))
|
||||||
|
# другие поля...
|
||||||
|
|
||||||
|
employee = relationship("Employee", back_populates="requests")
|
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.110.0
|
||||||
|
uvicorn==0.27.1
|
||||||
|
sqlalchemy==2.0.27
|
||||||
|
pydantic==2.6.3
|
||||||
|
pydantic-settings==2.2.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]>=1.7.4
|
||||||
|
bcrypt>=4.0.1
|
||||||
|
aiogram>=3.4.1
|
||||||
|
python-dotenv==1.0.1
|
38
backend/run.py
Normal file
38
backend/run.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Main application entry point.
|
||||||
|
Runs both the FastAPI application and Telegram bot.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import uvicorn
|
||||||
|
from app.main import app
|
||||||
|
from app.bot import start_bot
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
async def run_api():
|
||||||
|
"""Run FastAPI application"""
|
||||||
|
config = uvicorn.Config(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
reload=True
|
||||||
|
)
|
||||||
|
server = uvicorn.Server(config)
|
||||||
|
await server.serve()
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Run both bot and API in the main thread"""
|
||||||
|
try:
|
||||||
|
# Создаем задачи для бота и API
|
||||||
|
bot_task = asyncio.create_task(start_bot())
|
||||||
|
api_task = asyncio.create_task(run_api())
|
||||||
|
|
||||||
|
# Запускаем обе задачи
|
||||||
|
await asyncio.gather(bot_task, api_task)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Application crashed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Запускаем в основном потоке
|
||||||
|
asyncio.run(main())
|
44
backend/schemas.py
Normal file
44
backend/schemas.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from models import RequestStatus
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeBase(BaseModel):
|
||||||
|
last_name: str
|
||||||
|
first_name: str
|
||||||
|
department: str
|
||||||
|
office: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeCreate(EmployeeBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Employee(EmployeeBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class RequestBase(BaseModel):
|
||||||
|
department: str
|
||||||
|
request_type: str
|
||||||
|
priority: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestCreate(RequestBase):
|
||||||
|
employee_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class Request(RequestBase):
|
||||||
|
id: int
|
||||||
|
status: RequestStatus
|
||||||
|
created_at: datetime
|
||||||
|
employee_id: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
66
docker-compose.yml
Normal file
66
docker-compose.yml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/frontend/Dockerfile
|
||||||
|
container_name: support-frontend
|
||||||
|
volumes:
|
||||||
|
- frontend_build:/app/dist
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/backend/Dockerfile
|
||||||
|
container_name: support-backend
|
||||||
|
environment:
|
||||||
|
- TELEGRAM_BOT_TOKEN=7677506032:AAHEqNUr1lIUfNVbLwaWIaPeKKShsCyz3eo
|
||||||
|
- TELEGRAM_CHAT_ID=-1002037023574
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./sql_app.db:/app/sql_app.db:rw
|
||||||
|
- ./logs:/app/logs:rw
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/nginx/Dockerfile
|
||||||
|
container_name: support-nginx
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- frontend_build:/usr/share/nginx/html
|
||||||
|
- ./docker/nginx/conf.d:/etc/nginx/conf.d
|
||||||
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
|
- ./certbot/www:/var/www/certbot
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- backend
|
||||||
|
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/certbot
|
||||||
|
container_name: support-certbot
|
||||||
|
volumes:
|
||||||
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
|
- ./certbot/www:/var/www/certbot
|
||||||
|
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
frontend_build:
|
26
docker/backend/Dockerfile
Normal file
26
docker/backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
RUN mkdir -p /app/logs
|
||||||
|
|
||||||
|
# Copy requirements first to leverage Docker cache
|
||||||
|
COPY backend/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY backend/ .
|
||||||
|
|
||||||
|
# Create volume for logs
|
||||||
|
VOLUME ["/app/logs"]
|
||||||
|
|
||||||
|
# Expose the port the app runs on
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["python", "run.py"]
|
18
docker/frontend/Dockerfile
Normal file
18
docker/frontend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем package.json и package-lock.json
|
||||||
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
|
# Устанавливаем зависимости
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Копируем исходный код
|
||||||
|
COPY frontend/ ./
|
||||||
|
|
||||||
|
# Собираем приложение
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Держим контейнер запущенным
|
||||||
|
CMD ["tail", "-f", "/dev/null"]
|
14
docker/nginx/Dockerfile
Normal file
14
docker/nginx/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Копируем конфигурацию nginx
|
||||||
|
COPY docker/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Копируем собранные файлы фронтенда
|
||||||
|
COPY frontend/dist /usr/share/nginx/html/
|
||||||
|
|
||||||
|
# Удаляем дефолтную страницу nginx
|
||||||
|
RUN rm -rf /usr/share/nginx/html/50x.html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
41
docker/nginx/conf.d/default.conf
Normal file
41
docker/nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
upstream backend_upstream {
|
||||||
|
server support-backend:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name itformhelp.ru www.itformhelp.ru;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
|
||||||
|
# SPA routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Редирект с IP на домен
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name 185.139.70.62;
|
||||||
|
return 301 http://itformhelp.ru$request_uri;
|
||||||
|
}
|
25
docker/nginx/nginx.conf
Normal file
25
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log notice;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Администрация КАО</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
2908
frontend/package-lock.json
generated
Normal file
2908
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-portal",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
|
"@vueuse/core": "^10.9.0",
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
|
"lucide-vue-next": "^0.344.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vue-tsc": "^2.0.6"
|
||||||
|
}
|
||||||
|
}
|
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
14
frontend/src/App.vue
Normal file
14
frontend/src/App.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col bg-slate-100">
|
||||||
|
<Header />
|
||||||
|
<main class="flex-grow container mx-auto px-3 sm:px-4 py-4 sm:py-8">
|
||||||
|
<router-view></router-view>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Header from './components/Header.vue';
|
||||||
|
import Footer from './components/Footer.vue';
|
||||||
|
</script>
|
45
frontend/src/components/Footer.vue
Normal file
45
frontend/src/components/Footer.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="bg-slate-800 text-white">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<PhoneIcon :size="16" class="text-blue-400" />
|
||||||
|
Контактная информация
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1 text-sm text-slate-300">
|
||||||
|
<p>8 (800) 123-45-67</p>
|
||||||
|
<p>support@admincity.ru</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<ClockIcon :size="16" class="text-blue-400" />
|
||||||
|
Режим работы
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1 text-sm text-slate-300">
|
||||||
|
<p>Пн-Пт: 9:45 - 17:45</p>
|
||||||
|
<p>Сб-Вс: выходной</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<MailIcon :size="16" class="text-blue-400" />
|
||||||
|
Техподдержка
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1 text-sm text-slate-300">
|
||||||
|
<p>Время реакции: до 2 часов</p>
|
||||||
|
<p>support@admincity.ru</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-slate-700 mt-3 pt-2 text-center text-xs text-slate-400">
|
||||||
|
<p>© 2024 Администрация КАО. Все права защищены.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PhoneIcon, ClockIcon, MailIcon } from 'lucide-vue-next';
|
||||||
|
</script>
|
26
frontend/src/components/Header.vue
Normal file
26
frontend/src/components/Header.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<header class="bg-slate-800 text-white py-3">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-center gap-2">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<Building2Icon :size="28" class="text-blue-400" />
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<h1 class="text-lg sm:text-xl font-semibold">Администрация КАО</h1>
|
||||||
|
<p class="text-xs sm:text-sm text-slate-300">Портал технической поддержки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PhoneIcon :size="18" class="text-blue-400" />
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<p class="text-xs text-slate-300">Поддержка:</p>
|
||||||
|
<p class="text-sm font-semibold">8 (800) 123-45-67</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Building2Icon, PhoneIcon } from 'lucide-vue-next';
|
||||||
|
</script>
|
190
frontend/src/components/admin/EmployeeForm.vue
Normal file
190
frontend/src/components/admin/EmployeeForm.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-8 max-w-xl w-full mx-4 shadow-xl">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-50 rounded-lg">
|
||||||
|
<component
|
||||||
|
:is="employee ? UserIcon : UserPlusIcon"
|
||||||
|
:size="24"
|
||||||
|
class="text-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
{{ employee ? 'Редактирование сотрудника' : 'Добавление сотрудника' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="text-gray-400 hover:text-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Фамилия<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<UserIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.last_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Введите фамилию"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Имя<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<UserIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.first_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Введите имя"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Отдел<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<BuildingIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="formData.department"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Выберите отдел</option>
|
||||||
|
<option v-for="dept in departments" :key="dept.value" :value="dept.value">
|
||||||
|
{{ dept.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Кабинет<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<DoorClosedIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.office"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Номер кабинета"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Пароль{{ !employee ? '*' : '' }}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<LockIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.password"
|
||||||
|
type="password"
|
||||||
|
:required="!employee"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="employee" class="mt-1 text-sm text-gray-500">
|
||||||
|
Оставьте пустым, чтобы не менять пароль
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-8 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<XIcon :size="16" />
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<component :is="employee ? SaveIcon : UserPlusIcon" :size="16" />
|
||||||
|
{{ employee ? 'Сохранить' : 'Добавить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { XIcon, UserIcon, BuildingIcon, DoorClosedIcon, LockIcon, UserPlusIcon, SaveIcon } from 'lucide-vue-next';
|
||||||
|
import { departments } from '@/utils/constants';
|
||||||
|
import type { EmployeeFormData } from '@/types/employee';
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
employee?: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'submit', data: any): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formData = ref<EmployeeFormData>({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
department: '',
|
||||||
|
office: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.employee) {
|
||||||
|
formData.value = {
|
||||||
|
first_name: props.employee.first_name,
|
||||||
|
last_name: props.employee.last_name,
|
||||||
|
department: props.employee.department,
|
||||||
|
office: props.employee.office,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const data = { ...formData.value };
|
||||||
|
if (props.employee && !data.password) {
|
||||||
|
delete data.password; // Теперь это безопасно, так как password опциональный
|
||||||
|
}
|
||||||
|
emit('submit', data);
|
||||||
|
}
|
||||||
|
</script>
|
125
frontend/src/components/admin/EmployeeList.vue
Normal file
125
frontend/src/components/admin/EmployeeList.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { departments } from '@/utils/constants';
|
||||||
|
import type { Employee } from '@/types/employee';
|
||||||
|
|
||||||
|
const employees = ref<Employee[]>([]); // Добавляем типизацию массива сотрудников
|
||||||
|
const showAddForm = ref(false);
|
||||||
|
const editingEmployee = ref<Employee | null>(null);
|
||||||
|
|
||||||
|
function getDepartmentLabel(value: string) {
|
||||||
|
return departments.find(d => d.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editEmployee(employee: any) {
|
||||||
|
editingEmployee.value = employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
showAddForm.value = false;
|
||||||
|
editingEmployee.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(data: any) {
|
||||||
|
try {
|
||||||
|
if (editingEmployee.value) {
|
||||||
|
// Update existing employee
|
||||||
|
await fetch(`/api/employees/${editingEmployee.value.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new employee
|
||||||
|
const response = await fetch('/api/employees/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to create employee');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fetchEmployees();
|
||||||
|
closeForm();
|
||||||
|
alert(editingEmployee.value ? 'Сотрудник обновлен' : 'Сотрудник добавлен');
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Ошибка: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEmployees() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/employees/');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch employees');
|
||||||
|
employees.value = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching employees:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchEmployees);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-lg font-semibold">Сотрудники</h2>
|
||||||
|
<button
|
||||||
|
@click="showAddForm = true"
|
||||||
|
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Добавить сотрудника
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Фамилия</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Имя</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Отдел</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кабинет</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="employee in employees" :key="employee.id">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ employee.last_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ employee.first_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ getDepartmentLabel(employee.department) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ employee.office }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<button
|
||||||
|
@click="editEmployee(employee)"
|
||||||
|
class="text-blue-600 hover:text-blue-900 mr-4"
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee form modal -->
|
||||||
|
<EmployeeForm
|
||||||
|
v-if="showAddForm || editingEmployee"
|
||||||
|
:employee="editingEmployee"
|
||||||
|
@close="closeForm"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
64
frontend/src/components/admin/RequestDescriptionModal.vue
Normal file
64
frontend/src/components/admin/RequestDescriptionModal.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center">
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">Описание заявки №{{ request.id }}</h3>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<XIcon :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Сотрудник:</span>
|
||||||
|
<p>{{ request.employee_last_name }} {{ request.employee_first_name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Кабинет:</span>
|
||||||
|
<p>{{ request.employee_office }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Тип заявки:</span>
|
||||||
|
<p>{{ getRequestTypeLabel(request.request_type) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Приоритет:</span>
|
||||||
|
<RequestPriorityBadge :priority="request.priority" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Описание проблемы:</span>
|
||||||
|
<p class="mt-2 text-gray-700 whitespace-pre-wrap">{{ request.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { XIcon } from 'lucide-vue-next';
|
||||||
|
import { getRequestTypeLabel } from '@/utils/constants';
|
||||||
|
import RequestPriorityBadge from './RequestPriorityBadge.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
request: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
198
frontend/src/components/admin/RequestList.vue
Normal file
198
frontend/src/components/admin/RequestList.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-semibold">Заявки</h2>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по фамилии..."
|
||||||
|
class="px-3 py-1 border rounded-md"
|
||||||
|
@input="handleSearch"
|
||||||
|
/>
|
||||||
|
<select v-model="filter" class="px-3 py-1 border rounded-md" @change="handleFilter">
|
||||||
|
<option value="all">Все заявки</option>
|
||||||
|
<option value="new">Новые</option>
|
||||||
|
<option value="in_progress">В работе</option>
|
||||||
|
<option value="resolved">Решенные</option>
|
||||||
|
<option value="closed">Закрытые</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">№</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Сотрудник</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кабинет</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Приоритет</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Статус</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="request in filteredRequests"
|
||||||
|
:key="request.id"
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ request.id }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ formatDate(request.created_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ request.employee_last_name }} {{ request.employee_first_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ request.employee_office }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<button
|
||||||
|
@click="showDescription(request)"
|
||||||
|
class="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
{{ getRequestTypeLabel(request.request_type) }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<RequestPriorityBadge :priority="request.priority" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<RequestStatusBadge :status="request.status" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
@click="openStatusModal(request)"
|
||||||
|
class="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
Изменить статус
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description modal -->
|
||||||
|
<RequestDescriptionModal
|
||||||
|
v-if="selectedDescription"
|
||||||
|
:request="selectedDescription"
|
||||||
|
@close="selectedDescription = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Status update modal -->
|
||||||
|
<RequestStatusModal
|
||||||
|
v-if="selectedRequest"
|
||||||
|
:current-status="selectedRequest.status"
|
||||||
|
@close="selectedRequest = null"
|
||||||
|
@update="handleStatusUpdate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Success notification -->
|
||||||
|
<Notification
|
||||||
|
:show="showNotification"
|
||||||
|
message="Статус заявки успешно обновлен"
|
||||||
|
@close="showNotification = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import type { Request } from '@/types/request';
|
||||||
|
import RequestStatusBadge from './RequestStatusBadge.vue';
|
||||||
|
import RequestPriorityBadge from './RequestPriorityBadge.vue';
|
||||||
|
import RequestStatusModal from './RequestStatusModal.vue';
|
||||||
|
import RequestDescriptionModal from './RequestDescriptionModal.vue';
|
||||||
|
import Notification from '@/components/ui/Notification.vue';
|
||||||
|
import { getRequestTypeLabel } from '@/utils/constants';
|
||||||
|
const requests = ref<Request[]>([]); // Типизируем массив запросов
|
||||||
|
const selectedRequest = ref<Request | null>(null);
|
||||||
|
const selectedDescription = ref<Request | null>(null);
|
||||||
|
const showNotification = ref(false);
|
||||||
|
const filter = ref('all');
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
const filteredRequests = computed(() => {
|
||||||
|
let result = requests.value;
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
result = result.filter(request =>
|
||||||
|
request.employee_last_name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value !== 'all') {
|
||||||
|
result = result.filter(request => request.status === filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return new Date(date).toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusModal(request: any) {
|
||||||
|
selectedRequest.value = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDescription(request: any) {
|
||||||
|
selectedDescription.value = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusUpdate(newStatus: string) {
|
||||||
|
if (!selectedRequest.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/requests/${selectedRequest.value.id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update status');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchRequests();
|
||||||
|
selectedRequest.value = null;
|
||||||
|
showNotification.value = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
showNotification.value = false;
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
alert('Не удалось обновить статус');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRequests() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/requests/');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch requests');
|
||||||
|
requests.value = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching requests:', error);
|
||||||
|
alert('Не удалось загрузить заявки');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
// Debounce could be added here if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilter() {
|
||||||
|
// Additional filter logic if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchRequests);
|
||||||
|
</script>
|
28
frontend/src/components/admin/RequestPriorityBadge.vue
Normal file
28
frontend/src/components/admin/RequestPriorityBadge.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="[
|
||||||
|
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
|
||||||
|
priorityClasses[priority]
|
||||||
|
]">
|
||||||
|
{{ priorityLabels[priority] }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { priority } = defineProps<{
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical'
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const priorityClasses = {
|
||||||
|
low: 'bg-green-100 text-green-800',
|
||||||
|
medium: 'bg-yellow-100 text-yellow-800',
|
||||||
|
high: 'bg-orange-100 text-orange-800',
|
||||||
|
critical: 'bg-red-100 text-red-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityLabels = {
|
||||||
|
low: 'Низкий',
|
||||||
|
medium: 'Средний',
|
||||||
|
high: 'Высокий',
|
||||||
|
critical: 'Критический'
|
||||||
|
};
|
||||||
|
</script>
|
30
frontend/src/components/admin/RequestStatusBadge.vue
Normal file
30
frontend/src/components/admin/RequestStatusBadge.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="[
|
||||||
|
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
|
||||||
|
statusClasses[status]
|
||||||
|
]">
|
||||||
|
{{ statusLabels[status] }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
status: 'new' | 'in_progress' | 'resolved' | 'closed'
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const statusClasses = {
|
||||||
|
new: 'bg-blue-100 text-blue-800',
|
||||||
|
in_progress: 'bg-yellow-100 text-yellow-800',
|
||||||
|
resolved: 'bg-green-100 text-green-800',
|
||||||
|
closed: 'bg-gray-100 text-gray-800'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
new: 'Новая',
|
||||||
|
in_progress: 'В работе',
|
||||||
|
resolved: 'Решена',
|
||||||
|
closed: 'Закрыта'
|
||||||
|
};
|
||||||
|
</script>
|
64
frontend/src/components/admin/RequestStatusModal.vue
Normal file
64
frontend/src/components/admin/RequestStatusModal.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center">
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Изменение статуса заявки</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Текущий статус
|
||||||
|
</label>
|
||||||
|
<RequestStatusBadge :status="currentStatus" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">
|
||||||
|
Выберите новый статус
|
||||||
|
</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="status in allStatuses"
|
||||||
|
:key="status"
|
||||||
|
@click="handleStatusSelect(status)"
|
||||||
|
:disabled="status === currentStatus"
|
||||||
|
:class="[
|
||||||
|
'w-full text-left px-4 py-2 rounded-md border transition-colors',
|
||||||
|
status === currentStatus
|
||||||
|
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<RequestStatusBadge :status="status" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RequestStatusBadge from './RequestStatusBadge.vue';
|
||||||
|
import type { RequestStatus } from '@/types/request';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentStatus: RequestStatus;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'update']);
|
||||||
|
|
||||||
|
const allStatuses: RequestStatus[] = ['new', 'in_progress', 'resolved', 'closed'];
|
||||||
|
|
||||||
|
function handleStatusSelect(newStatus: RequestStatus) {
|
||||||
|
if (newStatus === props.currentStatus) return;
|
||||||
|
emit('update', newStatus);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
95
frontend/src/components/admin/StatisticsPanel.vue
Normal file
95
frontend/src/components/admin/StatisticsPanel.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Period selector -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button
|
||||||
|
v-for="option in periodOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="period = option.value"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-md text-sm',
|
||||||
|
period === option.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="stat in summaryStats"
|
||||||
|
:key="stat.label"
|
||||||
|
class="bg-white p-4 rounded-lg shadow"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-500">{{ stat.label }}</div>
|
||||||
|
<div class="text-2xl font-semibold mt-1">{{ stat.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<VolumeChart
|
||||||
|
:labels="chartData.volumeLabels"
|
||||||
|
:data="chartData.volumeData"
|
||||||
|
/>
|
||||||
|
<TypesChart
|
||||||
|
:labels="chartData.typeLabels"
|
||||||
|
:data="chartData.typeData"
|
||||||
|
/>
|
||||||
|
<StatusChart
|
||||||
|
:labels="chartData.statusLabels"
|
||||||
|
:data="chartData.statusData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
|
import VolumeChart from './charts/VolumeChart.vue';
|
||||||
|
import TypesChart from './charts/TypesChart.vue';
|
||||||
|
import StatusChart from './charts/StatusChart.vue';
|
||||||
|
|
||||||
|
const period = ref('week');
|
||||||
|
const chartData = ref({
|
||||||
|
volumeLabels: [],
|
||||||
|
volumeData: [],
|
||||||
|
typeLabels: [],
|
||||||
|
typeData: [],
|
||||||
|
statusLabels: [],
|
||||||
|
statusData: [],
|
||||||
|
totalRequests: 0,
|
||||||
|
resolvedRequests: 0,
|
||||||
|
averageResolutionTime: '0ч'
|
||||||
|
});
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ value: 'day', label: 'День' },
|
||||||
|
{ value: 'week', label: 'Неделя' },
|
||||||
|
{ value: 'month', label: 'Месяц' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const summaryStats = computed(() => [
|
||||||
|
{ label: 'Всего заявок', value: chartData.value.totalRequests },
|
||||||
|
{ label: 'Решено за период', value: chartData.value.resolvedRequests },
|
||||||
|
{ label: 'Среднее время решения', value: chartData.value.averageResolutionTime }
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchStatistics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/statistics?period=${period.value}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch statistics');
|
||||||
|
chartData.value = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching statistics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(period, fetchStatistics);
|
||||||
|
onMounted(fetchStatistics);
|
||||||
|
</script>
|
87
frontend/src/components/admin/charts/StatusChart.vue
Normal file
87
frontend/src/components/admin/charts/StatusChart.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Распределение по статусам</h3>
|
||||||
|
<div class="h-48">
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { Chart } from 'chart.js/auto';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
new: 'Новые',
|
||||||
|
in_progress: 'В работе',
|
||||||
|
resolved: 'Решены',
|
||||||
|
closed: 'Закрыты'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
new: '#3b82f6',
|
||||||
|
in_progress: '#f59e0b',
|
||||||
|
resolved: '#10b981',
|
||||||
|
closed: '#6b7280'
|
||||||
|
};
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return;
|
||||||
|
|
||||||
|
chart?.destroy();
|
||||||
|
chart = new Chart(chartRef.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: props.labels.map(status => statusLabels[status as keyof typeof statusLabels]),
|
||||||
|
datasets: [{
|
||||||
|
data: props.data,
|
||||||
|
backgroundColor: props.labels.map(status => statusColors[status as keyof typeof statusColors]),
|
||||||
|
barThickness: 30,
|
||||||
|
maxBarThickness: 35
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
font: { size: 11 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
font: { size: 11 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
padding: {
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
bottom: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.labels, props.data], createChart, { deep: true });
|
||||||
|
onMounted(createChart);
|
||||||
|
</script>
|
63
frontend/src/components/admin/charts/TypesChart.vue
Normal file
63
frontend/src/components/admin/charts/TypesChart.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Типы заявок</h3>
|
||||||
|
<div class="h-48">
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { Chart } from 'chart.js/auto';
|
||||||
|
import { getRequestTypeLabel } from '@/utils/constants';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#3b82f6',
|
||||||
|
'#10b981',
|
||||||
|
'#f59e0b',
|
||||||
|
'#ef4444',
|
||||||
|
'#8b5cf6'
|
||||||
|
];
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return;
|
||||||
|
|
||||||
|
chart?.destroy();
|
||||||
|
chart = new Chart(chartRef.value, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: props.labels.map(getRequestTypeLabel),
|
||||||
|
datasets: [{
|
||||||
|
data: props.data,
|
||||||
|
backgroundColor: colors
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
font: {
|
||||||
|
size: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.labels, props.data], createChart, { deep: true });
|
||||||
|
onMounted(createChart);
|
||||||
|
</script>
|
57
frontend/src/components/admin/charts/VolumeChart.vue
Normal file
57
frontend/src/components/admin/charts/VolumeChart.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="md:col-span-2 bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Количество заявок</h3>
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { Chart } from 'chart.js/auto';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return;
|
||||||
|
|
||||||
|
chart?.destroy();
|
||||||
|
chart = new Chart(chartRef.value, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: props.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Количество заявок',
|
||||||
|
data: props.data,
|
||||||
|
borderColor: '#2563eb',
|
||||||
|
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.labels, props.data], createChart, { deep: true });
|
||||||
|
onMounted(createChart);
|
||||||
|
</script>
|
151
frontend/src/components/admin/employee/EmployeeFormModal.vue
Normal file
151
frontend/src/components/admin/employee/EmployeeFormModal.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-8 max-w-xl w-full mx-4 shadow-xl">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-50 rounded-lg">
|
||||||
|
<component
|
||||||
|
:is="employee ? UserIcon : UserPlusIcon"
|
||||||
|
:size="24"
|
||||||
|
class="text-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
{{ employee ? 'Редактирование сотрудника' : 'Добавление сотрудника' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="text-gray-400 hover:text-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
v-model="formData.last_name"
|
||||||
|
label="Фамилия"
|
||||||
|
required
|
||||||
|
placeholder="Введите фамилию"
|
||||||
|
:icon="UserIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
v-model="formData.first_name"
|
||||||
|
label="Имя"
|
||||||
|
required
|
||||||
|
placeholder="Введите имя"
|
||||||
|
:icon="UserIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
v-model="formData.department"
|
||||||
|
label="Отдел"
|
||||||
|
type="select"
|
||||||
|
required
|
||||||
|
:options="departmentOptions"
|
||||||
|
:icon="BuildingIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
v-model="formData.office"
|
||||||
|
label="Кабинет"
|
||||||
|
required
|
||||||
|
placeholder="Номер кабинета"
|
||||||
|
:icon="DoorClosedIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<FormField
|
||||||
|
v-model="formData.password"
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
:required="!employee"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
:icon="LockIcon"
|
||||||
|
:help="employee ? 'Оставьте пустым, чтобы не менять пароль' : undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-8 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<XIcon :size="16" />
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<component :is="employee ? SaveIcon : UserPlusIcon" :size="16" />
|
||||||
|
{{ employee ? 'Сохранить' : 'Добавить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { XIcon, UserIcon, BuildingIcon, DoorClosedIcon, LockIcon, UserPlusIcon, SaveIcon } from 'lucide-vue-next';
|
||||||
|
import { departments } from '@/utils/constants';
|
||||||
|
import FormField from '@/components/ui/FormField.vue';
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
employee?: Employee;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const departmentOptions = departments.map(d => ({
|
||||||
|
value: d.value,
|
||||||
|
label: d.label
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'submit', data: any): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
department: '',
|
||||||
|
office: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.employee) {
|
||||||
|
formData.value = {
|
||||||
|
first_name: props.employee.first_name,
|
||||||
|
last_name: props.employee.last_name,
|
||||||
|
department: props.employee.department,
|
||||||
|
office: props.employee.office,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const data = { ...formData.value };
|
||||||
|
if (props.employee && !data.password) {
|
||||||
|
formData.value.password = ''; // Вместо delete используем присваивание пустой строки
|
||||||
|
}
|
||||||
|
emit('submit', data);
|
||||||
|
}
|
||||||
|
</script>
|
162
frontend/src/components/request/RequestForm.vue
Normal file
162
frontend/src/components/request/RequestForm.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Тип обращения<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="formData.request_type"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Выберите тип обращения</option>
|
||||||
|
<option v-for="type in requestTypes" :key="type.value" :value="type.value">
|
||||||
|
{{ type.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Приоритет<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="formData.priority"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Выберите приоритет</option>
|
||||||
|
<option value="low">Низкий</option>
|
||||||
|
<option value="medium">Средний</option>
|
||||||
|
<option value="high">Высокий</option>
|
||||||
|
<option value="critical">Критический</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Описание проблемы<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.description"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
rows="4"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Опишите вашу проблему..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 text-sm sm:text-base rounded-md hover:bg-blue-700 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<component :is="isSubmitting ? LoaderIcon : SendIcon"
|
||||||
|
:size="18"
|
||||||
|
:class="{ 'animate-spin': isSubmitting }"
|
||||||
|
/>
|
||||||
|
{{ isSubmitting ? 'Отправка...' : 'Отправить заявку' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transform ease-out duration-300"
|
||||||
|
enter-from-class="translate-y-2 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-200"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showNotification"
|
||||||
|
class="fixed top-4 right-4 z-50 max-w-sm w-full bg-green-50 rounded-lg shadow-lg ring-1 ring-green-500 ring-opacity-5 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<CheckCircleIcon class="h-5 w-5 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium text-green-800">
|
||||||
|
Заявка успешно отправлена. Ожидайте прибытия технического специалиста.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0 flex">
|
||||||
|
<button
|
||||||
|
@click="showNotification = false"
|
||||||
|
class="inline-flex text-green-500 hover:text-green-600 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { SendIcon, LoaderIcon, CheckCircleIcon, XIcon } from 'lucide-vue-next';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { requestTypes } from '@/utils/constants';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const showNotification = ref(false);
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
request_type: '',
|
||||||
|
priority: '',
|
||||||
|
description: '',
|
||||||
|
department: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!authStore.user) {
|
||||||
|
alert('Необходимо авторизоваться');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/requests/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...formData.value,
|
||||||
|
employee_id: parseInt(authStore.user.id),
|
||||||
|
department: authStore.user.department
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to submit request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
showNotification.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showNotification.value = false;
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
formData.value = {
|
||||||
|
request_type: '',
|
||||||
|
priority: '',
|
||||||
|
description: '',
|
||||||
|
department: ''
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting request:', error);
|
||||||
|
alert('Ошибка при отправке заявки');
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
60
frontend/src/components/ui/FormField.vue
Normal file
60
frontend/src/components/ui/FormField.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{{ label }}<span v-if="required" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div v-if="icon" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<component :is="icon" size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="type === 'select'">
|
||||||
|
<select
|
||||||
|
:value="modelValue"
|
||||||
|
@input="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||||
|
:required="required"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Выберите {{ label.toLowerCase() }}</option>
|
||||||
|
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
:type="type"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
:required="required"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot name="help"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Component } from 'vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
help?: string;
|
||||||
|
icon?: Component;
|
||||||
|
size?: number | string; // Добавляем поддержку как числа, так и строки
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
61
frontend/src/components/ui/Notification.vue
Normal file
61
frontend/src/components/ui/Notification.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transform ease-out duration-300 transition"
|
||||||
|
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||||
|
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||||
|
leave-active-class="transition ease-in duration-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed top-4 right-4 z-50 max-w-sm w-full bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 overflow-hidden"
|
||||||
|
:class="{
|
||||||
|
'bg-green-50 ring-green-500': type === 'success',
|
||||||
|
'bg-red-50 ring-red-500': type === 'error'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<component
|
||||||
|
:is="type === 'error' ? XCircleIcon : CheckCircleIcon"
|
||||||
|
class="h-5 w-5"
|
||||||
|
:class="type === 'error' ? 'text-red-400' : 'text-green-400'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium" :class="type === 'error' ? 'text-red-800' : 'text-green-800'">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0 flex">
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||||
|
:class="type === 'error' ? 'text-red-500 hover:text-red-600 focus:ring-red-500' : 'text-green-500 hover:text-green-600 focus:ring-green-500'"
|
||||||
|
>
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircleIcon, XIcon, XCircleIcon } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
type?: 'success' | 'error';
|
||||||
|
}>(), {
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
31
frontend/src/composables/useNotification.ts
Normal file
31
frontend/src/composables/useNotification.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export function useNotification() {
|
||||||
|
const show = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const type = ref<'success' | 'error'>('success');
|
||||||
|
|
||||||
|
function showNotification(newMessage: string, newType: 'success' | 'error' = 'success', duration = 3000) {
|
||||||
|
message.value = newMessage;
|
||||||
|
type.value = newType;
|
||||||
|
show.value = true;
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
show.value = false;
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideNotification() {
|
||||||
|
show.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
showNotification,
|
||||||
|
hideNotification
|
||||||
|
};
|
||||||
|
}
|
17
frontend/src/composables/utils/constants.ts
Normal file
17
frontend/src/composables/utils/constants.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const requestTypes = [
|
||||||
|
{ value: 'hardware', label: 'Проблемы с оборудованием' },
|
||||||
|
{ value: 'software', label: 'Проблемы с программным обеспечением' },
|
||||||
|
{ value: 'network', label: 'Проблемы с сетью' },
|
||||||
|
{ value: 'access', label: 'Доступ к системам' },
|
||||||
|
{ value: 'other', label: 'Другое' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const departments = [
|
||||||
|
{ value: 'aho', label: 'Административно-хозяйственный отдел' },
|
||||||
|
{ value: 'gkh', label: 'Жилищно-коммунальное хозяйство' },
|
||||||
|
{ value: 'general', label: 'Общий отдел' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getRequestTypeLabel(value: string): string {
|
||||||
|
return requestTypes.find(type => type.value === value)?.label || value;
|
||||||
|
}
|
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(router);
|
||||||
|
app.mount('#app');
|
48
frontend/src/router/index.ts
Normal file
48
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import LoginView from '../views/LoginView.vue';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/support',
|
||||||
|
name: 'support',
|
||||||
|
// Ошибка: Не найден модуль '../views/SupportView.vue'
|
||||||
|
component: () => import('../views/SupportView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'admin-login',
|
||||||
|
// Ошибка: Не найден модуль '../views/admin/AdminLoginView.vue'
|
||||||
|
component: () => import('../views/admin/AdminLoginView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/dashboard',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
// Ошибка: Не найден модуль '../views/admin/DashboardView.vue'
|
||||||
|
component: () => import('../views/admin/DashboardView.vue'),
|
||||||
|
meta: { requiresAdmin: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to, _, next) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next({ name: 'login' });
|
||||||
|
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
|
next({ name: 'admin-login' });
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
79
frontend/src/stores/auth.ts
Normal file
79
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { User } from '@/types/auth';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref<User | null>(null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!user.value);
|
||||||
|
|
||||||
|
function setUser(newUser: User | null) {
|
||||||
|
user.value = newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdmin(value: boolean) {
|
||||||
|
isAdmin.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(lastName: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ lastName, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json();
|
||||||
|
setUser(userData);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminLogin(username: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdmin(true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
user.value = null;
|
||||||
|
isAdmin.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isAdmin,
|
||||||
|
isAuthenticated,
|
||||||
|
setUser,
|
||||||
|
setAdmin,
|
||||||
|
login,
|
||||||
|
adminLogin,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
});
|
36
frontend/src/types/auth.ts
Normal file
36
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
department: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
lastName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Request {
|
||||||
|
id: number;
|
||||||
|
employee_last_name: string;
|
||||||
|
employee_first_name: string;
|
||||||
|
employee_office: string;
|
||||||
|
request_type: string;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
status: 'new' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
created_at: string;
|
||||||
|
}
|
17
frontend/src/types/employee.ts
Normal file
17
frontend/src/types/employee.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface Employee {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeFormData {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
password?: string; // Делаем password опциональным
|
||||||
|
}
|
||||||
|
|
14
frontend/src/types/request.ts
Normal file
14
frontend/src/types/request.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type RequestStatus = 'new' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
|
||||||
|
export interface Request {
|
||||||
|
id: number;
|
||||||
|
status: RequestStatus;
|
||||||
|
created_at: string;
|
||||||
|
employee_last_name: string;
|
||||||
|
employee_first_name: string;
|
||||||
|
employee_office: string;
|
||||||
|
request_type: string;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
98
frontend/src/views/LoginView.vue
Normal file
98
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-slate-800 mb-4">Вход в систему</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Фамилия
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<UserIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите фамилию"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<LockIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="isLoading ? LoaderIcon : LogInIcon"
|
||||||
|
:size="18"
|
||||||
|
:class="{ 'animate-spin': isLoading }"
|
||||||
|
/>
|
||||||
|
{{ isLoading ? 'Вход...' : 'Войти' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<router-link
|
||||||
|
to="/admin"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Вход для администраторов
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { UserIcon, LockIcon, LogInIcon, LoaderIcon } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const lastName = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const success = await authStore.login(lastName.value, password.value);
|
||||||
|
if (success) {
|
||||||
|
router.push('/support');
|
||||||
|
} else {
|
||||||
|
alert('Неверная фамилия или пароль');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка авторизации');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
16
frontend/src/views/SupportView.vue
Normal file
16
frontend/src/views/SupportView.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-4 sm:p-6">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">
|
||||||
|
Техническая поддержка
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm sm:text-base text-slate-600 mb-4 sm:mb-6">
|
||||||
|
Заполните форму для создания заявки в IT-отдел
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RequestForm />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RequestForm from '@/components/request/RequestForm.vue';
|
||||||
|
</script>
|
97
frontend/src/views/admin/AdminLoginView.vue
Normal file
97
frontend/src/views/admin/AdminLoginView.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-slate-800 mb-4">Вход в админ-панель</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<UserIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите логин"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<LockIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="isLoading ? LoaderIcon : LogInIcon"
|
||||||
|
:size="18"
|
||||||
|
:class="{ 'animate-spin': isLoading }"
|
||||||
|
/>
|
||||||
|
{{ isLoading ? 'Вход...' : 'Войти' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Вернуться к входу для сотрудников
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { UserIcon, LockIcon, LogInIcon, LoaderIcon } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const username = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const success = await authStore.adminLogin(username.value, password.value);
|
||||||
|
if (success) {
|
||||||
|
router.push('/admin/dashboard');
|
||||||
|
} else {
|
||||||
|
alert('Неверные учетные данные');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка авторизации');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
64
frontend/src/views/admin/DashboardView.vue
Normal file
64
frontend/src/views/admin/DashboardView.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Панель администратора</h1>
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div class="border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="currentTab = tab.id"
|
||||||
|
:class="[
|
||||||
|
currentTab === tab.id
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||||
|
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ tab.name }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<StatisticsPanel v-if="currentTab === 'statistics'" />
|
||||||
|
<RequestList v-if="currentTab === 'requests'" />
|
||||||
|
<EmployeeList v-if="currentTab === 'employees'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import RequestList from '@/components/admin/RequestList.vue';
|
||||||
|
import EmployeeList from '@/components/admin/EmployeeList.vue';
|
||||||
|
import StatisticsPanel from '@/components/admin/StatisticsPanel.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'statistics', name: 'Статистика' },
|
||||||
|
{ id: 'requests', name: 'Заявки' },
|
||||||
|
{ id: 'employees', name: 'Сотрудники' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentTab = ref('statistics');
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
authStore.logout();
|
||||||
|
router.push('/admin');
|
||||||
|
}
|
||||||
|
</script>
|
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
20
frontend/vite.config.ts
Normal file
20
frontend/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { fileURLToPath, URL } from 'url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
27
init-letsencrypt.sh
Normal file
27
init-letsencrypt.sh
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Остановить все контейнеры
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
# Создать временную директорию для webroot
|
||||||
|
mkdir -p ./docker/certbot/www
|
||||||
|
|
||||||
|
# Запустить nginx
|
||||||
|
docker compose up -d frontend
|
||||||
|
|
||||||
|
# Подождать, пока nginx запустится
|
||||||
|
echo "Waiting for nginx to start..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Получить тестовый сертификат
|
||||||
|
docker compose run --rm certbot
|
||||||
|
|
||||||
|
# Если тестовый сертификат получен успешно, получить боевой сертификат
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Test certificate obtained successfully. Getting production certificate..."
|
||||||
|
docker compose run --rm certbot certonly --webroot --webroot-path=/var/www/html --email admin@itformhelp.ru --agree-tos --no-eff-email --force-renewal -d itformhelp.ru -d www.itformhelp.ru
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Перезапустить все сервисы
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
BIN
sql_app.db
Normal file
BIN
sql_app.db
Normal file
Binary file not shown.
21
ssl-init.sh
Normal file
21
ssl-init.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Остановить все контейнеры
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
# Создать необходимые директории
|
||||||
|
mkdir -p ./certbot/www
|
||||||
|
mkdir -p ./certbot/conf
|
||||||
|
|
||||||
|
# Запустить только nginx для первичной проверки
|
||||||
|
docker compose up -d frontend
|
||||||
|
|
||||||
|
# Подождать, пока nginx запустится
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# Запустить certbot для получения сертификата
|
||||||
|
docker compose run --rm certbot
|
||||||
|
|
||||||
|
# Перезапустить все сервисы
|
||||||
|
docker compose down
|
||||||
|
docker compose up -d
|
Reference in New Issue
Block a user