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