Files
site_for_glpi/dashboard.php
T

611 lines
30 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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()">&times;</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 '&amp;';
if (m === '<') return '&lt;';
if (m === '>') return '&gt;';
return m;
});
}
</script>
</body>
</html>