1
0
mirror of https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git synced 2025-08-14 00:25:46 +02:00

Проверка

This commit is contained in:
MoonTestUse1
2024-12-28 05:32:33 +06:00
commit f9543463c4
84 changed files with 6811 additions and 0 deletions

29
.gitignore vendored Normal file
View 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

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# AdministrationItDepartmens
Site for teh support

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# FastAPI application package

68
backend/app/bot.py Normal file
View 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)

View 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
View 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
View 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"

View 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: "📝",
}

View 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']

View 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

View 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("Бот для обработки заявок запущен!")

View 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
)

View 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

View 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}"
)

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

View 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

View 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

View 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 ""
# 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
View 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()

View 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
View 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...

View File

@@ -0,0 +1,3 @@
from .logging import LoggingMiddleware
__all__ = ['LoggingMiddleware']

View 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

View 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

View 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

View 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
View 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)

View 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': '📝'
}

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

View 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
View 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;"]

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

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

14
frontend/src/App.vue Normal file
View 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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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
};
}

View 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
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

12
frontend/src/main.ts Normal file
View 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');

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

View 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
};
});

View 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;
}

View 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 опциональным
}

View 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;
}

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

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

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

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

View 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
View 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" }]
}

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

Binary file not shown.

21
ssl-init.sh Normal file
View 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