1
0
mirror of https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git synced 2025-08-14 00:25:46 +02:00
This commit is contained in:
MoonTestUse1
2024-12-28 06:02:19 +06:00
parent 4044b803f9
commit 1bdddbb98f
42 changed files with 5293 additions and 36 deletions

View File

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

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

View File

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

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Администрация КАО</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2908
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "admin-portal",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@supabase/supabase-js": "^2.39.7",
"@vueuse/core": "^10.9.0",
"chart.js": "^4.4.1",
"lucide-vue-next": "^0.344.0",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vue-tsc": "^2.0.6"
}
}

View File

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

14
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<div class="min-h-screen flex flex-col bg-slate-100">
<Header />
<main class="flex-grow container mx-auto px-3 sm:px-4 py-4 sm:py-8">
<router-view></router-view>
</main>
<Footer />
</div>
</template>
<script setup lang="ts">
import Header from './components/Header.vue';
import Footer from './components/Footer.vue';
</script>

View File

@@ -0,0 +1,45 @@
<template>
<footer class="bg-slate-800 text-white">
<div class="container mx-auto px-4 py-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
<PhoneIcon :size="16" class="text-blue-400" />
Контактная информация
</h3>
<div class="space-y-1 text-sm text-slate-300">
<p>8 (800) 123-45-67</p>
<p>support@admincity.ru</p>
</div>
</div>
<div>
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
<ClockIcon :size="16" class="text-blue-400" />
Режим работы
</h3>
<div class="space-y-1 text-sm text-slate-300">
<p>Пн-Пт: 9:45 - 17:45</p>
<p>Сб-Вс: выходной</p>
</div>
</div>
<div>
<h3 class="text-sm font-semibold mb-2 flex items-center gap-2">
<MailIcon :size="16" class="text-blue-400" />
Техподдержка
</h3>
<div class="space-y-1 text-sm text-slate-300">
<p>Время реакции: до 2 часов</p>
<p>support@admincity.ru</p>
</div>
</div>
</div>
<div class="border-t border-slate-700 mt-3 pt-2 text-center text-xs text-slate-400">
<p>© 2024 Администрация КАО. Все права защищены.</p>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { PhoneIcon, ClockIcon, MailIcon } from 'lucide-vue-next';
</script>

View File

@@ -0,0 +1,26 @@
<template>
<header class="bg-slate-800 text-white py-3">
<div class="container mx-auto px-4">
<div class="flex flex-col sm:flex-row justify-between items-center gap-2">
<div class="flex items-center space-x-3">
<Building2Icon :size="28" class="text-blue-400" />
<div class="text-center sm:text-left">
<h1 class="text-lg sm:text-xl font-semibold">Администрация КАО</h1>
<p class="text-xs sm:text-sm text-slate-300">Портал технической поддержки</p>
</div>
</div>
<div class="flex items-center space-x-2">
<PhoneIcon :size="18" class="text-blue-400" />
<div class="text-center sm:text-left">
<p class="text-xs text-slate-300">Поддержка:</p>
<p class="text-sm font-semibold">8 (800) 123-45-67</p>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { Building2Icon, PhoneIcon } from 'lucide-vue-next';
</script>

View File

@@ -0,0 +1,190 @@
<template>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-xl w-full mx-4 shadow-xl">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-50 rounded-lg">
<component
:is="employee ? UserIcon : UserPlusIcon"
:size="24"
class="text-blue-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900">
{{ employee ? 'Редактирование сотрудника' : 'Добавление сотрудника' }}
</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-500 transition-colors"
>
<XIcon :size="20" />
</button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Фамилия<span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserIcon :size="18" class="text-gray-400" />
</div>
<input
v-model="formData.last_name"
type="text"
required
placeholder="Введите фамилию"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Имя<span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<UserIcon :size="18" class="text-gray-400" />
</div>
<input
v-model="formData.first_name"
type="text"
required
placeholder="Введите имя"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Отдел<span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<BuildingIcon :size="18" class="text-gray-400" />
</div>
<select
v-model="formData.department"
required
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
>
<option value="">Выберите отдел</option>
<option v-for="dept in departments" :key="dept.value" :value="dept.value">
{{ dept.label }}
</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Кабинет<span class="text-red-500">*</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<DoorClosedIcon :size="18" class="text-gray-400" />
</div>
<input
v-model="formData.office"
type="text"
required
placeholder="Номер кабинета"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">
Пароль{{ !employee ? '*' : '' }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<LockIcon :size="18" class="text-gray-400" />
</div>
<input
v-model="formData.password"
type="password"
:required="!employee"
placeholder="Введите пароль"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
</div>
<p v-if="employee" class="mt-1 text-sm text-gray-500">
Оставьте пустым, чтобы не менять пароль
</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-8 pt-4 border-t">
<button
type="button"
@click="$emit('close')"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
>
<XIcon :size="16" />
Отмена
</button>
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
>
<component :is="employee ? SaveIcon : UserPlusIcon" :size="16" />
{{ employee ? 'Сохранить' : 'Добавить' }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { XIcon, UserIcon, BuildingIcon, DoorClosedIcon, LockIcon, UserPlusIcon, SaveIcon } from 'lucide-vue-next';
import { departments } from '@/utils/constants';
import type { EmployeeFormData } from '@/types/employee';
const props = defineProps<{
employee?: any;
}>();
const emit = defineEmits<{
(e: 'close'): void;
(e: 'submit', data: any): void;
}>();
const formData = ref<EmployeeFormData>({
first_name: '',
last_name: '',
department: '',
office: '',
password: ''
});
onMounted(() => {
if (props.employee) {
formData.value = {
first_name: props.employee.first_name,
last_name: props.employee.last_name,
department: props.employee.department,
office: props.employee.office,
password: ''
};
}
});
function handleSubmit() {
const data = { ...formData.value };
if (props.employee && !data.password) {
delete data.password; // Теперь это безопасно, так как password опциональный
}
emit('submit', data);
}
</script>

View File

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { departments } from '@/utils/constants';
import type { Employee } from '@/types/employee';
const employees = ref<Employee[]>([]); // Добавляем типизацию массива сотрудников
const showAddForm = ref(false);
const editingEmployee = ref<Employee | null>(null);
function getDepartmentLabel(value: string) {
return departments.find(d => d.value === value)?.label || value;
}
function editEmployee(employee: any) {
editingEmployee.value = employee;
}
function closeForm() {
showAddForm.value = false;
editingEmployee.value = null;
}
async function handleSubmit(data: any) {
try {
if (editingEmployee.value) {
// Update existing employee
await fetch(`/api/employees/${editingEmployee.value.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
// Create new employee
const response = await fetch('/api/employees/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create employee');
}
}
await fetchEmployees();
closeForm();
alert(editingEmployee.value ? 'Сотрудник обновлен' : 'Сотрудник добавлен');
} catch (error: any) {
alert(`Ошибка: ${error.message}`);
}
}
async function fetchEmployees() {
try {
const response = await fetch('/api/employees/');
if (!response.ok) throw new Error('Failed to fetch employees');
employees.value = await response.json();
} catch (error) {
console.error('Error fetching employees:', error);
}
}
onMounted(fetchEmployees);
</script>
<template>
<div class="space-y-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-semibold">Сотрудники</h2>
<button
@click="showAddForm = true"
class="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
Добавить сотрудника
</button>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Фамилия</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Имя</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Отдел</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кабинет</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="employee in employees" :key="employee.id">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ employee.last_name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ employee.first_name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ getDepartmentLabel(employee.department) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ employee.office }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<button
@click="editEmployee(employee)"
class="text-blue-600 hover:text-blue-900 mr-4"
>
Редактировать
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Employee form modal -->
<EmployeeForm
v-if="showAddForm || editingEmployee"
:employee="editingEmployee"
@close="closeForm"
@submit="handleSubmit"
/>
</div>
</template>

View File

@@ -0,0 +1,64 @@
<template>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center">
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4">
<div class="flex justify-between items-start mb-4">
<h3 class="text-lg font-semibold">Описание заявки {{ request.id }}</h3>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-500"
>
<XIcon :size="20" />
</button>
</div>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-gray-500">Сотрудник:</span>
<p>{{ request.employee_last_name }} {{ request.employee_first_name }}</p>
</div>
<div>
<span class="text-gray-500">Кабинет:</span>
<p>{{ request.employee_office }}</p>
</div>
<div>
<span class="text-gray-500">Тип заявки:</span>
<p>{{ getRequestTypeLabel(request.request_type) }}</p>
</div>
<div>
<span class="text-gray-500">Приоритет:</span>
<RequestPriorityBadge :priority="request.priority" />
</div>
</div>
<div>
<span class="text-gray-500">Описание проблемы:</span>
<p class="mt-2 text-gray-700 whitespace-pre-wrap">{{ request.description }}</p>
</div>
</div>
<div class="mt-6 flex justify-end">
<button
@click="$emit('close')"
class="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200"
>
Закрыть
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from 'lucide-vue-next';
import { getRequestTypeLabel } from '@/utils/constants';
import RequestPriorityBadge from './RequestPriorityBadge.vue';
defineProps<{
request: any;
}>();
defineEmits<{
(e: 'close'): void;
}>();
</script>

View File

@@ -0,0 +1,198 @@
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">Заявки</h2>
<div class="flex gap-4">
<input
v-model="searchQuery"
type="text"
placeholder="Поиск по фамилии..."
class="px-3 py-1 border rounded-md"
@input="handleSearch"
/>
<select v-model="filter" class="px-3 py-1 border rounded-md" @change="handleFilter">
<option value="all">Все заявки</option>
<option value="new">Новые</option>
<option value="in_progress">В работе</option>
<option value="resolved">Решенные</option>
<option value="closed">Закрытые</option>
</select>
</div>
</div>
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Дата</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Сотрудник</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кабинет</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Тип</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Приоритет</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Статус</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Действия</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr
v-for="request in filteredRequests"
:key="request.id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ request.id }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(request.created_at) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ request.employee_last_name }} {{ request.employee_first_name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ request.employee_office }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<button
@click="showDescription(request)"
class="text-blue-600 hover:text-blue-900"
>
{{ getRequestTypeLabel(request.request_type) }}
</button>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<RequestPriorityBadge :priority="request.priority" />
</td>
<td class="px-6 py-4 whitespace-nowrap">
<RequestStatusBadge :status="request.status" />
</td>
<td class="px-6 py-4 whitespace-nowrap">
<button
@click="openStatusModal(request)"
class="text-blue-600 hover:text-blue-900"
>
Изменить статус
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Description modal -->
<RequestDescriptionModal
v-if="selectedDescription"
:request="selectedDescription"
@close="selectedDescription = null"
/>
<!-- Status update modal -->
<RequestStatusModal
v-if="selectedRequest"
:current-status="selectedRequest.status"
@close="selectedRequest = null"
@update="handleStatusUpdate"
/>
<!-- Success notification -->
<Notification
:show="showNotification"
message="Статус заявки успешно обновлен"
@close="showNotification = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import type { Request } from '@/types/request';
import RequestStatusBadge from './RequestStatusBadge.vue';
import RequestPriorityBadge from './RequestPriorityBadge.vue';
import RequestStatusModal from './RequestStatusModal.vue';
import RequestDescriptionModal from './RequestDescriptionModal.vue';
import Notification from '@/components/ui/Notification.vue';
import { getRequestTypeLabel } from '@/utils/constants';
const requests = ref<Request[]>([]); // Типизируем массив запросов
const selectedRequest = ref<Request | null>(null);
const selectedDescription = ref<Request | null>(null);
const showNotification = ref(false);
const filter = ref('all');
const searchQuery = ref('');
const filteredRequests = computed(() => {
let result = requests.value;
if (searchQuery.value) {
result = result.filter(request =>
request.employee_last_name.toLowerCase().includes(searchQuery.value.toLowerCase())
);
}
if (filter.value !== 'all') {
result = result.filter(request => request.status === filter.value);
}
return result;
});
function formatDate(date: string) {
return new Date(date).toLocaleString('ru-RU');
}
function openStatusModal(request: any) {
selectedRequest.value = request;
}
function showDescription(request: any) {
selectedDescription.value = request;
}
async function handleStatusUpdate(newStatus: string) {
if (!selectedRequest.value) return;
try {
const response = await fetch(`/api/requests/${selectedRequest.value.id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (!response.ok) {
throw new Error('Failed to update status');
}
await fetchRequests();
selectedRequest.value = null;
showNotification.value = true;
setTimeout(() => {
showNotification.value = false;
}, 3000);
} catch (error) {
console.error('Error updating status:', error);
alert('Не удалось обновить статус');
}
}
async function fetchRequests() {
try {
const response = await fetch('/api/requests/');
if (!response.ok) throw new Error('Failed to fetch requests');
requests.value = await response.json();
} catch (error) {
console.error('Error fetching requests:', error);
alert('Не удалось загрузить заявки');
}
}
function handleSearch() {
// Debounce could be added here if needed
}
function handleFilter() {
// Additional filter logic if needed
}
onMounted(fetchRequests);
</script>

View File

@@ -0,0 +1,28 @@
<template>
<span :class="[
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
priorityClasses[priority]
]">
{{ priorityLabels[priority] }}
</span>
</template>
<script setup lang="ts">
const { priority } = defineProps<{
priority: 'low' | 'medium' | 'high' | 'critical'
}>();
const priorityClasses = {
low: 'bg-green-100 text-green-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800'
};
const priorityLabels = {
low: 'Низкий',
medium: 'Средний',
high: 'Высокий',
critical: 'Критический'
};
</script>

View File

@@ -0,0 +1,30 @@
<template>
<span :class="[
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
statusClasses[status]
]">
{{ statusLabels[status] }}
</span>
</template>
<script setup lang="ts">
defineProps<{
status: 'new' | 'in_progress' | 'resolved' | 'closed'
}>();
const statusClasses = {
new: 'bg-blue-100 text-blue-800',
in_progress: 'bg-yellow-100 text-yellow-800',
resolved: 'bg-green-100 text-green-800',
closed: 'bg-gray-100 text-gray-800'
} as const;
const statusLabels = {
new: 'Новая',
in_progress: 'В работе',
resolved: 'Решена',
closed: 'Закрыта'
};
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center">
<div class="bg-white rounded-lg p-6 max-w-md w-full">
<h3 class="text-lg font-semibold mb-4">Изменение статуса заявки</h3>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">
Текущий статус
</label>
<RequestStatusBadge :status="currentStatus" />
</div>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">
Выберите новый статус
</label>
<div class="space-y-2">
<button
v-for="status in allStatuses"
:key="status"
@click="handleStatusSelect(status)"
:disabled="status === currentStatus"
:class="[
'w-full text-left px-4 py-2 rounded-md border transition-colors',
status === currentStatus
? 'border-gray-200 bg-gray-50 cursor-not-allowed'
: 'border-gray-200 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500'
]"
>
<RequestStatusBadge :status="status" />
</button>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button
@click="$emit('close')"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Отмена
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import RequestStatusBadge from './RequestStatusBadge.vue';
import type { RequestStatus } from '@/types/request';
const props = defineProps<{
currentStatus: RequestStatus;
}>();
const emit = defineEmits(['close', 'update']);
const allStatuses: RequestStatus[] = ['new', 'in_progress', 'resolved', 'closed'];
function handleStatusSelect(newStatus: RequestStatus) {
if (newStatus === props.currentStatus) return;
emit('update', newStatus);
emit('close');
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="space-y-6">
<!-- Period selector -->
<div class="flex justify-between items-center">
<div class="flex gap-4">
<button
v-for="option in periodOptions"
:key="option.value"
@click="period = option.value"
:class="[
'px-3 py-1 rounded-md text-sm',
period === option.value
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
{{ option.label }}
</button>
</div>
</div>
<!-- Summary cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div
v-for="stat in summaryStats"
:key="stat.label"
class="bg-white p-4 rounded-lg shadow"
>
<div class="text-sm text-gray-500">{{ stat.label }}</div>
<div class="text-2xl font-semibold mt-1">{{ stat.value }}</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<VolumeChart
:labels="chartData.volumeLabels"
:data="chartData.volumeData"
/>
<TypesChart
:labels="chartData.typeLabels"
:data="chartData.typeData"
/>
<StatusChart
:labels="chartData.statusLabels"
:data="chartData.statusData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue';
import VolumeChart from './charts/VolumeChart.vue';
import TypesChart from './charts/TypesChart.vue';
import StatusChart from './charts/StatusChart.vue';
const period = ref('week');
const chartData = ref({
volumeLabels: [],
volumeData: [],
typeLabels: [],
typeData: [],
statusLabels: [],
statusData: [],
totalRequests: 0,
resolvedRequests: 0,
averageResolutionTime: '0ч'
});
const periodOptions = [
{ value: 'day', label: 'День' },
{ value: 'week', label: 'Неделя' },
{ value: 'month', label: 'Месяц' }
];
const summaryStats = computed(() => [
{ label: 'Всего заявок', value: chartData.value.totalRequests },
{ label: 'Решено за период', value: chartData.value.resolvedRequests },
{ label: 'Среднее время решения', value: chartData.value.averageResolutionTime }
]);
async function fetchStatistics() {
try {
const response = await fetch(`/api/statistics?period=${period.value}`);
if (!response.ok) throw new Error('Failed to fetch statistics');
chartData.value = await response.json();
} catch (error) {
console.error('Error fetching statistics:', error);
}
}
watch(period, fetchStatistics);
onMounted(fetchStatistics);
</script>

View File

@@ -0,0 +1,87 @@
<template>
<div class="bg-white p-4 rounded-lg shadow">
<h3 class="text-lg font-medium mb-4">Распределение по статусам</h3>
<div class="h-48">
<canvas ref="chartRef"></canvas>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { Chart } from 'chart.js/auto';
const props = defineProps<{
labels: string[];
data: number[];
}>();
const chartRef = ref<HTMLCanvasElement | null>(null);
let chart: Chart | null = null;
const statusLabels = {
new: 'Новые',
in_progress: 'В работе',
resolved: 'Решены',
closed: 'Закрыты'
};
const statusColors = {
new: '#3b82f6',
in_progress: '#f59e0b',
resolved: '#10b981',
closed: '#6b7280'
};
function createChart() {
if (!chartRef.value) return;
chart?.destroy();
chart = new Chart(chartRef.value, {
type: 'bar',
data: {
labels: props.labels.map(status => statusLabels[status as keyof typeof statusLabels]),
datasets: [{
data: props.data,
backgroundColor: props.labels.map(status => statusColors[status as keyof typeof statusColors]),
barThickness: 30,
maxBarThickness: 35
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
font: { size: 11 }
}
},
x: {
ticks: {
font: { size: 11 }
}
}
},
layout: {
padding: {
left: 10,
right: 10,
top: 10,
bottom: 10
}
}
}
});
}
watch(() => [props.labels, props.data], createChart, { deep: true });
onMounted(createChart);
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="bg-white p-4 rounded-lg shadow">
<h3 class="text-lg font-medium mb-4">Типы заявок</h3>
<div class="h-48">
<canvas ref="chartRef"></canvas>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { Chart } from 'chart.js/auto';
import { getRequestTypeLabel } from '@/utils/constants';
const props = defineProps<{
labels: string[];
data: number[];
}>();
const chartRef = ref<HTMLCanvasElement | null>(null);
let chart: Chart | null = null;
const colors = [
'#3b82f6',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6'
];
function createChart() {
if (!chartRef.value) return;
chart?.destroy();
chart = new Chart(chartRef.value, {
type: 'doughnut',
data: {
labels: props.labels.map(getRequestTypeLabel),
datasets: [{
data: props.data,
backgroundColor: colors
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: {
font: {
size: 11
}
}
}
}
}
});
}
watch(() => [props.labels, props.data], createChart, { deep: true });
onMounted(createChart);
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="md:col-span-2 bg-white p-4 rounded-lg shadow">
<h3 class="text-lg font-medium mb-4">Количество заявок</h3>
<canvas ref="chartRef"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { Chart } from 'chart.js/auto';
const props = defineProps<{
labels: string[];
data: number[];
}>();
const chartRef = ref<HTMLCanvasElement | null>(null);
let chart: Chart | null = null;
function createChart() {
if (!chartRef.value) return;
chart?.destroy();
chart = new Chart(chartRef.value, {
type: 'line',
data: {
labels: props.labels,
datasets: [{
label: 'Количество заявок',
data: props.data,
borderColor: '#2563eb',
backgroundColor: 'rgba(37, 99, 235, 0.1)',
fill: true
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0
}
}
}
}
});
}
watch(() => [props.labels, props.data], createChart, { deep: true });
onMounted(createChart);
</script>

View File

@@ -0,0 +1,151 @@
<template>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50">
<div class="bg-white rounded-lg p-8 max-w-xl w-full mx-4 shadow-xl">
<div class="flex justify-between items-center mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-50 rounded-lg">
<component
:is="employee ? UserIcon : UserPlusIcon"
:size="24"
class="text-blue-600"
/>
</div>
<h3 class="text-xl font-semibold text-gray-900">
{{ employee ? 'Редактирование сотрудника' : 'Добавление сотрудника' }}
</h3>
</div>
<button
@click="$emit('close')"
class="text-gray-400 hover:text-gray-500 transition-colors"
>
<XIcon :size="20" />
</button>
</div>
<form @submit.prevent="handleSubmit" class="space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<FormField
v-model="formData.last_name"
label="Фамилия"
required
placeholder="Введите фамилию"
:icon="UserIcon"
/>
<FormField
v-model="formData.first_name"
label="Имя"
required
placeholder="Введите имя"
:icon="UserIcon"
/>
<FormField
v-model="formData.department"
label="Отдел"
type="select"
required
:options="departmentOptions"
:icon="BuildingIcon"
/>
<FormField
v-model="formData.office"
label="Кабинет"
required
placeholder="Номер кабинета"
:icon="DoorClosedIcon"
/>
<div class="sm:col-span-2">
<FormField
v-model="formData.password"
label="Пароль"
type="password"
:required="!employee"
placeholder="Введите пароль"
:icon="LockIcon"
:help="employee ? 'Оставьте пустым, чтобы не менять пароль' : undefined"
/>
</div>
</div>
<div class="flex justify-end gap-3 mt-8 pt-4 border-t">
<button
type="button"
@click="$emit('close')"
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
>
<XIcon :size="16" />
Отмена
</button>
<button
type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors flex items-center gap-2"
>
<component :is="employee ? SaveIcon : UserPlusIcon" :size="16" />
{{ employee ? 'Сохранить' : 'Добавить' }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { XIcon, UserIcon, BuildingIcon, DoorClosedIcon, LockIcon, UserPlusIcon, SaveIcon } from 'lucide-vue-next';
import { departments } from '@/utils/constants';
import FormField from '@/components/ui/FormField.vue';
interface Employee {
id: number;
first_name: string;
last_name: string;
department: string;
office: string;
}
const props = defineProps<{
employee?: Employee;
}>();
const departmentOptions = departments.map(d => ({
value: d.value,
label: d.label
}));
const emit = defineEmits<{
(e: 'close'): void;
(e: 'submit', data: any): void;
}>();
const formData = ref({
first_name: '',
last_name: '',
department: '',
office: '',
password: ''
});
onMounted(() => {
if (props.employee) {
formData.value = {
first_name: props.employee.first_name,
last_name: props.employee.last_name,
department: props.employee.department,
office: props.employee.office,
password: ''
};
}
});
function handleSubmit() {
const data = { ...formData.value };
if (props.employee && !data.password) {
formData.value.password = ''; // Вместо delete используем присваивание пустой строки
}
emit('submit', data);
}
</script>

View File

@@ -0,0 +1,162 @@
<template>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Тип обращения<span class="text-red-500">*</span>
</label>
<select
v-model="formData.request_type"
required
:disabled="isSubmitting"
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Выберите тип обращения</option>
<option v-for="type in requestTypes" :key="type.value" :value="type.value">
{{ type.label }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Приоритет<span class="text-red-500">*</span>
</label>
<select
v-model="formData.priority"
required
:disabled="isSubmitting"
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Выберите приоритет</option>
<option value="low">Низкий</option>
<option value="medium">Средний</option>
<option value="high">Высокий</option>
<option value="critical">Критический</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Описание проблемы<span class="text-red-500">*</span>
</label>
<textarea
v-model="formData.description"
required
:disabled="isSubmitting"
rows="4"
class="w-full px-3 py-2 text-sm border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Опишите вашу проблему..."
></textarea>
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full bg-blue-600 text-white py-2 px-4 text-sm sm:text-base rounded-md hover:bg-blue-700 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
>
<component :is="isSubmitting ? LoaderIcon : SendIcon"
:size="18"
:class="{ 'animate-spin': isSubmitting }"
/>
{{ isSubmitting ? 'Отправка...' : 'Отправить заявку' }}
</button>
</form>
<Transition
enter-active-class="transform ease-out duration-300"
enter-from-class="translate-y-2 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showNotification"
class="fixed top-4 right-4 z-50 max-w-sm w-full bg-green-50 rounded-lg shadow-lg ring-1 ring-green-500 ring-opacity-5 overflow-hidden"
>
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<CheckCircleIcon class="h-5 w-5 text-green-400" />
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium text-green-800">
Заявка успешно отправлена. Ожидайте прибытия технического специалиста.
</p>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button
@click="showNotification = false"
class="inline-flex text-green-500 hover:text-green-600 focus:outline-none focus:ring-2 focus:ring-green-500"
>
<XIcon class="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { SendIcon, LoaderIcon, CheckCircleIcon, XIcon } from 'lucide-vue-next';
import { useAuthStore } from '@/stores/auth';
import { requestTypes } from '@/utils/constants';
const authStore = useAuthStore();
const isSubmitting = ref(false);
const showNotification = ref(false);
const formData = ref({
request_type: '',
priority: '',
description: '',
department: ''
});
async function handleSubmit() {
if (!authStore.user) {
alert('Необходимо авторизоваться');
return;
}
isSubmitting.value = true;
try {
const response = await fetch('/api/requests/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...formData.value,
employee_id: parseInt(authStore.user.id),
department: authStore.user.department
}),
});
if (!response.ok) {
throw new Error('Failed to submit request');
}
// Show notification
showNotification.value = true;
setTimeout(() => {
showNotification.value = false;
}, 5000);
// Reset form
formData.value = {
request_type: '',
priority: '',
description: '',
department: ''
};
} catch (error) {
console.error('Error submitting request:', error);
alert('Ошибка при отправке заявки');
} finally {
isSubmitting.value = false;
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ label }}<span v-if="required" class="text-red-500">*</span>
</label>
<div class="relative">
<div v-if="icon" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<component :is="icon" size="18" class="text-gray-400" />
</div>
<template v-if="type === 'select'">
<select
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
:required="required"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Выберите {{ label.toLowerCase() }}</option>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</template>
<template v-else>
<input
:type="type"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
:required="required"
class="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</template>
</div>
<slot name="help"></slot>
</div>
</template>
<script setup lang="ts">
import { Component } from 'vue';
defineProps<{
modelValue: string;
label: string;
type?: string;
required?: boolean;
disabled?: boolean;
placeholder?: string;
help?: string;
icon?: Component;
size?: number | string; // Добавляем поддержку как числа, так и строки
options?: Array<{ value: string; label: string }>;
}>();
defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
</script>

View File

@@ -0,0 +1,61 @@
<template>
<Transition
enter-active-class="transform ease-out duration-300 transition"
enter-from-class="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enter-to-class="translate-y-0 opacity-100 sm:translate-x-0"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed top-4 right-4 z-50 max-w-sm w-full bg-white rounded-lg shadow-lg ring-1 ring-black ring-opacity-5 overflow-hidden"
:class="{
'bg-green-50 ring-green-500': type === 'success',
'bg-red-50 ring-red-500': type === 'error'
}"
>
<div class="p-4">
<div class="flex items-start">
<div class="flex-shrink-0">
<component
:is="type === 'error' ? XCircleIcon : CheckCircleIcon"
class="h-5 w-5"
:class="type === 'error' ? 'text-red-400' : 'text-green-400'"
/>
</div>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm font-medium" :class="type === 'error' ? 'text-red-800' : 'text-green-800'">
{{ message }}
</p>
</div>
<div class="ml-4 flex-shrink-0 flex">
<button
@click="$emit('close')"
class="inline-flex rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2"
:class="type === 'error' ? 'text-red-500 hover:text-red-600 focus:ring-red-500' : 'text-green-500 hover:text-green-600 focus:ring-green-500'"
>
<XIcon class="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { CheckCircleIcon, XIcon, XCircleIcon } from 'lucide-vue-next';
withDefaults(defineProps<{
show: boolean;
message: string;
type?: 'success' | 'error';
}>(), {
type: 'success'
});
defineEmits<{
(e: 'close'): void;
}>();
</script>

View File

@@ -0,0 +1,31 @@
import { ref } from 'vue';
export function useNotification() {
const show = ref(false);
const message = ref('');
const type = ref<'success' | 'error'>('success');
function showNotification(newMessage: string, newType: 'success' | 'error' = 'success', duration = 3000) {
message.value = newMessage;
type.value = newType;
show.value = true;
if (duration > 0) {
setTimeout(() => {
show.value = false;
}, duration);
}
}
function hideNotification() {
show.value = false;
}
return {
show,
message,
type,
showNotification,
hideNotification
};
}

3
frontend/src/index.css Normal file
View File

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

12
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,12 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './index.css';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,48 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import LoginView from '../views/LoginView.vue';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'login',
component: LoginView
},
{
path: '/support',
name: 'support',
// Ошибка: Не найден модуль '../views/SupportView.vue'
component: () => import('../views/SupportView.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin',
name: 'admin-login',
// Ошибка: Не найден модуль '../views/admin/AdminLoginView.vue'
component: () => import('../views/admin/AdminLoginView.vue')
},
{
path: '/admin/dashboard',
name: 'admin-dashboard',
// Ошибка: Не найден модуль '../views/admin/DashboardView.vue'
component: () => import('../views/admin/DashboardView.vue'),
meta: { requiresAdmin: true }
}
]
});
router.beforeEach((to, _, next) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login' });
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
next({ name: 'admin-login' });
} else {
next();
}
});
export default router;

View File

@@ -0,0 +1,79 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { User } from '@/types/auth';
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const isAdmin = ref(false);
const isAuthenticated = computed(() => !!user.value);
function setUser(newUser: User | null) {
user.value = newUser;
}
function setAdmin(value: boolean) {
isAdmin.value = value;
}
async function login(lastName: string, password: string): Promise<boolean> {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ lastName, password }),
});
if (!response.ok) {
return false;
}
const userData = await response.json();
setUser(userData);
return true;
} catch (error) {
console.error('Login error:', error);
return false;
}
}
async function adminLogin(username: string, password: string): Promise<boolean> {
try {
const response = await fetch('/api/auth/admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
return false;
}
setAdmin(true);
return true;
} catch (error) {
console.error('Admin login error:', error);
return false;
}
}
function logout() {
user.value = null;
isAdmin.value = false;
}
return {
user,
isAdmin,
isAuthenticated,
setUser,
setAdmin,
login,
adminLogin,
logout
};
});

View File

@@ -0,0 +1,36 @@
export interface User {
id: string;
firstName: string;
lastName: string;
department: string;
createdAt: string;
}
export interface LoginCredentials {
lastName: string;
password: string;
}
export interface AdminCredentials {
username: string;
password: string;
}
export interface Employee {
id: number;
first_name: string;
last_name: string;
department: string;
office: string;
}
export interface Request {
id: number;
employee_last_name: string;
employee_first_name: string;
employee_office: string;
request_type: string;
priority: 'low' | 'medium' | 'high' | 'critical';
status: 'new' | 'in_progress' | 'resolved' | 'closed';
created_at: string;
}

View File

@@ -0,0 +1,17 @@
export interface Employee {
id: number;
first_name: string;
last_name: string;
department: string;
office: string;
created_at?: string;
}
export interface EmployeeFormData {
first_name: string;
last_name: string;
department: string;
office: string;
password?: string; // Делаем password опциональным
}

View File

@@ -0,0 +1,14 @@
export type RequestStatus = 'new' | 'in_progress' | 'resolved' | 'closed';
export interface Request {
id: number;
status: RequestStatus;
created_at: string;
employee_last_name: string;
employee_first_name: string;
employee_office: string;
request_type: string;
priority: 'low' | 'medium' | 'high' | 'critical';
description: string;
}

View File

@@ -0,0 +1,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;
}

View File

@@ -0,0 +1,98 @@
<template>
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
<h2 class="text-2xl font-semibold text-slate-800 mb-4">Вход в систему</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Фамилия
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
<UserIcon :size="18" class="text-slate-400" />
</div>
<input
v-model="lastName"
type="text"
required
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
placeholder="Введите фамилию"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Пароль
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
<LockIcon :size="18" class="text-slate-400" />
</div>
<input
v-model="password"
type="password"
required
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
placeholder="Введите пароль"
/>
</div>
</div>
<button
type="submit"
:disabled="isLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
<component
:is="isLoading ? LoaderIcon : LogInIcon"
:size="18"
:class="{ 'animate-spin': isLoading }"
/>
{{ isLoading ? 'Вход...' : 'Войти' }}
</button>
<div class="text-center">
<router-link
to="/admin"
class="text-sm text-blue-600 hover:text-blue-800"
>
Вход для администраторов
</router-link>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { UserIcon, LockIcon, LogInIcon, LoaderIcon } from 'lucide-vue-next';
const router = useRouter();
const authStore = useAuthStore();
const lastName = ref('');
const password = ref('');
const isLoading = ref(false);
async function handleSubmit() {
if (isLoading.value) return;
isLoading.value = true;
try {
const success = await authStore.login(lastName.value, password.value);
if (success) {
router.push('/support');
} else {
alert('Неверная фамилия или пароль');
}
} catch (error) {
console.error('Error:', error);
alert('Ошибка авторизации');
} finally {
isLoading.value = false;
}
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div class="bg-white rounded-lg shadow-lg p-4 sm:p-6">
<h2 class="text-xl sm:text-2xl font-semibold text-slate-800 mb-2">
Техническая поддержка
</h2>
<p class="text-sm sm:text-base text-slate-600 mb-4 sm:mb-6">
Заполните форму для создания заявки в IT-отдел
</p>
<RequestForm />
</div>
</template>
<script setup lang="ts">
import RequestForm from '@/components/request/RequestForm.vue';
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg p-6">
<h2 class="text-2xl font-semibold text-slate-800 mb-4">Вход в админ-панель</h2>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Логин
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
<UserIcon :size="18" class="text-slate-400" />
</div>
<input
v-model="username"
type="text"
required
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
placeholder="Введите логин"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 mb-1">
Пароль
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center">
<LockIcon :size="18" class="text-slate-400" />
</div>
<input
v-model="password"
type="password"
required
class="w-full pl-10 pr-3 py-2 border border-slate-300 rounded-md focus:ring-2 focus:ring-blue-500"
placeholder="Введите пароль"
/>
</div>
</div>
<button
type="submit"
:disabled="isLoading"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
<component
:is="isLoading ? LoaderIcon : LogInIcon"
:size="18"
:class="{ 'animate-spin': isLoading }"
/>
{{ isLoading ? 'Вход...' : 'Войти' }}
</button>
<div class="text-center">
<router-link
to="/"
class="text-sm text-blue-600 hover:text-blue-800"
>
Вернуться к входу для сотрудников
</router-link>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { UserIcon, LockIcon, LogInIcon, LoaderIcon } from 'lucide-vue-next';
const router = useRouter();
const authStore = useAuthStore();
const username = ref('');
const password = ref('');
const isLoading = ref(false);
async function handleSubmit() {
if (isLoading.value) return;
isLoading.value = true;
try {
const success = await authStore.adminLogin(username.value, password.value);
if (success) {
router.push('/admin/dashboard');
} else {
alert('Неверные учетные данные');
}
} catch (error) {
alert('Ошибка авторизации');
} finally {
isLoading.value = false;
}
}
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Панель администратора</h1>
<button
@click="handleLogout"
class="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 transition-colors"
>
Выйти
</button>
</div>
<div class="bg-white rounded-lg shadow-lg p-6">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
<button
v-for="tab in tabs"
:key="tab.id"
@click="currentTab = tab.id"
:class="[
currentTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
'whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm'
]"
>
{{ tab.name }}
</button>
</nav>
</div>
<div class="mt-6">
<StatisticsPanel v-if="currentTab === 'statistics'" />
<RequestList v-if="currentTab === 'requests'" />
<EmployeeList v-if="currentTab === 'employees'" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import RequestList from '@/components/admin/RequestList.vue';
import EmployeeList from '@/components/admin/EmployeeList.vue';
import StatisticsPanel from '@/components/admin/StatisticsPanel.vue';
const router = useRouter();
const authStore = useAuthStore();
const tabs = [
{ id: 'statistics', name: 'Статистика' },
{ id: 'requests', name: 'Заявки' },
{ id: 'employees', name: 'Сотрудники' }
];
const currentTab = ref('statistics');
function handleLogout() {
authStore.logout();
router.push('/admin');
}
</script>

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

31
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Paths */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { fileURLToPath, URL } from 'url';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
});