diff --git a/admin.php b/admin.php index 4c4acce..e532921 100644 --- a/admin.php +++ b/admin.php @@ -1,68 +1,30 @@ prepare("INSERT INTO faqs (keywords, answer) VALUES (?, ?)"); - $stmt->execute([$keywords, $answer]); - } - } elseif (isset($_POST['action']) && $_POST['action'] === 'delete') { - $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]); +$users = db()->query("SELECT * FROM users ORDER BY created_at DESC")->fetchAll(); + +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { + if ($_POST['action'] === 'change_role') { + $stmt = db()->prepare("UPDATE users SET role = ? WHERE id = ?"); + $stmt->execute([$_POST['role'], $_POST['user_id']]); + } elseif ($_POST['action'] === 'toggle_status') { + $newStatus = $_POST['status'] === 'active' ? 'blocked' : 'active'; + $stmt = db()->prepare("UPDATE users SET status = ? WHERE id = ?"); + $stmt->execute([$newStatus, $_POST['user_id']]); } - header("Location: admin.php"); + header('Location: admin.php'); 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']; -} ?> - + - Admin - FAQ Manager - - + Админ-панель - Система поддержки - +
@@ -70,97 +32,65 @@ if ($row) {
-
-
-

FAQ Manager

- Back to Chat -
-
-

Telegram Bot Settings

-
- -
- - -
-

- Webhook URL: https:///api/telegram_webhook.php -

- -
-
+
+ -
-

Add New FAQ

-
- -
- - -
-
- - -
- -
-
- -

Existing FAQs

- - - - - - - - - - - - - - - - - -
KeywordsAnswerActions
-
- - - -
-
- -

Recent Chat History (Last 50)

-
- +

Управление пользователями

+ +
+
- - - - + + + + + + + - - - - - - - - - - + + + + + + + + - - +
TimeUser MessageAI Response
IDИмяРольСтатусДата рег.Действия
No messages yet.
+
+ + + +
+
+ +
+ + + + +
+ +
- - + \ No newline at end of file diff --git a/api/messages.php b/api/messages.php new file mode 100644 index 0000000..d2d325f --- /dev/null +++ b/api/messages.php @@ -0,0 +1,36 @@ + '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); diff --git a/api/send_message.php b/api/send_message.php new file mode 100644 index 0000000..9da1e0c --- /dev/null +++ b/api/send_message.php @@ -0,0 +1,66 @@ + '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]); diff --git a/api/stats.php b/api/stats.php new file mode 100644 index 0000000..8b97f20 --- /dev/null +++ b/api/stats.php @@ -0,0 +1,12 @@ + 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); diff --git a/api/telegram_webhook.php b/api/telegram_webhook.php index fa4899c..835b559 100644 --- a/api/telegram_webhook.php +++ b/api/telegram_webhook.php @@ -1,91 +1,2 @@ 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()); -} +echo "Telegram webhook not implemented."; \ No newline at end of file diff --git a/api/update_ticket.php b/api/update_ticket.php new file mode 100644 index 0000000..39254da --- /dev/null +++ b/api/update_ticket.php @@ -0,0 +1,65 @@ + '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]); \ No newline at end of file diff --git a/assets/css/custom.css b/assets/css/custom.css index 50e0502..87ed12c 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,279 +1,89 @@ -body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; - margin: 0; - min-height: 100vh; +:root { + --bg-dark: #0f172a; + --card-bg: #1e293b; + --primary: #3b82f6; + --primary-hover: #2563eb; + --text-primary: #f8fafc; + --text-secondary: #94a3b8; + --border-color: #334155; + --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; - position: relative; - z-index: 1; + margin: 0; + padding: 0; } -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 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; +body { + font-family: 'Inter', sans-serif; + background-color: var(--bg-dark); + color: var(--text-primary); line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + min-height: 100vh; + overflow-x: hidden; } -@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 { position: fixed; top: 0; left: 0; width: 100%; height: 100%; - z-index: 0; + z-index: -1; overflow: hidden; - pointer-events: none; } .blob { position: absolute; width: 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%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); + filter: blur(40px); } -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); +.blob-1 { top: -100px; left: -100px; animation: float 20s infinite alternate; } +.blob-2 { bottom: -100px; right: -100px; animation: float 25s infinite alternate-reverse; } +.blob-3 { top: 50%; left: 50%; transform: translate(-50%, -50%); opacity: 0.5; } + +@keyframes float { + 0% { transform: translate(0, 0); } + 100% { transform: translate(100px, 50px); } } -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; +.main-wrapper { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + min-height: 100vh; } -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; +/* Auth Pages */ +.auth-page { + display: flex; + align-items: center; + justify-content: center; } -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 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; +.auth-card { + background-color: var(--card-bg); padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - 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 { + border-radius: 1rem; width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; + max-width: 400px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-color); } -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; +.auth-card h2 { + margin-bottom: 1.5rem; + text-align: center; + font-size: 1.875rem; } -.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 { margin-bottom: 1.25rem; } @@ -281,22 +91,314 @@ body { .form-group label { display: block; margin-bottom: 0.5rem; - font-weight: 600; - font-size: 0.9rem; + color: var(--text-secondary); + font-size: 0.875rem; } -.form-control { +.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; + border-radius: 0.5rem; + border: 1px solid var(--border-color); + background-color: var(--bg-dark); + color: var(--text-primary); + 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; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); -} \ No newline at end of file +} + +.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; + } +} diff --git a/auth.php b/auth.php new file mode 100644 index 0000000..3f4d11b --- /dev/null +++ b/auth.php @@ -0,0 +1,73 @@ +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; +} diff --git a/create_ticket.php b/create_ticket.php new file mode 100644 index 0000000..cd02468 --- /dev/null +++ b/create_ticket.php @@ -0,0 +1,81 @@ +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; + } +} +?> + + + + + + Создать тикет - Система поддержки + + + + +
+
+
+
+
+ +
+
+

Новый тикет

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ + Отмена +
+
+
+ + diff --git a/db/config.php b/db/config.php index e512965..e335269 100644 --- a/db/config.php +++ b/db/config.php @@ -1,17 +1,22 @@ PDO::ERRMODE_EXCEPTION, - PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, - ]); + try { + $pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [ + 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; -} +} \ No newline at end of file diff --git a/db/migrations/init.sql b/db/migrations/init.sql new file mode 100644 index 0000000..ed2d875 --- /dev/null +++ b/db/migrations/init.sql @@ -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'); diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..ff4d2cf --- /dev/null +++ b/healthz.php @@ -0,0 +1,3 @@ +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; +} ?> - + - Chat Assistant - - - - - - - - - - + Панель управления - Система поддержки +
@@ -27,23 +102,104 @@ $projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
+
-
-
- Chat Assistant - Admin + + + +
+
+
+
Всего тикетов
+
+
+
+
Активных
+
+
+
ч
+
Ср. время ответа
+
+
-
-
- - -
-
+ + +
+

Тикеты

+ + + Создать тикет + + Максимум 3 открытых тикета + + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + Сбросить +
+ + +
+ +

Тикетов не найдено.

+ + + +
+ +
+

+

+
+ Приоритет: + +
+ +
+ От: +
+ +
+ +
diff --git a/login.php b/login.php new file mode 100644 index 0000000..3be38d5 --- /dev/null +++ b/login.php @@ -0,0 +1,55 @@ + + + + + + + Вход - Система поддержки + + + + +
+
+
+
+
+
+
+

Вход

+ +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+ + diff --git a/logout.php b/logout.php new file mode 100644 index 0000000..8b5c218 --- /dev/null +++ b/logout.php @@ -0,0 +1,3 @@ + + + + + + + Регистрация - Система поддержки + + + + +
+
+
+
+
+
+
+

Регистрация

+ +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+ + diff --git a/ticket.php b/ticket.php new file mode 100644 index 0000000..ccfa559 --- /dev/null +++ b/ticket.php @@ -0,0 +1,264 @@ +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; +} +?> + + + + + + Тикет #<?= $ticket['id'] ?> - <?= htmlspecialchars($ticket['title']) ?> + + + + +
+
+
+
+
+ +
+ + +
+
+
+
+
+ +
+ Категория: +
+

#

+

+
+
+
+ + + + +
+ +
+ + +
+ + +
+ +
+ +
+
+ +
+
+ +
+
+ Создано: + От: +
+ +
+ + + + + + + + + +
+
+
+ +
+
+ +
+ + +
+
+ + + + +
+
+
+ +
+ Тикет закрыт. Общение невозможно. +
+ +
+
+ + + + \ No newline at end of file