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.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
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from .models import employee as employee_models
|
||||
from .models import request as request_models
|
||||
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(
|
||||
title="Support Portal API",
|
||||
description="API for managing support requests and employees",
|
||||
version="1.0.0",
|
||||
docs_url=None,
|
||||
redoc_url=None
|
||||
tables.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
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
|
||||
@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"
|
||||
# Auth endpoints
|
||||
@app.post("/api/test/create-user")
|
||||
def create_test_user(db: Session = Depends(get_db)):
|
||||
test_user = employee_models.EmployeeCreate(
|
||||
first_name="Test",
|
||||
last_name="User",
|
||||
department="general",
|
||||
office="101",
|
||||
password="test123"
|
||||
)
|
||||
|
||||
@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
|
||||
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
|
||||
|
||||
# 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 \
|
||||
&& 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
|
||||
@@ -17,9 +14,6 @@ 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
|
||||
|
||||
|
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