Compare commits

...

1 Commits

Author SHA1 Message Date
Flatlogic Bot
fd8a2de90a z 2026-03-01 18:23:38 +00:00
17 changed files with 1373 additions and 515 deletions

202
admin.php
View File

@ -1,68 +1,30 @@
<?php <?php
require_once __DIR__ . '/db/config.php'; require_once __DIR__ . '/auth.php';
$user = requireRole('curator');
// Simple handling of form submissions $users = db()->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['action']) && $_POST['action'] === 'add') { if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
$keywords = $_POST['keywords'] ?? ''; if ($_POST['action'] === 'change_role') {
$answer = $_POST['answer'] ?? ''; $stmt = db()->prepare("UPDATE users SET role = ? WHERE id = ?");
if ($keywords && $answer) { $stmt->execute([$_POST['role'], $_POST['user_id']]);
$stmt = db()->prepare("INSERT INTO faqs (keywords, answer) VALUES (?, ?)"); } elseif ($_POST['action'] === 'toggle_status') {
$stmt->execute([$keywords, $answer]); $newStatus = $_POST['status'] === 'active' ? 'blocked' : 'active';
} $stmt = db()->prepare("UPDATE users SET status = ? WHERE id = ?");
} elseif (isset($_POST['action']) && $_POST['action'] === 'delete') { $stmt->execute([$newStatus, $_POST['user_id']]);
$id = $_POST['id'] ?? 0;
if ($id) {
$stmt = db()->prepare("DELETE FROM faqs WHERE id = ?");
$stmt->execute([$id]);
}
} elseif (isset($_POST['action']) && $_POST['action'] === 'update_settings') {
$token = $_POST['telegram_token'] ?? '';
$stmt = db()->prepare("INSERT INTO settings (setting_key, setting_value) VALUES ('telegram_token', ?) ON DUPLICATE KEY UPDATE setting_value = ?");
$stmt->execute([$token, $token]);
} }
header("Location: admin.php"); header('Location: admin.php');
exit; exit;
} }
$faqs = db()->query("SELECT * FROM faqs ORDER BY created_at DESC")->fetchAll();
$messages = db()->query("SELECT * FROM messages ORDER BY created_at DESC LIMIT 50")->fetchAll();
$telegramToken = '';
$stmt = db()->query("SELECT setting_value FROM settings WHERE setting_key = 'telegram_token'");
$row = $stmt->fetch();
if ($row) {
$telegramToken = $row['setting_value'];
}
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin - FAQ Manager</title> <title>Админ-панель - Система поддержки</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>"> <link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<style> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
.btn-delete {
background: #dc3545;
color: white;
border: none;
padding: 0.25rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.btn-add {
background: #212529;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
margin-top: 1rem;
}
</style>
</head> </head>
<body> <body>
<div class="bg-animations"> <div class="bg-animations">
@ -70,97 +32,65 @@ if ($row) {
<div class="blob blob-2"></div> <div class="blob blob-2"></div>
<div class="blob blob-3"></div> <div class="blob blob-3"></div>
</div> </div>
<div class="admin-container">
<div style="display: flex; justify-content: space-between; align-items: center;">
<h1>FAQ Manager</h1>
<a href="index.php" class="admin-link">Back to Chat</a>
</div>
<div class="admin-card" style="background: rgba(255, 255, 255, 0.6); padding: 2rem; border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.5); margin-bottom: 2.5rem; box-shadow: 0 10px 30px rgba(0,0,0,0.05);"> <div class="main-wrapper">
<h3 style="margin-top: 0; margin-bottom: 1.5rem; font-weight: 700;">Telegram Bot Settings</h3> <nav class="navbar">
<form method="POST"> <a href="index.php" class="logo" style="text-decoration: none;">SupportSystem</a>
<input type="hidden" name="action" value="update_settings"> <div class="user-info">
<div class="form-group"> <span><?= htmlspecialchars($user['username']) ?> (<?= $user['role'] ?>)</span>
<label for="telegram_token">Telegram Bot Token</label> <a href="logout.php" class="logout-link">Выйти</a>
<input type="text" name="telegram_token" id="telegram_token" class="form-control" placeholder="Paste your bot token from @BotFather" value="<?= htmlspecialchars($telegramToken) ?>"> </div>
</div> </nav>
<p style="font-size: 0.85em; color: #555; margin-top: 0.5rem;">
Webhook URL: <code>https://<?= $_SERVER['HTTP_HOST'] ?>/api/telegram_webhook.php</code>
</p>
<button type="submit" class="btn-add" style="background: #0088cc; color: white; border: none; padding: 0.8rem 1.5rem; border-radius: 12px; cursor: pointer; font-weight: 600; width: 100%; transition: all 0.3s ease;">Save Token</button>
</form>
</div>
<div class="admin-card" style="background: rgba(255, 255, 255, 0.6); padding: 2rem; border-radius: 20px; border: 1px solid rgba(255, 255, 255, 0.5); margin-bottom: 2.5rem; box-shadow: 0 10px 30px rgba(0,0,0,0.05);"> <h1>Управление пользователями</h1>
<h3 style="margin-top: 0; margin-bottom: 1.5rem; font-weight: 700;">Add New FAQ</h3>
<form method="POST"> <div style="background-color: var(--card-bg); border-radius: 1rem; border: 1px solid var(--border-color); overflow-x: auto; margin-top: 2rem;">
<input type="hidden" name="action" value="add"> <table style="width: 100%; border-collapse: collapse;">
<div class="form-group">
<label for="keywords">Keywords (comma separated)</label>
<input type="text" name="keywords" id="keywords" class="form-control" placeholder="e.g. price, cost, dollar" required>
</div>
<div class="form-group">
<label for="answer">Answer</label>
<textarea name="answer" id="answer" class="form-control" rows="3" placeholder="Enter the answer..." required></textarea>
</div>
<button type="submit" class="btn-add" style="background: #212529; color: white; border: none; padding: 0.8rem 1.5rem; border-radius: 12px; cursor: pointer; font-weight: 600; width: 100%; transition: all 0.3s ease;">Save FAQ</button>
</form>
</div>
<h3>Existing FAQs</h3>
<table class="table">
<thead>
<tr>
<th>Keywords</th>
<th>Answer</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($faqs as $faq): ?>
<tr>
<td><?= htmlspecialchars($faq['keywords']) ?></td>
<td><?= htmlspecialchars($faq['answer']) ?></td>
<td>
<form method="POST" style="display:inline;" onsubmit="return confirm('Delete this FAQ?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= $faq['id'] ?>">
<button type="submit" class="btn-delete">Delete</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<h3 style="margin-top: 3rem; margin-bottom: 1rem;">Recent Chat History (Last 50)</h3>
<div style="overflow-x: auto; background: rgba(255, 255, 255, 0.4); padding: 1rem; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.3);">
<table class="table" style="width: 100%;">
<thead> <thead>
<tr> <tr style="border-bottom: 1px solid var(--border-color);">
<th style="width: 15%;">Time</th> <th style="padding: 1rem; text-align: left;">ID</th>
<th style="width: 35%;">User Message</th> <th style="padding: 1rem; text-align: left;">Имя</th>
<th style="width: 50%;">AI Response</th> <th style="padding: 1rem; text-align: left;">Роль</th>
<th style="padding: 1rem; text-align: left;">Статус</th>
<th style="padding: 1rem; text-align: left;">Дата рег.</th>
<th style="padding: 1rem; text-align: left;">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if (empty($messages)): ?> <?php foreach ($users as $u): ?>
<tr> <tr style="border-bottom: 1px solid var(--border-color);">
<td colspan="3" style="text-align: center; color: #777;">No messages yet.</td> <td style="padding: 1rem;"><?= $u['id'] ?></td>
</tr> <td style="padding: 1rem;"><?= htmlspecialchars($u['username']) ?></td>
<?php else: ?> <td style="padding: 1rem;">
<?php foreach ($messages as $msg): ?> <form method="POST" style="display: flex; gap: 0.5rem; align-items: center;">
<tr> <input type="hidden" name="user_id" value="<?= $u['id'] ?>">
<td style="white-space: nowrap; font-size: 0.85em; color: #555;"><?= htmlspecialchars($msg['created_at']) ?></td> <input type="hidden" name="action" value="change_role">
<td style="background: rgba(255, 255, 255, 0.3); border-radius: 8px; padding: 8px;"><?= htmlspecialchars($msg['user_message']) ?></td> <select name="role" onchange="this.form.submit()" style="padding: 0.25rem 0.5rem;">
<td style="background: rgba(255, 255, 255, 0.5); border-radius: 8px; padding: 8px;"><?= htmlspecialchars($msg['ai_response']) ?></td> <option value="user" <?= $u['role'] === 'user' ? 'selected' : '' ?>>Пользователь</option>
<option value="helper" <?= $u['role'] === 'helper' ? 'selected' : '' ?>>Помощник</option>
<option value="curator" <?= $u['role'] === 'curator' ? 'selected' : '' ?>>Куратор</option>
</select>
</form>
</td>
<td style="padding: 1rem;"><?= $u['status'] ?></td>
<td style="padding: 1rem;"><?= date('d.m.Y', strtotime($u['created_at'])) ?></td>
<td style="padding: 1rem;">
<?php if ($u['id'] != $user['id']): ?>
<form method="POST">
<input type="hidden" name="user_id" value="<?= $u['id'] ?>">
<input type="hidden" name="action" value="toggle_status">
<input type="hidden" name="status" value="<?= $u['status'] ?>">
<button type="submit" class="btn-primary" style="width: auto; padding: 0.25rem 0.75rem; background-color: <?= $u['status'] === 'active' ? 'var(--error)' : 'var(--success)' ?>;">
<?= $u['status'] === 'active' ? 'Блокировать' : 'Разблокировать' ?>
</button>
</form>
<?php endif; ?>
</td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</body> </body>
</html> </html>

36
api/messages.php Normal file
View File

@ -0,0 +1,36 @@
<?php
require_once __DIR__ . '/../auth.php';
$user = requireAuth();
$ticketId = $_GET['ticket_id'] ?? null;
if (!$ticketId) {
echo json_encode(['error' => 'Missing ticket_id']);
exit;
}
// Check access
$stmt = db()->prepare("SELECT * FROM tickets WHERE id = ?");
$stmt->execute([$ticketId]);
$ticket = $stmt->fetch();
if (!$ticket) {
echo json_encode(['error' => 'Ticket not found']);
exit;
}
if ($user['role'] === 'user' && $ticket['user_id'] != $user['id']) {
echo json_encode(['error' => 'Access denied']);
exit;
}
$stmt = db()->prepare("
SELECT m.*, u.username, u.role
FROM messages m
JOIN users u ON m.user_id = u.id
WHERE m.ticket_id = ?
ORDER BY m.created_at ASC
");
$stmt->execute([$ticketId]);
$messages = $stmt->fetchAll();
echo json_encode($messages);

66
api/send_message.php Normal file
View File

@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../auth.php';
$user = requireAuth();
$ticketId = $_POST['ticket_id'] ?? null;
$message = $_POST['message'] ?? '';
$file = $_FILES['file'] ?? null;
if (!$ticketId) {
echo json_encode(['error' => 'Missing ticket_id']);
exit;
}
// Check access
$stmt = db()->prepare("SELECT * FROM tickets WHERE id = ?");
$stmt->execute([$ticketId]);
$ticket = $stmt->fetch();
if (!$ticket || $ticket['status'] === 'closed') {
echo json_encode(['error' => 'Ticket closed or not found']);
exit;
}
if ($user['role'] === 'user' && $ticket['user_id'] != $user['id']) {
echo json_encode(['error' => 'Access denied']);
exit;
}
$filePath = null;
$fileName = null;
$fileType = null;
if ($file && $file['error'] === UPLOAD_ERR_OK) {
$allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/zip', 'text/plain'];
$maxSize = 50 * 1024 * 1024; // 50MB
if ($file['size'] > $maxSize) {
echo json_encode(['error' => 'File too large (max 50MB)']);
exit;
}
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$newFileName = bin2hex(random_bytes(16)) . '.' . $extension;
$uploadDir = __DIR__ . '/../uploads/';
$targetPath = $uploadDir . $newFileName;
if (move_uploaded_file($file['tmp_name'], $targetPath)) {
$filePath = 'uploads/' . $newFileName;
$fileName = $file['name'];
$fileType = $file['type'];
} else {
echo json_encode(['error' => 'Failed to upload file']);
exit;
}
}
$stmt = db()->prepare("INSERT INTO messages (ticket_id, user_id, message, file_path, file_name, file_type) VALUES (?, ?, ?, ?, ?, ?)");
$stmt->execute([$ticketId, $user['id'], $message, $filePath, $fileName, $fileType]);
// Update ticket status automatically if helper replies
if ($user['role'] !== 'user' && $ticket['status'] === 'open') {
$updateStmt = db()->prepare("UPDATE tickets SET status = 'in_progress' WHERE id = ?");
$updateStmt->execute([$ticketId]);
}
echo json_encode(['success' => true]);

12
api/stats.php Normal file
View File

@ -0,0 +1,12 @@
<?php
require_once __DIR__ . '/../auth.php';
$user = requireRole('curator');
$stats = [
'total_tickets' => db()->query("SELECT COUNT(*) FROM tickets")->fetchColumn(),
'active_tickets' => db()->query("SELECT COUNT(*) FROM tickets WHERE status != 'closed'")->fetchColumn(),
'users_count' => db()->query("SELECT COUNT(*) FROM users")->fetchColumn(),
'helpers_count' => db()->query("SELECT COUNT(*) FROM users WHERE role = 'helper'")->fetchColumn()
];
echo json_encode($stats);

View File

@ -1,91 +1,2 @@
<?php <?php
require_once __DIR__ . '/../db/config.php'; echo "Telegram webhook not implemented.";
require_once __DIR__ . '/../ai/LocalAIApi.php';
// Get Telegram Update
$content = file_get_contents("php://input");
$update = json_decode($content, true);
if (!$update || !isset($update['message'])) {
exit;
}
$message = $update['message'];
$chatId = $message['chat']['id'];
$text = $message['text'] ?? '';
if (empty($text)) {
exit;
}
// Get Telegram Token from DB
$stmt = db()->query("SELECT setting_value FROM settings WHERE setting_key = 'telegram_token'");
$token = $stmt->fetchColumn();
if (!$token) {
error_log("Telegram Error: No bot token found in settings.");
exit;
}
function sendTelegramMessage($chatId, $text, $token) {
$url = "https://api.telegram.org/bot$token/sendMessage";
$data = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'Markdown'
];
$options = [
'http' => [
'header' => "Content-type: application/x-www-form-urlencoded\r\n",
'method' => 'POST',
'content' => http_build_query($data),
],
];
$context = stream_context_create($options);
return file_get_contents($url, false, $context);
}
// Process with AI (Similar logic to api/chat.php)
try {
// 1. Fetch Knowledge Base
$stmt = db()->query("SELECT keywords, answer FROM faqs");
$faqs = $stmt->fetchAll(PDO::FETCH_ASSOC);
$knowledgeBase = "Here is the knowledge base for this website:\n\n";
foreach ($faqs as $faq) {
$knowledgeBase .= "Q: " . $faq['keywords'] . "\nA: " . $faq['answer'] . "\n---\n";
}
$systemPrompt = "You are a helpful AI assistant integrated with Telegram. " .
"Use the provided Knowledge Base to answer user questions. " .
"Keep answers concise for mobile reading. Use Markdown for formatting.\n\n" .
$knowledgeBase;
// 2. Call AI
$response = LocalAIApi::createResponse([
'model' => 'gpt-4o-mini',
'input' => [
['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => $text],
]
]);
if (!empty($response['success'])) {
$aiReply = LocalAIApi::extractText($response);
// 3. Save History
try {
$stmt = db()->prepare("INSERT INTO messages (user_message, ai_response) VALUES (?, ?)");
$stmt->execute(["[Telegram] " . $text, $aiReply]);
} catch (Exception $e) {}
// 4. Send back to Telegram
sendTelegramMessage($chatId, $aiReply, $token);
} else {
sendTelegramMessage($chatId, "I'm sorry, I encountered an error processing your request.", $token);
}
} catch (Exception $e) {
error_log("Telegram Webhook Error: " . $e->getMessage());
}

65
api/update_ticket.php Normal file
View File

@ -0,0 +1,65 @@
<?php
require_once __DIR__ . '/../auth.php';
$user = requireAuth();
$input = json_decode(file_get_contents('php://input'), true);
if (!$input) {
// Fallback to traditional POST
$input = $_POST;
}
$ticketId = $input['ticket_id'] ?? null;
$status = $input['status'] ?? null;
$priority = $input['priority'] ?? null;
$helperId = $input['helper_id'] ?? null;
if (!$ticketId) {
echo json_encode(['error' => 'Missing ticket_id']);
exit;
}
// Fetch ticket
$stmt = db()->prepare("SELECT * FROM tickets WHERE id = ?");
$stmt->execute([$ticketId]);
$ticket = $stmt->fetch();
if (!$ticket) {
echo json_encode(['error' => 'Ticket not found']);
exit;
}
// Check access
if ($user['role'] === 'user' && $ticket['user_id'] != $user['id']) {
echo json_encode(['error' => 'Access denied']);
exit;
}
// For users, only closing is allowed
if ($user['role'] === 'user') {
if ($status === 'closed') {
$stmt = db()->prepare("UPDATE tickets SET status = 'closed' WHERE id = ?");
$stmt->execute([$ticketId]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['error' => 'Only closing is allowed for users']);
}
exit;
}
// For helpers and curators, allow everything
if ($status) {
$stmt = db()->prepare("UPDATE tickets SET status = ? WHERE id = ?");
$stmt->execute([$status, $ticketId]);
}
if ($priority) {
$stmt = db()->prepare("UPDATE tickets SET priority = ? WHERE id = ?");
$stmt->execute([$priority, $ticketId]);
}
if ($helperId !== null && $user['role'] === 'curator') {
$stmt = db()->prepare("UPDATE tickets SET helper_id = ? WHERE id = ?");
$stmt->execute([$helperId == 0 ? null : $helperId, $ticketId]);
}
echo json_encode(['success' => true]);

View File

@ -1,279 +1,89 @@
body { :root {
background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); --bg-dark: #0f172a;
background-size: 400% 400%; --card-bg: #1e293b;
animation: gradient 15s ease infinite; --primary: #3b82f6;
color: #212529; --primary-hover: #2563eb;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --text-primary: #f8fafc;
font-size: 14px; --text-secondary: #94a3b8;
margin: 0; --border-color: #334155;
min-height: 100vh; --error: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
} }
.main-wrapper { * {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
width: 100%;
padding: 20px;
box-sizing: border-box; box-sizing: border-box;
position: relative; margin: 0;
z-index: 1; padding: 0;
} }
@keyframes gradient { body {
0% { font-family: 'Inter', sans-serif;
background-position: 0% 50%; background-color: var(--bg-dark);
} color: var(--text-primary);
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.chat-container {
width: 100%;
max-width: 600px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
display: flex;
flex-direction: column;
height: 85vh;
box-shadow: 0 20px 40px rgba(0,0,0,0.2);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
overflow: hidden;
}
.chat-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: rgba(255, 255, 255, 0.5);
font-weight: 700;
font-size: 1.1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.message {
max-width: 85%;
padding: 0.85rem 1.1rem;
border-radius: 16px;
line-height: 1.5; line-height: 1.5;
font-size: 0.95rem; min-height: 100vh;
box-shadow: 0 4px 15px rgba(0,0,0,0.05); overflow-x: hidden;
animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
} }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px) scale(0.95); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.message.visitor {
align-self: flex-end;
background: linear-gradient(135deg, #212529 0%, #343a40 100%);
color: #fff;
border-bottom-right-radius: 4px;
}
.message.bot {
align-self: flex-start;
background: #ffffff;
color: #212529;
border-bottom-left-radius: 4px;
}
.chat-input-area {
padding: 1.25rem;
background: rgba(255, 255, 255, 0.5);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.chat-input-area form {
display: flex;
gap: 0.75rem;
}
.chat-input-area input {
flex: 1;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
outline: none;
background: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
}
.chat-input-area input:focus {
border-color: #23a6d5;
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2);
}
.chat-input-area button {
background: #212529;
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 12px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
}
.chat-input-area button:hover {
background: #000;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
}
/* Background Animations */
.bg-animations { .bg-animations {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 0; z-index: -1;
overflow: hidden; overflow: hidden;
pointer-events: none;
} }
.blob { .blob {
position: absolute; position: absolute;
width: 500px; width: 500px;
height: 500px; height: 500px;
background: rgba(255, 255, 255, 0.2); background: radial-gradient(circle, rgba(59, 130, 246, 0.15) 0%, transparent 70%);
border-radius: 50%; border-radius: 50%;
filter: blur(80px); filter: blur(40px);
animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1);
} }
.blob-1 { .blob-1 { top: -100px; left: -100px; animation: float 20s infinite alternate; }
top: -10%; .blob-2 { bottom: -100px; right: -100px; animation: float 25s infinite alternate-reverse; }
left: -10%; .blob-3 { top: 50%; left: 50%; transform: translate(-50%, -50%); opacity: 0.5; }
background: rgba(238, 119, 82, 0.4);
@keyframes float {
0% { transform: translate(0, 0); }
100% { transform: translate(100px, 50px); }
} }
.blob-2 { .main-wrapper {
bottom: -10%; max-width: 1200px;
right: -10%; margin: 0 auto;
background: rgba(35, 166, 213, 0.4); padding: 2rem;
animation-delay: -7s; min-height: 100vh;
width: 600px;
height: 600px;
} }
.blob-3 { /* Auth Pages */
top: 40%; .auth-page {
left: 30%; display: flex;
background: rgba(231, 60, 126, 0.3); align-items: center;
animation-delay: -14s; justify-content: center;
width: 450px;
height: 450px;
} }
@keyframes move { .auth-card {
0% { transform: translate(0, 0) rotate(0deg) scale(1); } background-color: var(--card-bg);
33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); }
66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); }
100% { transform: translate(0, 0) rotate(360deg) scale(1); }
}
.admin-link {
font-size: 14px;
color: #fff;
text-decoration: none;
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 1rem;
border-radius: 8px;
transition: all 0.3s ease;
}
.admin-link:hover {
background: rgba(0, 0, 0, 0.4);
text-decoration: none;
}
/* Admin Styles */
.admin-container {
max-width: 900px;
margin: 3rem auto;
padding: 2.5rem; padding: 2.5rem;
background: rgba(255, 255, 255, 0.85); border-radius: 1rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(0,0,0,0.15);
border: 1px solid rgba(255, 255, 255, 0.4);
position: relative;
z-index: 1;
}
.admin-container h1 {
margin-top: 0;
color: #212529;
font-weight: 800;
}
.table {
width: 100%; width: 100%;
border-collapse: separate; max-width: 400px;
border-spacing: 0 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
margin-top: 1.5rem; border: 1px solid var(--border-color);
} }
.table th { .auth-card h2 {
background: transparent; margin-bottom: 1.5rem;
border: none; text-align: center;
padding: 1rem; font-size: 1.875rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
} }
.table td {
background: #fff;
padding: 1rem;
border: none;
}
.table tr td:first-child { border-radius: 12px 0 0 12px; }
.table tr td:last-child { border-radius: 0 12px 12px 0; }
.form-group { .form-group {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
@ -281,22 +91,314 @@ body {
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 600; color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.875rem;
} }
.form-control { .form-group input, .form-group select, .form-group textarea {
width: 100%; width: 100%;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 0.5rem;
border-radius: 12px; border: 1px solid var(--border-color);
background: #fff; background-color: var(--bg-dark);
transition: all 0.3s ease; color: var(--text-primary);
box-sizing: border-box; outline: none;
transition: border-color 0.2s;
} }
.form-control:focus { .form-group input:focus {
border-color: var(--primary);
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background-color: var(--primary);
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary:hover {
background-color: var(--primary-hover);
}
.auth-footer {
margin-top: 1.5rem;
text-align: center;
font-size: 0.875rem;
color: var(--text-secondary);
}
.auth-footer a {
color: var(--primary);
text-decoration: none;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1.25rem;
font-size: 0.875rem;
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
color: var(--error);
border: 1px solid var(--error);
}
.alert-success {
background-color: rgba(34, 197, 94, 0.1);
color: var(--success);
border: 1px solid var(--success);
}
/* Dashboard / Index */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
}
.logo {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary);
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info span {
color: var(--text-secondary);
}
.logout-link {
color: var(--error);
text-decoration: none;
font-size: 0.875rem;
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.ticket-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.ticket-card {
background-color: var(--card-bg);
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid var(--border-color);
transition: transform 0.2s, box-shadow 0.2s;
text-decoration: none;
color: inherit;
display: block;
}
.ticket-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.ticket-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
margin-bottom: 0.75rem;
}
.badge-open { background-color: rgba(59, 130, 246, 0.1); color: #60a5fa; }
.badge-in_progress { background-color: rgba(245, 158, 11, 0.1); color: #fbbf24; }
.badge-awaiting_response { background-color: rgba(139, 92, 246, 0.1); color: #a78bfa; }
.badge-closed { background-color: rgba(148, 163, 184, 0.1); color: #94a3b8; }
.ticket-card h3 {
margin-bottom: 0.5rem;
font-size: 1.125rem;
}
.ticket-card p {
color: var(--text-secondary);
font-size: 0.875rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ticket-meta {
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Chat Styles */
.ticket-detail-header {
margin-bottom: 2rem;
}
.chat-box {
background-color: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
height: 600px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
max-width: 80%;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
position: relative;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-info {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.message.mine {
align-self: flex-end;
background-color: var(--primary);
color: white;
}
.message.other {
align-self: flex-start;
background-color: var(--bg-dark);
border: 1px solid var(--border-color);
}
.chat-input-area {
padding: 1rem;
border-top: 1px solid var(--border-color);
}
.chat-form {
display: flex;
gap: 0.75rem;
}
.chat-input {
flex: 1;
background-color: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
color: var(--text-primary);
outline: none; outline: none;
border-color: #23a6d5; }
box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1);
} .file-upload-btn {
background-color: var(--bg-dark);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 0.75rem;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
/* Modal */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: var(--card-bg);
padding: 2rem;
border-radius: 1rem;
width: 90%;
max-width: 500px;
border: 1px solid var(--border-color);
}
/* Statistics Dashboard */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2.5rem;
}
.stat-card {
background-color: var(--card-bg);
padding: 1.5rem;
border-radius: 0.75rem;
border: 1px solid var(--border-color);
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary);
margin-bottom: 0.5rem;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.875rem;
}
/* Responsive */
@media (max-width: 768px) {
.main-wrapper {
padding: 1rem;
}
.ticket-grid {
grid-template-columns: 1fr;
}
}

73
auth.php Normal file
View File

@ -0,0 +1,73 @@
<?php
session_start();
require_once __DIR__ . '/db/config.php';
function getCurrentUser() {
if (isset($_SESSION['user_id'])) {
$stmt = db()->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_SESSION['user_id']]);
return $stmt->fetch();
}
return null;
}
function requireAuth() {
$user = getCurrentUser();
if (!$user) {
header('Location: login.php');
exit;
}
if ($user['status'] === 'blocked') {
session_destroy();
die('Ваш аккаунт заблокирован.');
}
return $user;
}
function requireRole($role) {
$user = requireAuth();
if (is_array($role)) {
if (!in_array($user['role'], $role)) {
die('Доступ запрещен.');
}
} else {
if ($user['role'] !== $role) {
die('Доступ запрещен.');
}
}
return $user;
}
function login($username, $password) {
$stmt = db()->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
if ($user['status'] === 'blocked') {
return ['error' => 'Ваш аккаунт заблокирован.'];
}
$_SESSION['user_id'] = $user['id'];
return ['success' => true];
}
return ['error' => 'Неверное имя пользователя или пароль.'];
}
function register($username, $password) {
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
try {
$stmt = db()->prepare("INSERT INTO users (username, password_hash) VALUES (?, ?)");
$stmt->execute([$username, $passwordHash]);
return ['success' => true];
} catch (PDOException $e) {
if ($e->getCode() == 23000) {
return ['error' => 'Имя пользователя уже занято.'];
}
return ['error' => 'Ошибка при регистрации.'];
}
}
function logout() {
session_destroy();
header('Location: login.php');
exit;
}

81
create_ticket.php Normal file
View File

@ -0,0 +1,81 @@
<?php
require_once __DIR__ . '/auth.php';
$user = requireAuth();
if ($user['role'] !== 'user') {
header('Location: index.php');
exit;
}
// Check open tickets limit (max 3)
$stmt = db()->prepare("SELECT COUNT(*) FROM tickets WHERE user_id = ? AND status != 'closed'");
$stmt->execute([$user['id']]);
$openTicketsCount = $stmt->fetchColumn();
if ($openTicketsCount >= 3) {
die('У вас уже 3 открытых тикета. Пожалуйста, закройте один из них, прежде чем открывать новый.');
}
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title']);
$description = trim($_POST['description']);
$category = trim($_POST['category']);
if (empty($title) || empty($description)) {
$error = 'Пожалуйста, заполните все обязательные поля.';
} else {
$stmt = db()->prepare("INSERT INTO tickets (user_id, title, description, category) VALUES (?, ?, ?, ?)");
$stmt->execute([$user['id'], $title, $description, $category]);
header('Location: index.php');
exit;
}
}
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Создать тикет - Система поддержки</title>
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="bg-animations">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<div class="main-wrapper">
<div class="auth-card" style="max-width: 600px; margin: 0 auto;">
<h2>Новый тикет</h2>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST">
<div class="form-group">
<label for="title">Тема</label>
<input type="text" name="title" id="title" required placeholder="Например: Не работает вход в систему">
</div>
<div class="form-group">
<label for="category">Категория</label>
<select name="category" id="category">
<option value="technical">Техническая проблема</option>
<option value="question">Вопрос</option>
<option value="complaint">Жалоба</option>
<option value="other">Другое</option>
</select>
</div>
<div class="form-group">
<label for="description">Описание</label>
<textarea name="description" id="description" rows="6" required placeholder="Опишите проблему как можно подробнее..."></textarea>
</div>
<button type="submit" class="btn-primary">Отправить тикет</button>
<a href="index.php" class="auth-footer" style="display: block; margin-top: 1rem; color: var(--text-secondary);">Отмена</a>
</form>
</div>
</div>
</body>
</html>

View File

@ -1,17 +1,22 @@
<?php <?php
// Generated by setup_mariadb_project.sh — edit as needed. // Database configuration
define('DB_HOST', '127.0.0.1'); define('DB_HOST', '127.0.0.1');
define('DB_NAME', 'app_38384'); define('DB_NAME', 'app_38916');
define('DB_USER', 'app_38384'); define('DB_USER', 'app_38916');
define('DB_PASS', '5561099f-23a3-43d8-b1a2-54739c50721b'); define('DB_PASS', 'new_secure_password');
function db() { function db() {
static $pdo; static $pdo;
if (!$pdo) { if (!$pdo) {
$pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [ try {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, $pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]); PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
error_log($e->getMessage());
die('Database connection failed.');
}
} }
return $pdo; return $pdo;
} }

40
db/migrations/init.sql Normal file
View File

@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('curator', 'helper', 'user') DEFAULT 'user',
status ENUM('active', 'blocked') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
helper_id INT,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
category VARCHAR(255),
priority ENUM('low', 'medium', 'high') DEFAULT 'medium',
status ENUM('open', 'in_progress', 'awaiting_response', 'closed') DEFAULT 'open',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (helper_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS messages (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
user_id INT NOT NULL,
message TEXT,
file_path VARCHAR(255),
file_name VARCHAR(255),
file_type VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Seed initial curator
-- Password is 'admin123'
INSERT IGNORE INTO users (username, password_hash, role) VALUES ('admin', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', 'curator');

3
healthz.php Normal file
View File

@ -0,0 +1,3 @@
<?php
http_response_code(200);
echo "OK";

212
index.php
View File

@ -1,25 +1,100 @@
<?php <?php
require_once __DIR__ . '/db/config.php'; require_once __DIR__ . '/auth.php';
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? 'Modern AI-ready Chat Assistant'; $user = requireAuth();
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
// Filters for Helpers and Curators
$statusFilter = $_GET['status'] ?? '';
$categoryFilter = $_GET['category'] ?? '';
$searchQuery = $_GET['search'] ?? '';
$params = [];
$sql = "SELECT t.*, u.username as creator_name FROM tickets t JOIN users u ON t.user_id = u.id";
$whereClauses = [];
if ($user['role'] === 'user') {
$whereClauses[] = "t.user_id = ?";
$params[] = $user['id'];
} else {
if ($statusFilter) {
$whereClauses[] = "t.status = ?";
$params[] = $statusFilter;
}
if ($categoryFilter) {
$whereClauses[] = "t.category = ?";
$params[] = $categoryFilter;
}
if ($searchQuery) {
$whereClauses[] = "(t.title LIKE ? OR t.description LIKE ?)";
$params[] = "%$searchQuery%";
$params[] = "%$searchQuery%";
}
}
if (!empty($whereClauses)) {
$sql .= " WHERE " . implode(" AND ", $whereClauses);
}
$sql .= " ORDER BY t.created_at DESC";
$stmt = db()->prepare($sql);
$stmt->execute($params);
$tickets = $stmt->fetchAll();
$openTicketsCount = 0;
if ($user['role'] === 'user') {
foreach ($tickets as $t) {
if ($t['status'] !== 'closed') $openTicketsCount++;
}
}
// Stats for Curator
$stats = [];
if ($user['role'] === 'curator') {
$stats['total'] = db()->query("SELECT COUNT(*) FROM tickets")->fetchColumn();
$stats['active'] = db()->query("SELECT COUNT(*) FROM tickets WHERE status != 'closed'")->fetchColumn();
// Avg response time (simplified: difference between ticket creation and first message from a helper/curator)
$stats['avg_response'] = db()->query("
SELECT ROUND(AVG(TIMESTAMPDIFF(HOUR, t.created_at, m.created_at)), 1)
FROM tickets t
JOIN messages m ON t.id = m.ticket_id
JOIN users u ON m.user_id = u.id
WHERE u.role != 'user'
AND m.id = (
SELECT MIN(m2.id)
FROM messages m2
JOIN users u2 ON m2.user_id = u2.id
WHERE m2.ticket_id = t.id AND u2.role != 'user'
)
")->fetchColumn() ?: '...';
}
function getStatusLabel($status) {
$labels = [
'open' => 'Открыт',
'in_progress' => 'В работе',
'awaiting_response' => 'Ожидает ответа',
'closed' => 'Закрыт'
];
return $labels[$status] ?? $status;
}
function getPriorityLabel($priority) {
$labels = [
'low' => 'Низкий',
'medium' => 'Средний',
'high' => 'Высокий'
];
return $labels[$priority] ?? $priority;
}
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chat Assistant</title> <title>Панель управления - Система поддержки</title>
<?php if ($projectDescription): ?>
<meta name="description" content="<?= htmlspecialchars($projectDescription) ?>">
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>">
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>">
<?php endif; ?>
<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;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>"> <link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div class="bg-animations"> <div class="bg-animations">
@ -27,23 +102,104 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
<div class="blob blob-2"></div> <div class="blob blob-2"></div>
<div class="blob blob-3"></div> <div class="blob blob-3"></div>
</div> </div>
<div class="main-wrapper"> <div class="main-wrapper">
<div class="chat-container"> <nav class="navbar">
<div class="chat-header"> <div class="logo">SupportSystem</div>
<span>Chat Assistant</span> <div class="user-info">
<a href="admin.php" class="admin-link">Admin</a> <span><?= htmlspecialchars($user['username']) ?> (<?= $user['role'] ?>)</span>
<a href="logout.php" class="logout-link">Выйти</a>
</div> </div>
<div class="chat-messages" id="chat-messages"> </nav>
<div class="message bot">
Hello! I'm your assistant. How can I help you today? <?php if ($user['role'] === 'curator'): ?>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value"><?= $stats['total'] ?></div>
<div class="stat-label">Всего тикетов</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= $stats['active'] ?></div>
<div class="stat-label">Активных</div>
</div>
<div class="stat-card">
<div class="stat-value"><?= $stats['avg_response'] ?> ч</div>
<div class="stat-label">Ср. время ответа</div>
</div>
<div class="stat-card">
<a href="admin.php" style="text-decoration: none;">
<div class="stat-value">👥</div>
<div class="stat-label">Управление пользователями</div>
</a>
</div> </div>
</div> </div>
<div class="chat-input-area"> <?php endif; ?>
<form id="chat-form">
<input type="text" id="chat-input" placeholder="Type your message..." autocomplete="off"> <div class="dashboard-header">
<button type="submit">Send</button> <h1>Тикеты</h1>
</form> <?php if ($user['role'] === 'user'): ?>
</div> <?php if ($openTicketsCount < 3): ?>
<a href="create_ticket.php" class="btn-primary" style="padding: 0.5rem 1rem; width: auto; text-decoration: none;">Создать тикет</a>
<?php else: ?>
<span class="text-secondary" style="font-size: 0.875rem;">Максимум 3 открытых тикета</span>
<?php endif; ?>
<?php endif; ?>
</div>
<?php if ($user['role'] !== 'user'): ?>
<form class="filters-area" style="background-color: var(--card-bg); padding: 1rem; border-radius: 0.75rem; border: 1px solid var(--border-color); margin-bottom: 1.5rem; display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end;">
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 200px;">
<label>Поиск</label>
<input type="text" name="search" value="<?= htmlspecialchars($searchQuery) ?>" placeholder="По теме или описанию...">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label>Статус</label>
<select name="status">
<option value="">Все</option>
<option value="open" <?= $statusFilter === 'open' ? 'selected' : '' ?>>Открыт</option>
<option value="in_progress" <?= $statusFilter === 'in_progress' ? 'selected' : '' ?>>В работе</option>
<option value="awaiting_response" <?= $statusFilter === 'awaiting_response' ? 'selected' : '' ?>>Ожидает ответа</option>
<option value="closed" <?= $statusFilter === 'closed' ? 'selected' : '' ?>>Закрыт</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label>Категория</label>
<select name="category">
<option value="">Все</option>
<option value="technical" <?= $categoryFilter === 'technical' ? 'selected' : '' ?>>Техническая</option>
<option value="question" <?= $categoryFilter === 'question' ? 'selected' : '' ?>>Вопрос</option>
<option value="complaint" <?= $categoryFilter === 'complaint' ? 'selected' : '' ?>>Жалоба</option>
<option value="other" <?= $categoryFilter === 'other' ? 'selected' : '' ?>>Другое</option>
</select>
</div>
<button type="submit" class="btn-primary" style="width: auto; height: 42px; padding: 0 1rem;">Найти</button>
<a href="index.php" style="color: var(--text-secondary); text-decoration: none; padding-bottom: 0.5rem;">Сбросить</a>
</form>
<?php endif; ?>
<div class="ticket-grid">
<?php if (empty($tickets)): ?>
<p class="text-secondary">Тикетов не найдено.</p>
<?php else: ?>
<?php foreach ($tickets as $ticket): ?>
<a href="ticket.php?id=<?= $ticket['id'] ?>" class="ticket-card">
<div class="ticket-badge badge-<?= $ticket['status'] ?>">
<?= getStatusLabel($ticket['status']) ?>
</div>
<h3><?= htmlspecialchars($ticket['title']) ?></h3>
<p><?= htmlspecialchars($ticket['description']) ?></p>
<div class="ticket-meta">
<span>Приоритет: <?= getPriorityLabel($ticket['priority']) ?></span>
<span><?= date('d.m.Y H:i', strtotime($ticket['created_at'])) ?></span>
</div>
<?php if (isset($ticket['creator_name'])): ?>
<div class="ticket-meta" style="margin-top: 0.5rem;">
<span>От: <?= htmlspecialchars($ticket['creator_name']) ?></span>
</div>
<?php endif; ?>
</a>
<?php endforeach; ?>
<?php endif; ?>
</div> </div>
</div> </div>

55
login.php Normal file
View File

@ -0,0 +1,55 @@
<?php
require_once __DIR__ . '/auth.php';
if (isset($_SESSION['user_id'])) {
header('Location: index.php');
exit;
}
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$res = login($_POST['username'], $_POST['password']);
if (!empty($res['success'])) {
header('Location: index.php');
exit;
}
$error = $res['error'] ?? 'Неизвестная ошибка';
}
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Вход - Система поддержки</title>
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
</head>
<body class="auth-page">
<div class="bg-animations">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<div class="main-wrapper">
<div class="auth-card">
<h2>Вход</h2>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST">
<div class="form-group">
<label for="username">Имя пользователя</label>
<input type="text" name="username" id="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" name="password" id="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn-primary">Войти</button>
</form>
<p class="auth-footer">Нет аккаунта? <a href="register.php">Зарегистрироваться</a></p>
</div>
</div>
</body>
</html>

3
logout.php Normal file
View File

@ -0,0 +1,3 @@
<?php
require_once __DIR__ . '/auth.php';
logout();

56
register.php Normal file
View File

@ -0,0 +1,56 @@
<?php
require_once __DIR__ . '/auth.php';
if (isset($_SESSION['user_id'])) {
header('Location: index.php');
exit;
}
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$res = register($_POST['username'], $_POST['password']);
if (!empty($res['success'])) {
login($_POST['username'], $_POST['password']);
header('Location: index.php');
exit;
}
$error = $res['error'] ?? 'Неизвестная ошибка';
}
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Регистрация - Система поддержки</title>
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
</head>
<body class="auth-page">
<div class="bg-animations">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<div class="main-wrapper">
<div class="auth-card">
<h2>Регистрация</h2>
<?php if ($error): ?>
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="POST">
<div class="form-group">
<label for="username">Имя пользователя</label>
<input type="text" name="username" id="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">Пароль</label>
<input type="password" name="password" id="password" required autocomplete="new-password">
</div>
<button type="submit" class="btn-primary">Зарегистрироваться</button>
</form>
<p class="auth-footer">Уже есть аккаунт? <a href="login.php">Войти</a></p>
</div>
</div>
</body>
</html>

264
ticket.php Normal file
View File

@ -0,0 +1,264 @@
<?php
require_once __DIR__ . '/auth.php';
$user = requireAuth();
$ticketId = $_GET['id'] ?? null;
if (!$ticketId) {
header('Location: index.php');
exit;
}
// Fetch ticket
$stmt = db()->prepare("SELECT t.*, u.username as creator_name, h.username as helper_name FROM tickets t JOIN users u ON t.user_id = u.id LEFT JOIN users h ON t.helper_id = h.id WHERE t.id = ?");
$stmt->execute([$ticketId]);
$ticket = $stmt->fetch();
if (!$ticket) {
die('Тикет не найден.');
}
// Check access
if ($user['role'] === 'user' && $ticket['user_id'] != $user['id']) {
die('Доступ запрещен.');
}
// Fetch helpers for curator
$helpers = [];
if ($user['role'] === 'curator') {
$helpers = db()->query("SELECT id, username FROM users WHERE role IN ('helper', 'curator')")->fetchAll();
}
function getStatusLabel($status) {
$labels = [
'open' => 'Открыт',
'in_progress' => 'В работе',
'awaiting_response' => 'Ожидает ответа',
'closed' => 'Закрыт'
];
return $labels[$status] ?? $status;
}
function getPriorityLabel($priority) {
$labels = [
'low' => 'Низкий',
'medium' => 'Средний',
'high' => 'Высокий'
];
return $labels[$priority] ?? $priority;
}
?>
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Тикет #<?= $ticket['id'] ?> - <?= htmlspecialchars($ticket['title']) ?></title>
<link rel="stylesheet" href="assets/css/custom.css?v=<?php echo time(); ?>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div class="bg-animations">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<div class="main-wrapper">
<nav class="navbar">
<a href="index.php" class="logo" style="text-decoration: none;">SupportSystem</a>
<div class="user-info">
<span><?= htmlspecialchars($user['username']) ?> (<?= $user['role'] ?>)</span>
<a href="logout.php" class="logout-link">Выйти</a>
</div>
</nav>
<div class="ticket-detail-header" style="background-color: var(--card-bg); padding: 2rem; border-radius: 1rem; border: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; flex-wrap: wrap; gap: 1.5rem;">
<div style="flex: 1; min-width: 300px;">
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem;">
<div class="ticket-badge badge-<?= $ticket['status'] ?>">
<?= getStatusLabel($ticket['status']) ?>
</div>
<span style="color: var(--text-secondary); font-size: 0.875rem;">Категория: <?= htmlspecialchars($ticket['category'] ?: 'Не указана') ?></span>
</div>
<h1>#<?= $ticket['id'] ?> <?= htmlspecialchars($ticket['title']) ?></h1>
<p class="text-secondary" style="margin-top: 1rem; font-size: 1.125rem;"><?= nl2br(htmlspecialchars($ticket['description'])) ?></p>
</div>
<div style="min-width: 250px; background: var(--bg-dark); padding: 1.5rem; border-radius: 0.75rem; border: 1px solid var(--border-color);">
<div class="form-group">
<label>Приоритет</label>
<?php if ($user['role'] !== 'user'): ?>
<select onchange="updateTicket({priority: this.value})" style="padding: 0.5rem;">
<option value="low" <?= $ticket['priority'] === 'low' ? 'selected' : '' ?>>Низкий</option>
<option value="medium" <?= $ticket['priority'] === 'medium' ? 'selected' : '' ?>>Средний</option>
<option value="high" <?= $ticket['priority'] === 'high' ? 'selected' : '' ?>>Высокий</option>
</select>
<?php else: ?>
<div style="font-weight: 600; color: var(--primary);"><?= getPriorityLabel($ticket['priority']) ?></div>
<?php endif; ?>
</div>
<?php if ($user['role'] === 'curator'): ?>
<div class="form-group">
<label>Ответственный</label>
<select onchange="updateTicket({helper_id: this.value})" style="padding: 0.5rem;">
<option value="0">Не назначен</option>
<?php foreach ($helpers as $h): ?>
<option value="<?= $h['id'] ?>" <?= $ticket['helper_id'] == $h['id'] ? 'selected' : '' ?>><?= htmlspecialchars($h['username']) ?></option>
<?php endforeach; ?>
</select>
</div>
<?php elseif ($ticket['helper_name']): ?>
<div class="form-group" style="margin-bottom: 0;">
<label>Ответственный</label>
<div style="color: var(--text-primary);"><?= htmlspecialchars($ticket['helper_name']) ?></div>
</div>
<?php endif; ?>
</div>
</div>
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
<div class="ticket-meta" style="margin-top: 0;">
<span>Создано: <?= date('d.m.Y H:i', strtotime($ticket['created_at'])) ?></span>
<span>От: <?= htmlspecialchars($ticket['creator_name']) ?></span>
</div>
<div style="display: flex; gap: 0.5rem;">
<?php if ($user['role'] !== 'user' && $ticket['status'] !== 'closed'): ?>
<?php if ($ticket['status'] === 'open'): ?>
<button class="btn-primary" onclick="updateTicket({status: 'in_progress'})" style="width: auto; padding: 0.5rem 1rem;">В работу</button>
<?php endif; ?>
<button class="btn-primary" onclick="updateTicket({status: 'awaiting_response'})" style="width: auto; padding: 0.5rem 1rem; background-color: var(--warning);">Ожидать ответа</button>
<?php endif; ?>
<?php if ($ticket['status'] !== 'closed'): ?>
<button class="btn-primary" onclick="updateTicket({status: 'closed'})" style="width: auto; padding: 0.5rem 1rem; background-color: var(--error);">Закрыть тикет</button>
<?php endif; ?>
</div>
</div>
</div>
<div class="chat-box" style="margin-top: 2rem;">
<div class="chat-messages" id="chat-messages">
<!-- Messages will be loaded here via JS -->
</div>
<?php if ($ticket['status'] !== 'closed'): ?>
<div class="chat-input-area">
<form id="message-form" class="chat-form" enctype="multipart/form-data">
<input type="hidden" name="ticket_id" value="<?= $ticket['id'] ?>">
<input type="text" name="message" id="message-input" class="chat-input" placeholder="Напишите сообщение..." autocomplete="off">
<label for="file-upload" class="file-upload-btn" title="Прикрепить файл (до 50МБ)">
📎
<input type="file" id="file-upload" name="file" style="display: none;" onchange="updateFileName(this)">
</label>
<button type="submit" class="btn-primary" style="width: auto; padding: 0 1.5rem;">Отправить</button>
</form>
<div id="file-name" class="text-secondary" style="font-size: 0.75rem; margin-top: 0.25rem;"></div>
</div>
<?php else: ?>
<div class="chat-input-area" style="text-align: center; color: var(--text-secondary); background: rgba(0,0,0,0.2);">
Тикет закрыт. Общение невозможно.
</div>
<?php endif; ?>
</div>
</div>
<script>
const ticketId = <?= $ticket['id'] ?>;
const currentUserId = <?= $user['id'] ?>;
let lastStatus = "<?= $ticket['status'] ?>";
function updateFileName(input) {
const fileName = input.files[0] ? input.files[0].name : '';
document.getElementById('file-name').textContent = fileName ? 'Файл: ' + fileName : '';
}
async function loadMessages() {
try {
const res = await fetch(`api/messages.php?ticket_id=${ticketId}`);
const messages = await res.json();
if (messages.error) return;
const container = document.getElementById('chat-messages');
const isAtBottom = container.scrollHeight - container.scrollTop <= container.clientHeight + 100;
container.innerHTML = messages.map(msg => `
<div class="message ${msg.user_id == currentUserId ? 'mine' : 'other'}">
<div class="message-info">
${msg.username} (${msg.role === 'user' ? 'Пользователь' : (msg.role === 'helper' ? 'Помощник' : 'Куратор')}) ${new Date(msg.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</div>
<div class="message-text">${msg.message || ''}</div>
${msg.file_path ? `
<div class="message-file" style="margin-top: 0.5rem;">
${msg.file_type && msg.file_type.startsWith('image/') ?
`<a href="${msg.file_path}" target="_blank"><img src="${msg.file_path}" style="max-width: 100%; max-height: 300px; border-radius: 0.5rem; margin-top: 0.25rem; border: 1px solid rgba(255,255,255,0.1);"></a>` :
`<a href="${msg.file_path}" target="_blank" style="color: white; background: rgba(0,0,0,0.2); padding: 0.5rem; border-radius: 0.5rem; display: inline-block; text-decoration: none;">📎 ${msg.file_name}</a>`
}
</div>
` : ''}
</div>
`).join('');
if (isAtBottom) {
container.scrollTop = container.scrollHeight;
}
} catch (e) { console.error(e); }
}
document.getElementById('message-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const messageInput = document.getElementById('message-input');
const fileInput = document.getElementById('file-upload');
const submitBtn = e.target.querySelector('button[type="submit"]');
if (!messageInput.value.trim() && !fileInput.files.length) return;
submitBtn.disabled = true;
submitBtn.textContent = '...';
const res = await fetch('api/send_message.php', {
method: 'POST',
body: formData
});
const result = await res.json();
submitBtn.disabled = false;
submitBtn.textContent = 'Отправить';
if (result.success) {
messageInput.value = '';
fileInput.value = '';
document.getElementById('file-name').textContent = '';
loadMessages();
} else {
alert(result.error || 'Ошибка при отправке сообщения');
}
});
async function updateTicket(data) {
const res = await fetch('api/update_ticket.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket_id: ticketId, ...data })
});
const result = await res.json();
if (result.success) {
location.reload();
} else {
alert(result.error || 'Ошибка при обновлении');
}
}
// Polling for new messages
setInterval(loadMessages, 3000);
loadMessages();
// Scroll to bottom on load
setTimeout(() => {
const container = document.getElementById('chat-messages');
container.scrollTop = container.scrollHeight;
}, 500);
</script>
</body>
</html>