mirror of
https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git
synced 2025-08-14 00:25:46 +02:00
6
This commit is contained in:
@@ -1,39 +1,181 @@
|
|||||||
from fastapi import FastAPI, Depends, HTTPException
|
from fastapi import FastAPI, Depends, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.openapi.docs import get_swagger_ui_html
|
from sqlalchemy.orm import Session
|
||||||
from fastapi.openapi.utils import get_openapi
|
from typing import List
|
||||||
import logging
|
from .models import employee as employee_models
|
||||||
from logging.config import dictConfig
|
from .models import request as request_models
|
||||||
from .logging_config import logging_config
|
from .schemas import tables
|
||||||
|
from .crud import employees, requests, auth, statistics
|
||||||
|
from .database import engine, get_db
|
||||||
|
from .models.request import StatusUpdate
|
||||||
|
from .bot.notifications import send_notification
|
||||||
|
from .bot import start_bot
|
||||||
|
import threading
|
||||||
|
import asyncio
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
dictConfig(logging_config)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
app = FastAPI(
|
tables.Base.metadata.create_all(bind=engine)
|
||||||
title="Support Portal API",
|
|
||||||
description="API for managing support requests and employees",
|
app = FastAPI()
|
||||||
version="1.0.0",
|
|
||||||
docs_url=None,
|
|
||||||
redoc_url=None
|
def run_bot():
|
||||||
|
asyncio.run(start_bot())
|
||||||
|
|
||||||
|
|
||||||
|
bot_thread = threading.Thread(target=run_bot, daemon=True)
|
||||||
|
bot_thread.start()
|
||||||
|
|
||||||
|
# CORS middleware
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Custom OpenAPI documentation
|
# Auth endpoints
|
||||||
@app.get("/api/docs", include_in_schema=False)
|
@app.post("/api/test/create-user")
|
||||||
async def custom_swagger_ui_html():
|
def create_test_user(db: Session = Depends(get_db)):
|
||||||
return get_swagger_ui_html(
|
test_user = employee_models.EmployeeCreate(
|
||||||
openapi_url="/api/openapi.json",
|
first_name="Test",
|
||||||
title="Support Portal API Documentation",
|
last_name="User",
|
||||||
swagger_favicon_url="/favicon.ico"
|
department="general",
|
||||||
|
office="101",
|
||||||
|
password="test123"
|
||||||
)
|
)
|
||||||
|
return employees.create_employee(db=db, employee=test_user)
|
||||||
|
@app.post("/api/auth/login")
|
||||||
|
def login(credentials: dict, db: Session = Depends(get_db)):
|
||||||
|
print(f"Login attempt for: {credentials['lastName']}") # Добавьте для отладки
|
||||||
|
employee = auth.authenticate_employee(db, credentials["lastName"], credentials["password"])
|
||||||
|
if not employee:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail="Неверная фамилия или пароль"
|
||||||
|
)
|
||||||
|
return employee
|
||||||
|
|
||||||
@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...
|
@app.post("/api/auth/admin")
|
||||||
|
def admin_login(credentials: dict, db: Session = Depends(get_db)):
|
||||||
|
if not auth.authenticate_admin(
|
||||||
|
db, credentials["username"], credentials["password"]
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=401, detail="Неверные учетные данные")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Employee endpoints
|
||||||
|
@app.post("/api/employees/", response_model=employee_models.Employee)
|
||||||
|
def create_employee(
|
||||||
|
employee: employee_models.EmployeeCreate, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
db_employee = employees.get_employee_by_lastname(db, employee.last_name)
|
||||||
|
if db_employee:
|
||||||
|
raise HTTPException(status_code=400, detail="Last name already registered")
|
||||||
|
return employees.create_employee(db=db, employee=employee)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/employees/", response_model=List[employee_models.Employee])
|
||||||
|
def read_employees(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
|
return employees.get_employees(db, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/employees/{employee_id}")
|
||||||
|
def update_employee(employee_id: int, data: dict, db: Session = Depends(get_db)):
|
||||||
|
return employees.update_employee(db, employee_id, data)
|
||||||
|
|
||||||
|
|
||||||
|
# Request endpoints
|
||||||
|
@app.post("/api/requests/")
|
||||||
|
async def create_request(
|
||||||
|
request: request_models.RequestCreate, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# Create request in database
|
||||||
|
new_request = requests.create_request(db=db, request=request)
|
||||||
|
|
||||||
|
# Get employee details for the notification
|
||||||
|
employee = employees.get_employee(db, new_request.employee_id)
|
||||||
|
|
||||||
|
# Prepare notification data2
|
||||||
|
notification_data = {
|
||||||
|
"id": new_request.id,
|
||||||
|
"employee_last_name": employee.last_name,
|
||||||
|
"employee_first_name": employee.first_name,
|
||||||
|
"department": new_request.department,
|
||||||
|
"office": employee.office,
|
||||||
|
"request_type": new_request.request_type,
|
||||||
|
"priority": new_request.priority,
|
||||||
|
"description": new_request.description,
|
||||||
|
"created_at": new_request.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send notification to Telegram (non-blocking)
|
||||||
|
try:
|
||||||
|
await send_notification(notification_data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Failed to send Telegram notification: {e}")
|
||||||
|
|
||||||
|
return new_request
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/requests/{request_id}/status")
|
||||||
|
def update_request_status(
|
||||||
|
request_id: int,
|
||||||
|
status_update: request_models.StatusUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
request = requests.update_request_status(db, request_id, status_update.status)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
return request
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/requests/", response_model=request_models.Request)
|
||||||
|
def create_request(request_data: dict, db: Session = Depends(get_db)):
|
||||||
|
return requests.create_request(db=db, request_data=request_data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/requests/", response_model=List[request_models.RequestWithEmployee])
|
||||||
|
def read_requests(
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
last_name: str = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
if last_name:
|
||||||
|
return requests.get_requests_by_employee_lastname(db, last_name)
|
||||||
|
return requests.get_requests(db, skip=skip, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/requests/{request_id}/status")
|
||||||
|
def update_request_status(request_id: int, status: str, db: Session = Depends(get_db)):
|
||||||
|
request = requests.update_request_status(db, request_id, status)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
return request
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/requests/{request_id}/status")
|
||||||
|
def update_request_status(
|
||||||
|
request_id: int, status_update: StatusUpdate, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
request = requests.update_request_status(db, request_id, status_update.status)
|
||||||
|
if request is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
return request
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/statistics")
|
||||||
|
def get_statistics(period: str = "week", db: Session = Depends(get_db)):
|
||||||
|
return statistics.get_statistics(db, period)
|
||||||
|
42
backend/etc/nginx/sites-available/support-app.conf
Normal file
42
backend/etc/nginx/sites-available/support-app.conf
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name 185.139.70.62;
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# WebSocket support
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
|
||||||
|
# Таймауты
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backend API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8081/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Таймауты
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
access_log /var/log/nginx/support-app-access.log;
|
||||||
|
error_log /var/log/nginx/support-app-error.log;
|
||||||
|
}
|
@@ -7,9 +7,6 @@ RUN apt-get update && apt-get install -y \
|
|||||||
gcc \
|
gcc \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Create logs directory
|
|
||||||
RUN mkdir -p /app/logs
|
|
||||||
|
|
||||||
# Copy requirements first to leverage Docker cache
|
# Copy requirements first to leverage Docker cache
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
@@ -17,9 +14,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
|
|
||||||
# Create volume for logs
|
|
||||||
VOLUME ["/app/logs"]
|
|
||||||
|
|
||||||
# Expose the port the app runs on
|
# Expose the port the app runs on
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Администрация КАО</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
2908
frontend/package-lock.json
generated
Normal file
2908
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-portal",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
|
"@vueuse/core": "^10.9.0",
|
||||||
|
"chart.js": "^4.4.1",
|
||||||
|
"lucide-vue-next": "^0.344.0",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vue-tsc": "^2.0.6"
|
||||||
|
}
|
||||||
|
}
|
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
14
frontend/src/App.vue
Normal file
14
frontend/src/App.vue
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col bg-slate-100">
|
||||||
|
<Header />
|
||||||
|
<main class="flex-grow container mx-auto px-3 sm:px-4 py-4 sm:py-8">
|
||||||
|
<router-view></router-view>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Header from './components/Header.vue';
|
||||||
|
import Footer from './components/Footer.vue';
|
||||||
|
</script>
|
45
frontend/src/components/Footer.vue
Normal file
45
frontend/src/components/Footer.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="bg-slate-800 text-white">
|
||||||
|
<div class="container mx-auto px-4 py-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<PhoneIcon :size="16" class="text-blue-400" />
|
||||||
|
Контактная информация
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1 text-sm text-slate-300">
|
||||||
|
<p>8 (800) 123-45-67</p>
|
||||||
|
<p>support@admincity.ru</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<ClockIcon :size="16" class="text-blue-400" />
|
||||||
|
Режим работы
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1 text-sm text-slate-300">
|
||||||
|
<p>Пн-Пт: 9:45 - 17:45</p>
|
||||||
|
<p>Сб-Вс: выходной</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
|
||||||
|
<MailIcon :size="16" class="text-blue-400" />
|
||||||
|
Техподдержка
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-1 text-sm text-slate-300">
|
||||||
|
<p>Время реакции: до 2 часов</p>
|
||||||
|
<p>support@admincity.ru</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-slate-700 mt-3 pt-2 text-center text-xs text-slate-400">
|
||||||
|
<p>© 2024 Администрация КАО. Все права защищены.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PhoneIcon, ClockIcon, MailIcon } from 'lucide-vue-next';
|
||||||
|
</script>
|
26
frontend/src/components/Header.vue
Normal file
26
frontend/src/components/Header.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<header class="bg-slate-800 text-white py-3">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-center gap-2">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<Building2Icon :size="28" class="text-blue-400" />
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<h1 class="text-lg sm:text-xl font-semibold">Администрация КАО</h1>
|
||||||
|
<p class="text-xs sm:text-sm text-slate-300">Портал технической поддержки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PhoneIcon :size="18" class="text-blue-400" />
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<p class="text-xs text-slate-300">Поддержка:</p>
|
||||||
|
<p class="text-sm font-semibold">8 (800) 123-45-67</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Building2Icon, PhoneIcon } from 'lucide-vue-next';
|
||||||
|
</script>
|
190
frontend/src/components/admin/EmployeeForm.vue
Normal file
190
frontend/src/components/admin/EmployeeForm.vue
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-8 max-w-xl w-full mx-4 shadow-xl">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-50 rounded-lg">
|
||||||
|
<component
|
||||||
|
:is="employee ? UserIcon : UserPlusIcon"
|
||||||
|
:size="24"
|
||||||
|
class="text-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
{{ employee ? 'Редактирование сотрудника' : 'Добавление сотрудника' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="text-gray-400 hover:text-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Фамилия<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<UserIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.last_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Введите фамилию"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Имя<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<UserIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.first_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Введите имя"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Отдел<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<BuildingIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
v-model="formData.department"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
>
|
||||||
|
<option value="">Выберите отдел</option>
|
||||||
|
<option v-for="dept in departments" :key="dept.value" :value="dept.value">
|
||||||
|
{{ dept.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Кабинет<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<DoorClosedIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.office"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="Номер кабинета"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Пароль{{ !employee ? '*' : '' }}
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<LockIcon :size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="formData.password"
|
||||||
|
type="password"
|
||||||
|
:required="!employee"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="employee" class="mt-1 text-sm text-gray-500">
|
||||||
|
Оставьте пустым, чтобы не менять пароль
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-8 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<XIcon :size="16" />
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<component :is="employee ? SaveIcon : UserPlusIcon" :size="16" />
|
||||||
|
{{ employee ? 'Сохранить' : 'Добавить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { XIcon, UserIcon, BuildingIcon, DoorClosedIcon, LockIcon, UserPlusIcon, SaveIcon } from 'lucide-vue-next';
|
||||||
|
import { departments } from '@/utils/constants';
|
||||||
|
import type { EmployeeFormData } from '@/types/employee';
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
employee?: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'submit', data: any): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formData = ref<EmployeeFormData>({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
department: '',
|
||||||
|
office: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.employee) {
|
||||||
|
formData.value = {
|
||||||
|
first_name: props.employee.first_name,
|
||||||
|
last_name: props.employee.last_name,
|
||||||
|
department: props.employee.department,
|
||||||
|
office: props.employee.office,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const data = { ...formData.value };
|
||||||
|
if (props.employee && !data.password) {
|
||||||
|
delete data.password; // Теперь это безопасно, так как password опциональный
|
||||||
|
}
|
||||||
|
emit('submit', data);
|
||||||
|
}
|
||||||
|
</script>
|
125
frontend/src/components/admin/EmployeeList.vue
Normal file
125
frontend/src/components/admin/EmployeeList.vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { departments } from '@/utils/constants';
|
||||||
|
import type { Employee } from '@/types/employee';
|
||||||
|
|
||||||
|
const employees = ref<Employee[]>([]); // Добавляем типизацию массива сотрудников
|
||||||
|
const showAddForm = ref(false);
|
||||||
|
const editingEmployee = ref<Employee | null>(null);
|
||||||
|
|
||||||
|
function getDepartmentLabel(value: string) {
|
||||||
|
return departments.find(d => d.value === value)?.label || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editEmployee(employee: any) {
|
||||||
|
editingEmployee.value = employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
showAddForm.value = false;
|
||||||
|
editingEmployee.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(data: any) {
|
||||||
|
try {
|
||||||
|
if (editingEmployee.value) {
|
||||||
|
// Update existing employee
|
||||||
|
await fetch(`/api/employees/${editingEmployee.value.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new employee
|
||||||
|
const response = await fetch('/api/employees/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.detail || 'Failed to create employee');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fetchEmployees();
|
||||||
|
closeForm();
|
||||||
|
alert(editingEmployee.value ? 'Сотрудник обновлен' : 'Сотрудник добавлен');
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`Ошибка: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchEmployees() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/employees/');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch employees');
|
||||||
|
employees.value = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching employees:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchEmployees);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-lg font-semibold">Сотрудники</h2>
|
||||||
|
<button
|
||||||
|
@click="showAddForm = true"
|
||||||
|
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Добавить сотрудника
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Фамилия</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Имя</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Отдел</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кабинет</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr v-for="employee in employees" :key="employee.id">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ employee.last_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ employee.first_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ getDepartmentLabel(employee.department) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ employee.office }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<button
|
||||||
|
@click="editEmployee(employee)"
|
||||||
|
class="text-blue-600 hover:text-blue-900 mr-4"
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee form modal -->
|
||||||
|
<EmployeeForm
|
||||||
|
v-if="showAddForm || editingEmployee"
|
||||||
|
:employee="editingEmployee"
|
||||||
|
@close="closeForm"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
64
frontend/src/components/admin/RequestDescriptionModal.vue
Normal file
64
frontend/src/components/admin/RequestDescriptionModal.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center">
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-lg font-semibold">Описание заявки №{{ request.id }}</h3>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<XIcon :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Сотрудник:</span>
|
||||||
|
<p>{{ request.employee_last_name }} {{ request.employee_first_name }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Кабинет:</span>
|
||||||
|
<p>{{ request.employee_office }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Тип заявки:</span>
|
||||||
|
<p>{{ getRequestTypeLabel(request.request_type) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Приоритет:</span>
|
||||||
|
<RequestPriorityBadge :priority="request.priority" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Описание проблемы:</span>
|
||||||
|
<p class="mt-2 text-gray-700 whitespace-pre-wrap">{{ request.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { XIcon } from 'lucide-vue-next';
|
||||||
|
import { getRequestTypeLabel } from '@/utils/constants';
|
||||||
|
import RequestPriorityBadge from './RequestPriorityBadge.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
request: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
198
frontend/src/components/admin/RequestList.vue
Normal file
198
frontend/src/components/admin/RequestList.vue
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-semibold">Заявки</h2>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="Поиск по фамилии..."
|
||||||
|
class="px-3 py-1 border rounded-md"
|
||||||
|
@input="handleSearch"
|
||||||
|
/>
|
||||||
|
<select v-model="filter" class="px-3 py-1 border rounded-md" @change="handleFilter">
|
||||||
|
<option value="all">Все заявки</option>
|
||||||
|
<option value="new">Новые</option>
|
||||||
|
<option value="in_progress">В работе</option>
|
||||||
|
<option value="resolved">Решенные</option>
|
||||||
|
<option value="closed">Закрытые</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">№</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Сотрудник</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кабинет</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Приоритет</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Статус</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="request in filteredRequests"
|
||||||
|
:key="request.id"
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ request.id }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ formatDate(request.created_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ request.employee_last_name }} {{ request.employee_first_name }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{{ request.employee_office }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
<button
|
||||||
|
@click="showDescription(request)"
|
||||||
|
class="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
{{ getRequestTypeLabel(request.request_type) }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<RequestPriorityBadge :priority="request.priority" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<RequestStatusBadge :status="request.status" />
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
@click="openStatusModal(request)"
|
||||||
|
class="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
Изменить статус
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description modal -->
|
||||||
|
<RequestDescriptionModal
|
||||||
|
v-if="selectedDescription"
|
||||||
|
:request="selectedDescription"
|
||||||
|
@close="selectedDescription = null"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Status update modal -->
|
||||||
|
<RequestStatusModal
|
||||||
|
v-if="selectedRequest"
|
||||||
|
:current-status="selectedRequest.status"
|
||||||
|
@close="selectedRequest = null"
|
||||||
|
@update="handleStatusUpdate"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Success notification -->
|
||||||
|
<Notification
|
||||||
|
:show="showNotification"
|
||||||
|
message="Статус заявки успешно обновлен"
|
||||||
|
@close="showNotification = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue';
|
||||||
|
import type { Request } from '@/types/request';
|
||||||
|
import RequestStatusBadge from './RequestStatusBadge.vue';
|
||||||
|
import RequestPriorityBadge from './RequestPriorityBadge.vue';
|
||||||
|
import RequestStatusModal from './RequestStatusModal.vue';
|
||||||
|
import RequestDescriptionModal from './RequestDescriptionModal.vue';
|
||||||
|
import Notification from '@/components/ui/Notification.vue';
|
||||||
|
import { getRequestTypeLabel } from '@/utils/constants';
|
||||||
|
const requests = ref<Request[]>([]); // Типизируем массив запросов
|
||||||
|
const selectedRequest = ref<Request | null>(null);
|
||||||
|
const selectedDescription = ref<Request | null>(null);
|
||||||
|
const showNotification = ref(false);
|
||||||
|
const filter = ref('all');
|
||||||
|
const searchQuery = ref('');
|
||||||
|
|
||||||
|
const filteredRequests = computed(() => {
|
||||||
|
let result = requests.value;
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
result = result.filter(request =>
|
||||||
|
request.employee_last_name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.value !== 'all') {
|
||||||
|
result = result.filter(request => request.status === filter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return new Date(date).toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusModal(request: any) {
|
||||||
|
selectedRequest.value = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDescription(request: any) {
|
||||||
|
selectedDescription.value = request;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusUpdate(newStatus: string) {
|
||||||
|
if (!selectedRequest.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/requests/${selectedRequest.value.id}/status`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update status');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchRequests();
|
||||||
|
selectedRequest.value = null;
|
||||||
|
showNotification.value = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
showNotification.value = false;
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating status:', error);
|
||||||
|
alert('Не удалось обновить статус');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRequests() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/requests/');
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch requests');
|
||||||
|
requests.value = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching requests:', error);
|
||||||
|
alert('Не удалось загрузить заявки');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
// Debounce could be added here if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilter() {
|
||||||
|
// Additional filter logic if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchRequests);
|
||||||
|
</script>
|
28
frontend/src/components/admin/RequestPriorityBadge.vue
Normal file
28
frontend/src/components/admin/RequestPriorityBadge.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="[
|
||||||
|
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
|
||||||
|
priorityClasses[priority]
|
||||||
|
]">
|
||||||
|
{{ priorityLabels[priority] }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { priority } = defineProps<{
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical'
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const priorityClasses = {
|
||||||
|
low: 'bg-green-100 text-green-800',
|
||||||
|
medium: 'bg-yellow-100 text-yellow-800',
|
||||||
|
high: 'bg-orange-100 text-orange-800',
|
||||||
|
critical: 'bg-red-100 text-red-800'
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityLabels = {
|
||||||
|
low: 'Низкий',
|
||||||
|
medium: 'Средний',
|
||||||
|
high: 'Высокий',
|
||||||
|
critical: 'Критический'
|
||||||
|
};
|
||||||
|
</script>
|
30
frontend/src/components/admin/RequestStatusBadge.vue
Normal file
30
frontend/src/components/admin/RequestStatusBadge.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<span :class="[
|
||||||
|
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
|
||||||
|
statusClasses[status]
|
||||||
|
]">
|
||||||
|
{{ statusLabels[status] }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
status: 'new' | 'in_progress' | 'resolved' | 'closed'
|
||||||
|
}>();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const statusClasses = {
|
||||||
|
new: 'bg-blue-100 text-blue-800',
|
||||||
|
in_progress: 'bg-yellow-100 text-yellow-800',
|
||||||
|
resolved: 'bg-green-100 text-green-800',
|
||||||
|
closed: 'bg-gray-100 text-gray-800'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
new: 'Новая',
|
||||||
|
in_progress: 'В работе',
|
||||||
|
resolved: 'Решена',
|
||||||
|
closed: 'Закрыта'
|
||||||
|
};
|
||||||
|
</script>
|
64
frontend/src/components/admin/RequestStatusModal.vue
Normal file
64
frontend/src/components/admin/RequestStatusModal.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center">
|
||||||
|
<div class="bg-white rounded-lg p-6 max-w-md w-full">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Изменение статуса заявки</h3>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Текущий статус
|
||||||
|
</label>
|
||||||
|
<RequestStatusBadge :status="currentStatus" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block text-sm font-medium text-gray-700">
|
||||||
|
Выберите новый статус
|
||||||
|
</label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<button
|
||||||
|
v-for="status in allStatuses"
|
||||||
|
:key="status"
|
||||||
|
@click="handleStatusSelect(status)"
|
||||||
|
:disabled="status === currentStatus"
|
||||||
|
:class="[
|
||||||
|
'w-full text-left px-4 py-2 rounded-md border transition-colors',
|
||||||
|
status === currentStatus
|
||||||
|
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<RequestStatusBadge :status="status" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RequestStatusBadge from './RequestStatusBadge.vue';
|
||||||
|
import type { RequestStatus } from '@/types/request';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
currentStatus: RequestStatus;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'update']);
|
||||||
|
|
||||||
|
const allStatuses: RequestStatus[] = ['new', 'in_progress', 'resolved', 'closed'];
|
||||||
|
|
||||||
|
function handleStatusSelect(newStatus: RequestStatus) {
|
||||||
|
if (newStatus === props.currentStatus) return;
|
||||||
|
emit('update', newStatus);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
</script>
|
95
frontend/src/components/admin/StatisticsPanel.vue
Normal file
95
frontend/src/components/admin/StatisticsPanel.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Period selector -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<button
|
||||||
|
v-for="option in periodOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="period = option.value"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-md text-sm',
|
||||||
|
period === option.value
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="stat in summaryStats"
|
||||||
|
:key="stat.label"
|
||||||
|
class="bg-white p-4 rounded-lg shadow"
|
||||||
|
>
|
||||||
|
<div class="text-sm text-gray-500">{{ stat.label }}</div>
|
||||||
|
<div class="text-2xl font-semibold mt-1">{{ stat.value }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Charts -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<VolumeChart
|
||||||
|
:labels="chartData.volumeLabels"
|
||||||
|
:data="chartData.volumeData"
|
||||||
|
/>
|
||||||
|
<TypesChart
|
||||||
|
:labels="chartData.typeLabels"
|
||||||
|
:data="chartData.typeData"
|
||||||
|
/>
|
||||||
|
<StatusChart
|
||||||
|
:labels="chartData.statusLabels"
|
||||||
|
:data="chartData.statusData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, watch } from 'vue';
|
||||||
|
import VolumeChart from './charts/VolumeChart.vue';
|
||||||
|
import TypesChart from './charts/TypesChart.vue';
|
||||||
|
import StatusChart from './charts/StatusChart.vue';
|
||||||
|
|
||||||
|
const period = ref('week');
|
||||||
|
const chartData = ref({
|
||||||
|
volumeLabels: [],
|
||||||
|
volumeData: [],
|
||||||
|
typeLabels: [],
|
||||||
|
typeData: [],
|
||||||
|
statusLabels: [],
|
||||||
|
statusData: [],
|
||||||
|
totalRequests: 0,
|
||||||
|
resolvedRequests: 0,
|
||||||
|
averageResolutionTime: '0ч'
|
||||||
|
});
|
||||||
|
|
||||||
|
const periodOptions = [
|
||||||
|
{ value: 'day', label: 'День' },
|
||||||
|
{ value: 'week', label: 'Неделя' },
|
||||||
|
{ value: 'month', label: 'Месяц' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const summaryStats = computed(() => [
|
||||||
|
{ label: 'Всего заявок', value: chartData.value.totalRequests },
|
||||||
|
{ label: 'Решено за период', value: chartData.value.resolvedRequests },
|
||||||
|
{ label: 'Среднее время решения', value: chartData.value.averageResolutionTime }
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchStatistics() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/statistics?period=${period.value}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch statistics');
|
||||||
|
chartData.value = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching statistics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(period, fetchStatistics);
|
||||||
|
onMounted(fetchStatistics);
|
||||||
|
</script>
|
87
frontend/src/components/admin/charts/StatusChart.vue
Normal file
87
frontend/src/components/admin/charts/StatusChart.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Распределение по статусам</h3>
|
||||||
|
<div class="h-48">
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { Chart } from 'chart.js/auto';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
new: 'Новые',
|
||||||
|
in_progress: 'В работе',
|
||||||
|
resolved: 'Решены',
|
||||||
|
closed: 'Закрыты'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
new: '#3b82f6',
|
||||||
|
in_progress: '#f59e0b',
|
||||||
|
resolved: '#10b981',
|
||||||
|
closed: '#6b7280'
|
||||||
|
};
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return;
|
||||||
|
|
||||||
|
chart?.destroy();
|
||||||
|
chart = new Chart(chartRef.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: props.labels.map(status => statusLabels[status as keyof typeof statusLabels]),
|
||||||
|
datasets: [{
|
||||||
|
data: props.data,
|
||||||
|
backgroundColor: props.labels.map(status => statusColors[status as keyof typeof statusColors]),
|
||||||
|
barThickness: 30,
|
||||||
|
maxBarThickness: 35
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0,
|
||||||
|
font: { size: 11 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
ticks: {
|
||||||
|
font: { size: 11 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
padding: {
|
||||||
|
left: 10,
|
||||||
|
right: 10,
|
||||||
|
top: 10,
|
||||||
|
bottom: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.labels, props.data], createChart, { deep: true });
|
||||||
|
onMounted(createChart);
|
||||||
|
</script>
|
63
frontend/src/components/admin/charts/TypesChart.vue
Normal file
63
frontend/src/components/admin/charts/TypesChart.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Типы заявок</h3>
|
||||||
|
<div class="h-48">
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { Chart } from 'chart.js/auto';
|
||||||
|
import { getRequestTypeLabel } from '@/utils/constants';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
const colors = [
|
||||||
|
'#3b82f6',
|
||||||
|
'#10b981',
|
||||||
|
'#f59e0b',
|
||||||
|
'#ef4444',
|
||||||
|
'#8b5cf6'
|
||||||
|
];
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return;
|
||||||
|
|
||||||
|
chart?.destroy();
|
||||||
|
chart = new Chart(chartRef.value, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: props.labels.map(getRequestTypeLabel),
|
||||||
|
datasets: [{
|
||||||
|
data: props.data,
|
||||||
|
backgroundColor: colors
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
font: {
|
||||||
|
size: 11
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.labels, props.data], createChart, { deep: true });
|
||||||
|
onMounted(createChart);
|
||||||
|
</script>
|
57
frontend/src/components/admin/charts/VolumeChart.vue
Normal file
57
frontend/src/components/admin/charts/VolumeChart.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="md:col-span-2 bg-white p-4 rounded-lg shadow">
|
||||||
|
<h3 class="text-lg font-medium mb-4">Количество заявок</h3>
|
||||||
|
<canvas ref="chartRef"></canvas>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import { Chart } from 'chart.js/auto';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
labels: string[];
|
||||||
|
data: number[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return;
|
||||||
|
|
||||||
|
chart?.destroy();
|
||||||
|
chart = new Chart(chartRef.value, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: props.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Количество заявок',
|
||||||
|
data: props.data,
|
||||||
|
borderColor: '#2563eb',
|
||||||
|
backgroundColor: 'rgba(37, 99, 235, 0.1)',
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
precision: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.labels, props.data], createChart, { deep: true });
|
||||||
|
onMounted(createChart);
|
||||||
|
</script>
|
151
frontend/src/components/admin/employee/EmployeeFormModal.vue
Normal file
151
frontend/src/components/admin/employee/EmployeeFormModal.vue
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg p-8 max-w-xl w-full mx-4 shadow-xl">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="p-2 bg-blue-50 rounded-lg">
|
||||||
|
<component
|
||||||
|
:is="employee ? UserIcon : UserPlusIcon"
|
||||||
|
:size="24"
|
||||||
|
class="text-blue-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
{{ employee ? 'Редактирование сотрудника' : 'Добавление сотрудника' }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="text-gray-400 hover:text-gray-500 transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<FormField
|
||||||
|
v-model="formData.last_name"
|
||||||
|
label="Фамилия"
|
||||||
|
required
|
||||||
|
placeholder="Введите фамилию"
|
||||||
|
:icon="UserIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
v-model="formData.first_name"
|
||||||
|
label="Имя"
|
||||||
|
required
|
||||||
|
placeholder="Введите имя"
|
||||||
|
:icon="UserIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
v-model="formData.department"
|
||||||
|
label="Отдел"
|
||||||
|
type="select"
|
||||||
|
required
|
||||||
|
:options="departmentOptions"
|
||||||
|
:icon="BuildingIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
v-model="formData.office"
|
||||||
|
label="Кабинет"
|
||||||
|
required
|
||||||
|
placeholder="Номер кабинета"
|
||||||
|
:icon="DoorClosedIcon"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<FormField
|
||||||
|
v-model="formData.password"
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
:required="!employee"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
:icon="LockIcon"
|
||||||
|
:help="employee ? 'Оставьте пустым, чтобы не менять пароль' : undefined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 mt-8 pt-4 border-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<XIcon :size="16" />
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<component :is="employee ? SaveIcon : UserPlusIcon" :size="16" />
|
||||||
|
{{ employee ? 'Сохранить' : 'Добавить' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { XIcon, UserIcon, BuildingIcon, DoorClosedIcon, LockIcon, UserPlusIcon, SaveIcon } from 'lucide-vue-next';
|
||||||
|
import { departments } from '@/utils/constants';
|
||||||
|
import FormField from '@/components/ui/FormField.vue';
|
||||||
|
|
||||||
|
interface Employee {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
employee?: Employee;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const departmentOptions = departments.map(d => ({
|
||||||
|
value: d.value,
|
||||||
|
label: d.label
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'submit', data: any): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
department: '',
|
||||||
|
office: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.employee) {
|
||||||
|
formData.value = {
|
||||||
|
first_name: props.employee.first_name,
|
||||||
|
last_name: props.employee.last_name,
|
||||||
|
department: props.employee.department,
|
||||||
|
office: props.employee.office,
|
||||||
|
password: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
const data = { ...formData.value };
|
||||||
|
if (props.employee && !data.password) {
|
||||||
|
formData.value.password = ''; // Вместо delete используем присваивание пустой строки
|
||||||
|
}
|
||||||
|
emit('submit', data);
|
||||||
|
}
|
||||||
|
</script>
|
162
frontend/src/components/request/RequestForm.vue
Normal file
162
frontend/src/components/request/RequestForm.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Тип обращения<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="formData.request_type"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Выберите тип обращения</option>
|
||||||
|
<option v-for="type in requestTypes" :key="type.value" :value="type.value">
|
||||||
|
{{ type.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Приоритет<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="formData.priority"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Выберите приоритет</option>
|
||||||
|
<option value="low">Низкий</option>
|
||||||
|
<option value="medium">Средний</option>
|
||||||
|
<option value="high">Высокий</option>
|
||||||
|
<option value="critical">Критический</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Описание проблемы<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="formData.description"
|
||||||
|
required
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
rows="4"
|
||||||
|
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Опишите вашу проблему..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 text-sm sm:text-base rounded-md hover:bg-blue-700 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<component :is="isSubmitting ? LoaderIcon : SendIcon"
|
||||||
|
:size="18"
|
||||||
|
:class="{ 'animate-spin': isSubmitting }"
|
||||||
|
/>
|
||||||
|
{{ isSubmitting ? 'Отправка...' : 'Отправить заявку' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transform ease-out duration-300"
|
||||||
|
enter-from-class="translate-y-2 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-200"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showNotification"
|
||||||
|
class="fixed top-4 right-4 z-50 max-w-sm w-full bg-green-50 rounded-lg shadow-lg ring-1 ring-green-500 ring-opacity-5 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<CheckCircleIcon class="h-5 w-5 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium text-green-800">
|
||||||
|
Заявка успешно отправлена. Ожидайте прибытия технического специалиста.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0 flex">
|
||||||
|
<button
|
||||||
|
@click="showNotification = false"
|
||||||
|
class="inline-flex text-green-500 hover:text-green-600 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
>
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { SendIcon, LoaderIcon, CheckCircleIcon, XIcon } from 'lucide-vue-next';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { requestTypes } from '@/utils/constants';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const showNotification = ref(false);
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
request_type: '',
|
||||||
|
priority: '',
|
||||||
|
description: '',
|
||||||
|
department: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!authStore.user) {
|
||||||
|
alert('Необходимо авторизоваться');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/requests/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
...formData.value,
|
||||||
|
employee_id: parseInt(authStore.user.id),
|
||||||
|
department: authStore.user.department
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to submit request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
showNotification.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
showNotification.value = false;
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
formData.value = {
|
||||||
|
request_type: '',
|
||||||
|
priority: '',
|
||||||
|
description: '',
|
||||||
|
department: ''
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting request:', error);
|
||||||
|
alert('Ошибка при отправке заявки');
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
60
frontend/src/components/ui/FormField.vue
Normal file
60
frontend/src/components/ui/FormField.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{{ label }}<span v-if="required" class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<div v-if="icon" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<component :is="icon" size="18" class="text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="type === 'select'">
|
||||||
|
<select
|
||||||
|
:value="modelValue"
|
||||||
|
@input="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||||
|
:required="required"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Выберите {{ label.toLowerCase() }}</option>
|
||||||
|
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<input
|
||||||
|
:type="type"
|
||||||
|
:value="modelValue"
|
||||||
|
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
:required="required"
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot name="help"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Component } from 'vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string;
|
||||||
|
label: string;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
help?: string;
|
||||||
|
icon?: Component;
|
||||||
|
size?: number | string; // Добавляем поддержку как числа, так и строки
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
61
frontend/src/components/ui/Notification.vue
Normal file
61
frontend/src/components/ui/Notification.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transform ease-out duration-300 transition"
|
||||||
|
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||||
|
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
|
||||||
|
leave-active-class="transition ease-in duration-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed top-4 right-4 z-50 max-w-sm w-full bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 overflow-hidden"
|
||||||
|
:class="{
|
||||||
|
'bg-green-50 ring-green-500': type === 'success',
|
||||||
|
'bg-red-50 ring-red-500': type === 'error'
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<component
|
||||||
|
:is="type === 'error' ? XCircleIcon : CheckCircleIcon"
|
||||||
|
class="h-5 w-5"
|
||||||
|
:class="type === 'error' ? 'text-red-400' : 'text-green-400'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium" :class="type === 'error' ? 'text-red-800' : 'text-green-800'">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-shrink-0 flex">
|
||||||
|
<button
|
||||||
|
@click="$emit('close')"
|
||||||
|
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||||
|
:class="type === 'error' ? 'text-red-500 hover:text-red-600 focus:ring-red-500' : 'text-green-500 hover:text-green-600 focus:ring-green-500'"
|
||||||
|
>
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircleIcon, XIcon, XCircleIcon } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
show: boolean;
|
||||||
|
message: string;
|
||||||
|
type?: 'success' | 'error';
|
||||||
|
}>(), {
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
31
frontend/src/composables/useNotification.ts
Normal file
31
frontend/src/composables/useNotification.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export function useNotification() {
|
||||||
|
const show = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const type = ref<'success' | 'error'>('success');
|
||||||
|
|
||||||
|
function showNotification(newMessage: string, newType: 'success' | 'error' = 'success', duration = 3000) {
|
||||||
|
message.value = newMessage;
|
||||||
|
type.value = newType;
|
||||||
|
show.value = true;
|
||||||
|
|
||||||
|
if (duration > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
show.value = false;
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideNotification() {
|
||||||
|
show.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
show,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
showNotification,
|
||||||
|
hideNotification
|
||||||
|
};
|
||||||
|
}
|
3
frontend/src/index.css
Normal file
3
frontend/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
app.use(pinia);
|
||||||
|
app.use(router);
|
||||||
|
app.mount('#app');
|
48
frontend/src/router/index.ts
Normal file
48
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import LoginView from '../views/LoginView.vue';
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'login',
|
||||||
|
component: LoginView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/support',
|
||||||
|
name: 'support',
|
||||||
|
// Ошибка: Не найден модуль '../views/SupportView.vue'
|
||||||
|
component: () => import('../views/SupportView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'admin-login',
|
||||||
|
// Ошибка: Не найден модуль '../views/admin/AdminLoginView.vue'
|
||||||
|
component: () => import('../views/admin/AdminLoginView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/dashboard',
|
||||||
|
name: 'admin-dashboard',
|
||||||
|
// Ошибка: Не найден модуль '../views/admin/DashboardView.vue'
|
||||||
|
component: () => import('../views/admin/DashboardView.vue'),
|
||||||
|
meta: { requiresAdmin: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to, _, next) => {
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next({ name: 'login' });
|
||||||
|
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
|
next({ name: 'admin-login' });
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
79
frontend/src/stores/auth.ts
Normal file
79
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import type { User } from '@/types/auth';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref<User | null>(null);
|
||||||
|
const isAdmin = ref(false);
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!user.value);
|
||||||
|
|
||||||
|
function setUser(newUser: User | null) {
|
||||||
|
user.value = newUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdmin(value: boolean) {
|
||||||
|
isAdmin.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(lastName: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ lastName, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = await response.json();
|
||||||
|
setUser(userData);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminLogin(username: string, password: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/admin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdmin(true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin login error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
user.value = null;
|
||||||
|
isAdmin.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
isAdmin,
|
||||||
|
isAuthenticated,
|
||||||
|
setUser,
|
||||||
|
setAdmin,
|
||||||
|
login,
|
||||||
|
adminLogin,
|
||||||
|
logout
|
||||||
|
};
|
||||||
|
});
|
36
frontend/src/types/auth.ts
Normal file
36
frontend/src/types/auth.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
department: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
lastName: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCredentials {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Request {
|
||||||
|
id: number;
|
||||||
|
employee_last_name: string;
|
||||||
|
employee_first_name: string;
|
||||||
|
employee_office: string;
|
||||||
|
request_type: string;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
status: 'new' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
created_at: string;
|
||||||
|
}
|
17
frontend/src/types/employee.ts
Normal file
17
frontend/src/types/employee.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export interface Employee {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmployeeFormData {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
department: string;
|
||||||
|
office: string;
|
||||||
|
password?: string; // Делаем password опциональным
|
||||||
|
}
|
||||||
|
|
14
frontend/src/types/request.ts
Normal file
14
frontend/src/types/request.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type RequestStatus = 'new' | 'in_progress' | 'resolved' | 'closed';
|
||||||
|
|
||||||
|
export interface Request {
|
||||||
|
id: number;
|
||||||
|
status: RequestStatus;
|
||||||
|
created_at: string;
|
||||||
|
employee_last_name: string;
|
||||||
|
employee_first_name: string;
|
||||||
|
employee_office: string;
|
||||||
|
request_type: string;
|
||||||
|
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
17
frontend/src/utils/constants.ts
Normal file
17
frontend/src/utils/constants.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export const requestTypes = [
|
||||||
|
{ value: 'hardware', label: 'Проблемы с оборудованием' },
|
||||||
|
{ value: 'software', label: 'Проблемы с программным обеспечением' },
|
||||||
|
{ value: 'network', label: 'Проблемы с сетью' },
|
||||||
|
{ value: 'access', label: 'Доступ к системам' },
|
||||||
|
{ value: 'other', label: 'Другое' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const departments = [
|
||||||
|
{ value: 'aho', label: 'Административно-хозяйственный отдел' },
|
||||||
|
{ value: 'gkh', label: 'Жилищно-коммунальное хозяйство' },
|
||||||
|
{ value: 'general', label: 'Общий отдел' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function getRequestTypeLabel(value: string): string {
|
||||||
|
return requestTypes.find(type => type.value === value)?.label || value;
|
||||||
|
}
|
98
frontend/src/views/LoginView.vue
Normal file
98
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-slate-800 mb-4">Вход в систему</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Фамилия
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<UserIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="lastName"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите фамилию"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<LockIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="isLoading ? LoaderIcon : LogInIcon"
|
||||||
|
:size="18"
|
||||||
|
:class="{ 'animate-spin': isLoading }"
|
||||||
|
/>
|
||||||
|
{{ isLoading ? 'Вход...' : 'Войти' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<router-link
|
||||||
|
to="/admin"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Вход для администраторов
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { UserIcon, LockIcon, LogInIcon, LoaderIcon } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const lastName = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const success = await authStore.login(lastName.value, password.value);
|
||||||
|
if (success) {
|
||||||
|
router.push('/support');
|
||||||
|
} else {
|
||||||
|
alert('Неверная фамилия или пароль');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка авторизации');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
16
frontend/src/views/SupportView.vue
Normal file
16
frontend/src/views/SupportView.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-4 sm:p-6">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">
|
||||||
|
Техническая поддержка
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm sm:text-base text-slate-600 mb-4 sm:mb-6">
|
||||||
|
Заполните форму для создания заявки в IT-отдел
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<RequestForm />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import RequestForm from '@/components/request/RequestForm.vue';
|
||||||
|
</script>
|
97
frontend/src/views/admin/AdminLoginView.vue
Normal file
97
frontend/src/views/admin/AdminLoginView.vue
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<template>
|
||||||
|
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-2xl font-semibold text-slate-800 mb-4">Вход в админ-панель</h2>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Логин
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<UserIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите логин"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
|
||||||
|
<LockIcon :size="18" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="isLoading"
|
||||||
|
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="isLoading ? LoaderIcon : LogInIcon"
|
||||||
|
:size="18"
|
||||||
|
:class="{ 'animate-spin': isLoading }"
|
||||||
|
/>
|
||||||
|
{{ isLoading ? 'Вход...' : 'Войти' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<router-link
|
||||||
|
to="/"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Вернуться к входу для сотрудников
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { UserIcon, LockIcon, LogInIcon, LoaderIcon } from 'lucide-vue-next';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const username = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (isLoading.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const success = await authStore.adminLogin(username.value, password.value);
|
||||||
|
if (success) {
|
||||||
|
router.push('/admin/dashboard');
|
||||||
|
} else {
|
||||||
|
alert('Неверные учетные данные');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка авторизации');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
64
frontend/src/views/admin/DashboardView.vue
Normal file
64
frontend/src/views/admin/DashboardView.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">Панель администратора</h1>
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<div class="border-b border-gray-200">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.id"
|
||||||
|
@click="currentTab = tab.id"
|
||||||
|
:class="[
|
||||||
|
currentTab === tab.id
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
|
||||||
|
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ tab.name }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<StatisticsPanel v-if="currentTab === 'statistics'" />
|
||||||
|
<RequestList v-if="currentTab === 'requests'" />
|
||||||
|
<EmployeeList v-if="currentTab === 'employees'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import RequestList from '@/components/admin/RequestList.vue';
|
||||||
|
import EmployeeList from '@/components/admin/EmployeeList.vue';
|
||||||
|
import StatisticsPanel from '@/components/admin/StatisticsPanel.vue';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'statistics', name: 'Статистика' },
|
||||||
|
{ id: 'requests', name: 'Заявки' },
|
||||||
|
{ id: 'employees', name: 'Сотрудники' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentTab = ref('statistics');
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
authStore.logout();
|
||||||
|
router.push('/admin');
|
||||||
|
}
|
||||||
|
</script>
|
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Paths */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
20
frontend/vite.config.ts
Normal file
20
frontend/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { fileURLToPath, URL } from 'url';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
Reference in New Issue
Block a user