611 lines
30 KiB
PHP
611 lines
30 KiB
PHP
<?php
|
||
require_once __DIR__ . '/config.php';
|
||
|
||
$user = checkAuth($pdo);
|
||
if (!$user) {
|
||
redirect('index.php');
|
||
}
|
||
|
||
require_once __DIR__ . '/glpi_api.php';
|
||
$glpi = new GlpiApi(GLPI_API_URL, GLPI_APP_TOKEN, GLPI_USERNAME, GLPI_PASSWORD);
|
||
|
||
$message = '';
|
||
$messageType = '';
|
||
|
||
$userForms = getUserForms($pdo, $user['id']);
|
||
|
||
// Маппинг статусов GLPI на русские
|
||
$statusMap = [
|
||
'new' => 'Новая',
|
||
'processing' => 'В обработке',
|
||
'incoming' => 'Входящая',
|
||
'waiting' => 'Ожидание',
|
||
'pending' => 'На рассмотрении',
|
||
'closed' => 'Закрыта',
|
||
'solved' => 'Решена',
|
||
'assigned' => 'Назначена',
|
||
'rejected' => 'Отклонена'
|
||
];
|
||
|
||
// Синхронизация с GLPI
|
||
$updatedForms = [];
|
||
foreach ($userForms as $form) {
|
||
if (syncFormWithGlpi($pdo, $form, $glpi)) {
|
||
$updatedForms[] = $form;
|
||
}
|
||
}
|
||
$userForms = $updatedForms;
|
||
|
||
// Обработка создания заявки
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_request'])) {
|
||
$formType = $_POST['form_type'] ?? '';
|
||
$priority = $_POST['priority'] ?? 'medium';
|
||
$locationId = !empty($_POST['location']) ? (int)$_POST['location'] : null;
|
||
|
||
$allowedTypes = ['it_request', 'anonymous_complaint'];
|
||
if (!in_array($formType, $allowedTypes)) {
|
||
$message = 'Выберите корректный тип заявки';
|
||
$messageType = 'danger';
|
||
} else {
|
||
$formData = [];
|
||
$glpiDescription = '';
|
||
$uploadedFilePath = null;
|
||
|
||
if (!empty($_FILES['attachment']) && $_FILES['attachment']['error'] !== UPLOAD_ERR_NO_FILE) {
|
||
$uploadedFilePath = uploadFile($_FILES['attachment']);
|
||
if (!$uploadedFilePath) {
|
||
$message = 'Ошибка загрузки файла. Проверьте размер и тип.';
|
||
$messageType = 'danger';
|
||
}
|
||
}
|
||
|
||
if (!$message) {
|
||
if ($formType === 'it_request') {
|
||
$computerId = $_POST['computer_id'] ?? '';
|
||
$categoryId = $_POST['itil_category'] ?? '';
|
||
$description = trim($_POST['description'] ?? '');
|
||
|
||
if (!$computerId || !$categoryId || empty($description)) {
|
||
$message = 'Заполните все обязательные поля IT-заявки';
|
||
$messageType = 'danger';
|
||
} else {
|
||
// Получаем читаемые имена из GLPI
|
||
$computerName = $glpi->getComputerName((int)$computerId);
|
||
$categoryName = $glpi->getCategoryName((int)$categoryId);
|
||
|
||
// Данные пользователя
|
||
$userFullName = trim($user['last_name'] . ' ' . $user['first_name'] . ' ' . $user['patronymic']);
|
||
$userDepartment = $user['department'] ?? 'не указан';
|
||
$userPosition = $user['position'] ?? 'не указана';
|
||
|
||
// Формируем красивое описание
|
||
$glpiDescription = "Пользователь: $userFullName\n";
|
||
$glpiDescription .= "Отдел: $userDepartment\n";
|
||
$glpiDescription .= "Должность: $userPosition\n";
|
||
$glpiDescription .= "Оборудование: $computerName\n";
|
||
$glpiDescription .= "Категория: $categoryName\n";
|
||
$glpiDescription .= "Описание проблемы:\n$description";
|
||
|
||
if ($uploadedFilePath) {
|
||
$glpiDescription .= "\n\nПрикреплённый файл: " . $_SERVER['HTTP_HOST'] . '/' . $uploadedFilePath;
|
||
}
|
||
|
||
$formData = [
|
||
'computer_id' => $computerId,
|
||
'computer_name' => $computerName,
|
||
'itil_category' => $categoryId,
|
||
'category_name' => $categoryName,
|
||
'description' => $description,
|
||
'location_id' => $locationId,
|
||
'user_info' => [
|
||
'name' => $userFullName,
|
||
'department' => $userDepartment,
|
||
'position' => $userPosition
|
||
]
|
||
];
|
||
if ($uploadedFilePath) $formData['attachment'] = $uploadedFilePath;
|
||
}
|
||
} else {
|
||
$subject = trim($_POST['subject'] ?? '');
|
||
$complaintText = trim($_POST['complaint_text'] ?? '');
|
||
|
||
if (empty($subject) || empty($complaintText)) {
|
||
$message = 'Укажите тему и текст жалобы';
|
||
$messageType = 'danger';
|
||
} else {
|
||
// Для анонимной жалобы не пишем данные пользователя
|
||
$glpiDescription = "Тема: $subject\n\nТекст жалобы:\n$complaintText";
|
||
if ($uploadedFilePath) {
|
||
$glpiDescription .= "\n\nПрикреплённый файл: " . $_SERVER['HTTP_HOST'] . '/' . $uploadedFilePath;
|
||
}
|
||
$formData = ['subject' => $subject, 'text' => $complaintText];
|
||
if ($uploadedFilePath) $formData['attachment'] = $uploadedFilePath;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!$message) {
|
||
$glpiData = [
|
||
'name' => $formType === 'it_request' ? 'IT-заявка' : 'Анонимная жалоба',
|
||
'content' => $glpiDescription,
|
||
'priority' => $priority === 'high' ? 4 : ($priority === 'medium' ? 2 : 1), // 1-5, обычно 3 средний, но уточните по вашей конфигурации
|
||
'type' => 1, // инцидент
|
||
'requesttypes_id' => 1, // по умолчанию
|
||
];
|
||
|
||
if ($formType === 'it_request' && !empty($computerId)) {
|
||
$glpiData['items_id'] = ['itemtype' => 'Computer', 'items_id' => (int)$computerId];
|
||
}
|
||
if ($locationId) {
|
||
$glpiData['locations_id'] = $locationId;
|
||
}
|
||
|
||
// Попробуем установить автора заявки (если текущий пользователь может создавать заявки от своего имени)
|
||
// В GLPI API можно передать _users_id_requester. Но для этого нужны права.
|
||
// Оставим как есть, GLPI сам подставит автора из сессии API.
|
||
|
||
$glpiResponse = $glpi->createTicket($glpiData);
|
||
$userIdForDb = $formType === 'it_request' ? $user['id'] : null;
|
||
$localData = [
|
||
'glpi_response' => $glpiResponse ? 'success' : 'error',
|
||
'glpi_ticket_id' => $glpiResponse['id'] ?? null,
|
||
'form_fields' => $formData,
|
||
];
|
||
createLocalForm($pdo, $userIdForDb, $formType, $localData, 'pending', $priority, $locationId);
|
||
|
||
if ($glpiResponse) {
|
||
$message = "Заявка успешно отправлена! ID GLPI: " . ($glpiResponse['id'] ?? 'N/A');
|
||
$messageType = 'success';
|
||
} else {
|
||
$message = "Ошибка при отправке в GLPI. Заявка сохранена локально.";
|
||
$messageType = 'warning';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Повторно получаем формы после возможного обновления
|
||
$userForms = getUserForms($pdo, $user['id']);
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Панель заявок – Service Desk</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||
<link rel="stylesheet" href="style.css">
|
||
</head>
|
||
<body>
|
||
<header class="header containers">
|
||
<div class="container">
|
||
<h1>🧙♂️ Чинилыч</h1>
|
||
<div class="user-info">
|
||
<span><?= htmlspecialchars($user['last_name'] . ' ' . $user['first_name']) ?></span>
|
||
<button id="themeToggle" class="theme-toggle" aria-label="Тёмная тема">🌙</button>
|
||
<a href="logout.php" class="btn btn-outline">Выход</a>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="container">
|
||
<div class="nav-links">
|
||
<a href="dashboard.php" class="active">📋 Заявки</a>
|
||
<a href="knowledge.php">📚 База знаний</a>
|
||
</div>
|
||
|
||
<?php if ($message): ?>
|
||
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
|
||
<?php endif; ?>
|
||
|
||
<div class="info-block">
|
||
<p><strong>📌 Как это работает?</strong> Выберите тип заявки: ИТ‑заявка или анонимная жалоба. Для ИТ‑заявок поля подгружаются из GLPI. При выборе оборудования локация подставится автоматически. Заполните все поля, при необходимости прикрепите файл, и нажмите «Отправить».</p>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>➕ Новая заявка</h2>
|
||
<form method="post" enctype="multipart/form-data" id="requestForm">
|
||
<div class="form-group">
|
||
<label for="form_type">Тип заявки</label>
|
||
<select id="form_type" name="form_type" required>
|
||
<option value="">-- Выберите --</option>
|
||
<option value="it_request">🖥️ ИТ-заявка</option>
|
||
<option value="anonymous_complaint">🕵️ Анонимная жалоба</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="priority">Приоритет</label>
|
||
<select id="priority" name="priority">
|
||
<option value="low">Низкий</option>
|
||
<option value="medium" selected>Средний</option>
|
||
<option value="high">Высокий</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div id="dynamicFields"></div>
|
||
|
||
<div class="form-group">
|
||
<label for="attachment">📎 Прикрепить файл (скриншот, документ)</label>
|
||
<input type="file" id="attachment" name="attachment" accept="image/*,.pdf">
|
||
<div class="hint">Максимальный размер: 5 МБ. Допустимые типы: JPG, PNG, GIF, PDF.</div>
|
||
</div>
|
||
|
||
<button type="submit" name="create_request" class="btn btn-primary">Отправить заявку</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>📋 Мои заявки</h2>
|
||
<div class="table-wrapper">
|
||
<?php if (empty($userForms)): ?>
|
||
<p style="color: var(--text-muted);">У вас пока нет заявок.</p>
|
||
<?php else: ?>
|
||
<table class="table">
|
||
<thead>
|
||
<tr><th>ID</th><th>Название</th><th>Приоритет</th><th>Статус</th><th>Дата</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($userForms as $form):
|
||
$formData = json_decode($form['data'], true);
|
||
$glpiId = $formData['glpi_ticket_id'] ?? null;
|
||
$glpiName = $formData['glpi_name'] ?? $form['type'];
|
||
$statusRaw = strtolower($form['status']);
|
||
// Перевод статуса
|
||
$statusRu = $statusMap[$statusRaw] ?? ucfirst($statusRaw);
|
||
$priorityRaw = strtolower($form['priority']);
|
||
?>
|
||
<tr>
|
||
<td>#<?= $form['id'] ?></td>
|
||
<td><?= htmlspecialchars($glpiName) ?><?php if ($glpiId): ?><br><small class="hint">GLPI #<?= $glpiId ?></small><?php endif; ?></td>
|
||
<td><span class="priority priority-<?= $priorityRaw ?>"><?= $priorityRaw === 'low' ? 'Низкий' : ($priorityRaw === 'medium' ? 'Средний' : ($priorityRaw === 'high' ? 'Высокий' : $priorityRaw)) ?></span></td>
|
||
<td><span class="status status-<?= $statusRaw ?>"><?= htmlspecialchars($statusRu) ?></span></td>
|
||
<td><?= date('d.m.Y H:i', strtotime($form['created_at'])) ?></td>
|
||
<td><?php if ($glpiId): ?><button class="btn btn-outline btn-sm view-ticket" data-ticket-id="<?= $glpiId ?>">Подробнее</button><?php else: ?><span class="hint">Ожидает GLPI</span><?php endif; ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- FAQ блок -->
|
||
<div class="card">
|
||
<h2>❓ Часто задаваемые вопросы</h2>
|
||
<div class="faq-item"><input type="checkbox" id="faq1" class="faq-toggle"><label for="faq1" class="faq-question">Как долго обрабатывается заявка?</label><div class="faq-answer"><p>Обычно заявки рассматриваются в течение 2 рабочих часов.</p></div></div>
|
||
<div class="faq-item"><input type="checkbox" id="faq2" class="faq-toggle"><label for="faq2" class="faq-question">Что делать, если компьютер не загружается?</label><div class="faq-answer"><p>Создайте ИТ‑заявку с категорией «Аппаратная неисправность».</p></div></div>
|
||
<div class="faq-item"><input type="checkbox" id="faq3" class="faq-toggle"><label for="faq3" class="faq-question">Можно ли подать жалобу анонимно?</label><div class="faq-answer"><p>Да, для этого выберите тип «Анонимная жалоба».</p></div></div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>🔗 Полезные ссылки</h2>
|
||
<div class="external-links">
|
||
<a href="https://glpi.yourdomain.com" target="_blank" class="btn-link">🌐 Портал GLPI</a>
|
||
<a href="https://kb.yourdomain.com" target="_blank" class="btn-link">📚 База знаний</a>
|
||
<a href="mailto:support@yourdomain.com" class="btn-link">📧 Написать в поддержку</a>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Модальное окно деталей заявки (улучшенное) -->
|
||
<div class="modal-overlay" id="ticketModal">
|
||
<div class="modal modal-ticket">
|
||
<div class="modal-header">
|
||
<h3>Заявка <span id="modalTicketId"></span></h3>
|
||
<button class="modal-close" onclick="closeModal()">×</button>
|
||
</div>
|
||
<div class="modal-body" id="modalContent">
|
||
<div class="loading-spinner">Загрузка...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<style>
|
||
/* Стили для модального окна заявки */
|
||
.modal-ticket {
|
||
max-width: 750px;
|
||
width: 90%;
|
||
border-radius: 20px;
|
||
overflow: hidden;
|
||
}
|
||
.ticket-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 15px;
|
||
border-bottom: 2px solid var(--border-light);
|
||
}
|
||
.ticket-title {
|
||
font-size: 1.3rem;
|
||
font-weight: 600;
|
||
color: var(--primary-dark);
|
||
}
|
||
.ticket-status {
|
||
display: inline-block;
|
||
padding: 6px 14px;
|
||
border-radius: 30px;
|
||
font-size: 0.8rem;
|
||
font-weight: 500;
|
||
}
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: 120px 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 25px;
|
||
background: var(--bg-light);
|
||
padding: 15px;
|
||
border-radius: 16px;
|
||
}
|
||
.info-label {
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
}
|
||
.info-value {
|
||
color: var(--text-dark);
|
||
word-break: break-word;
|
||
}
|
||
.description-box {
|
||
background: var(--bg-white);
|
||
border: 1px solid var(--border-light);
|
||
border-radius: 16px;
|
||
padding: 15px;
|
||
margin: 20px 0;
|
||
}
|
||
.description-box h4 {
|
||
margin-top: 0;
|
||
margin-bottom: 10px;
|
||
color: var(--primary-dark);
|
||
}
|
||
.followups-list {
|
||
margin-top: 20px;
|
||
}
|
||
.followup-card {
|
||
background: var(--bg-light);
|
||
border-radius: 16px;
|
||
padding: 15px;
|
||
margin-bottom: 15px;
|
||
transition: var(--transition);
|
||
}
|
||
.followup-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
font-size: 0.85rem;
|
||
color: var(--text-muted);
|
||
border-bottom: 1px dashed var(--border-light);
|
||
padding-bottom: 6px;
|
||
}
|
||
.followup-author {
|
||
font-weight: 600;
|
||
color: var(--primary-dark);
|
||
}
|
||
.followup-content {
|
||
line-height: 1.5;
|
||
white-space: pre-wrap;
|
||
}
|
||
.no-followups {
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
padding: 20px;
|
||
font-style: italic;
|
||
}
|
||
@media (max-width: 640px) {
|
||
.info-grid {
|
||
grid-template-columns: 1fr;
|
||
gap: 6px;
|
||
}
|
||
.info-label {
|
||
margin-bottom: 0;
|
||
}
|
||
.ticket-header {
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<script>
|
||
// Тёмная тема
|
||
const themeToggle = document.getElementById('themeToggle');
|
||
if (localStorage.getItem('theme') === 'dark') {
|
||
document.body.classList.add('dark');
|
||
themeToggle.textContent = '☀️';
|
||
}
|
||
themeToggle.addEventListener('click', () => {
|
||
document.body.classList.toggle('dark');
|
||
const isDark = document.body.classList.contains('dark');
|
||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||
themeToggle.textContent = isDark ? '☀️' : '🌙';
|
||
});
|
||
|
||
// Динамические поля формы
|
||
const typeSelect = document.getElementById('form_type');
|
||
const dynamicContainer = document.getElementById('dynamicFields');
|
||
let glpiDataCache = { locations: null, computers: null, itilcategories: null };
|
||
|
||
async function fetchGlpiData(type) {
|
||
if (glpiDataCache[type]) return glpiDataCache[type];
|
||
try {
|
||
const response = await fetch(`get_glpi_data.php?type=${type}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const data = await response.json();
|
||
glpiDataCache[type] = data;
|
||
return data;
|
||
} catch (error) {
|
||
console.error(error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function createSelectOptions(items) {
|
||
let options = '<option value="">-- Выберите --</option>';
|
||
items.forEach(item => { options += `<option value="${item.id}">${escapeHtml(item.name)}</option>`; });
|
||
return options;
|
||
}
|
||
|
||
async function renderFields() {
|
||
const type = typeSelect.value;
|
||
if (type === 'it_request') {
|
||
dynamicContainer.innerHTML = '<div class="loading">Загрузка справочников...</div>';
|
||
try {
|
||
const [locations, computers, categories] = await Promise.all([
|
||
fetchGlpiData('locations'),
|
||
fetchGlpiData('computers'),
|
||
fetchGlpiData('itilcategories')
|
||
]);
|
||
let computersOptions = '';
|
||
computers.forEach(item => {
|
||
computersOptions += `<option value="${escapeHtml(item.name)}" data-id="${item.id}">${escapeHtml(item.name)}</option>`;
|
||
});
|
||
let html = `
|
||
<div class="field-group">
|
||
<h3>Информация об оборудовании</h3>
|
||
<div class="form-group">
|
||
<label for="location">Местоположение</label>
|
||
<select id="location" name="location">${createSelectOptions(locations)}</select>
|
||
<div class="hint">Автоматически подставится при выборе оборудования</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="computer_search">Оборудование *</label>
|
||
<input type="text" id="computer_search" list="computers_list" placeholder="Начните вводить название или инв. номер" autocomplete="off" required>
|
||
<datalist id="computers_list">${computersOptions}</datalist>
|
||
<input type="hidden" id="computer_id" name="computer_id" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="itil_category">Категория проблемы *</label>
|
||
<select id="itil_category" name="itil_category" required>${createSelectOptions(categories)}</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="description">Описание проблемы *</label>
|
||
<textarea id="description" name="description" rows="4" required placeholder="Подробно опишите, что случилось..."></textarea>
|
||
</div>
|
||
</div>
|
||
`;
|
||
dynamicContainer.innerHTML = html;
|
||
|
||
const computerSearch = document.getElementById('computer_search');
|
||
const computerIdField = document.getElementById('computer_id');
|
||
const locationSelect = document.getElementById('location');
|
||
const datalist = document.getElementById('computers_list');
|
||
|
||
computerSearch.addEventListener('change', async function() {
|
||
const val = this.value;
|
||
const option = Array.from(datalist.options).find(opt => opt.value === val);
|
||
if (option && option.dataset.id) {
|
||
const id = option.dataset.id;
|
||
computerIdField.value = id;
|
||
try {
|
||
const resp = await fetch(`get_computer_location.php?id=${id}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
const locData = await resp.json();
|
||
if (locData.location_id && locationSelect) {
|
||
locationSelect.value = locData.location_id;
|
||
}
|
||
} catch(e) { console.error(e); }
|
||
} else {
|
||
computerIdField.value = '';
|
||
}
|
||
});
|
||
|
||
document.getElementById('requestForm').addEventListener('submit', function(e) {
|
||
if (typeSelect.value === 'it_request' && !computerIdField.value) {
|
||
e.preventDefault();
|
||
alert('Пожалуйста, выберите оборудование из списка.');
|
||
}
|
||
});
|
||
} catch (error) {
|
||
dynamicContainer.innerHTML = '<div class="error-message">Ошибка загрузки справочников.</div>';
|
||
}
|
||
} else if (type === 'anonymous_complaint') {
|
||
dynamicContainer.innerHTML = `
|
||
<div class="field-group">
|
||
<h3>Анонимная жалоба</h3>
|
||
<div class="form-group"><label for="subject">Тема *</label><input type="text" id="subject" name="subject" required placeholder="Краткая суть жалобы"></div>
|
||
<div class="form-group"><label for="complaint_text">Текст жалобы *</label><textarea id="complaint_text" name="complaint_text" rows="6" required placeholder="Опишите ситуацию..."></textarea></div>
|
||
<p class="hint">Жалоба будет отправлена анонимно, ваше имя не будет указано.</p>
|
||
</div>
|
||
`;
|
||
} else {
|
||
dynamicContainer.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
typeSelect.addEventListener('change', renderFields);
|
||
if (typeSelect.value) renderFields();
|
||
|
||
// Модальное окно заявки (улучшенное)
|
||
const modal = document.getElementById('ticketModal');
|
||
const modalTicketId = document.getElementById('modalTicketId');
|
||
const modalContent = document.getElementById('modalContent');
|
||
function closeModal() { modal.style.display = 'none'; }
|
||
window.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
|
||
|
||
document.querySelectorAll('.view-ticket').forEach(btn => {
|
||
btn.addEventListener('click', async function() {
|
||
const ticketId = this.dataset.ticketId;
|
||
modal.style.display = 'flex';
|
||
modalTicketId.textContent = '#' + ticketId;
|
||
modalContent.innerHTML = '<div class="loading-spinner">Загрузка...</div>';
|
||
try {
|
||
const response = await fetch(`get_ticket_details.php?id=${ticketId}`, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||
const data = await response.json();
|
||
if (data.error) throw new Error(data.error);
|
||
|
||
// Формируем красивый HTML
|
||
let html = `
|
||
<div class="ticket-header">
|
||
<div class="ticket-title">${escapeHtml(data.name || 'Заявка')}</div>
|
||
<span class="ticket-status status status-${data.status.toLowerCase()}">${escapeHtml(data.status)}</span>
|
||
</div>
|
||
<div class="info-grid">
|
||
<div class="info-label">Приоритет</div><div class="info-value"><span class="priority priority-${data.priority.toLowerCase()}">${escapeHtml(data.priority)}</span></div>
|
||
<div class="info-label">Дата создания</div><div class="info-value">${escapeHtml(data.date)}</div>
|
||
${data.solvedate ? `<div class="info-label">Дата решения</div><div class="info-value">${escapeHtml(data.solvedate)}</div>` : ''}
|
||
</div>
|
||
<div class="description-box">
|
||
<h4>📝 Описание</h4>
|
||
<div>${data.content}</div>
|
||
</div>
|
||
<h4>💬 Комментарии и ход работ</h4>
|
||
<div class="followups-list">
|
||
`;
|
||
|
||
if (data.followups && data.followups.length > 0) {
|
||
data.followups.forEach(f => {
|
||
html += `
|
||
<div class="followup-card">
|
||
<div class="followup-header">
|
||
<span class="followup-author">${escapeHtml(f.author)}</span>
|
||
<span>${escapeHtml(f.date)}</span>
|
||
</div>
|
||
<div class="followup-content">${escapeHtml(f.content)}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
} else {
|
||
html += `<div class="no-followups">Комментариев пока нет.</div>`;
|
||
}
|
||
html += `</div>`;
|
||
modalContent.innerHTML = html;
|
||
} catch (error) {
|
||
modalContent.innerHTML = '<div class="error-message">Не удалось загрузить детали заявки.</div>';
|
||
}
|
||
});
|
||
});
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return str.replace(/[&<>]/g, function(m) {
|
||
if (m === '&') return '&';
|
||
if (m === '<') return '<';
|
||
if (m === '>') return '>';
|
||
return m;
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|