Первоначальная версия проекта chinilich-1.0

This commit is contained in:
2026-05-10 16:49:04 +03:00
commit 9b598697c5
16 changed files with 2886 additions and 0 deletions
+611
View File
@@ -0,0 +1,611 @@
<?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>