mirror of
https://github.com/Yamusa85/check-in_system.git
synced 2026-07-24 14:36:10 +03:00
Add files via upload
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK(role IN ('admin', 'manager'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_by INTEGER NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_active BOOLEAN DEFAULT 1,
|
||||||
|
is_passed BOOLEAN DEFAULT 0,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_columns (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_id INTEGER NOT NULL,
|
||||||
|
column_name TEXT NOT NULL,
|
||||||
|
column_order INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS guests (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
surname TEXT NOT NULL,
|
||||||
|
second_name TEXT,
|
||||||
|
additional_data TEXT,
|
||||||
|
checked_in BOOLEAN DEFAULT 0,
|
||||||
|
checked_in_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h2>Админка</h2>
|
||||||
|
<a href="{{ url_for('create_event') }}" class="btn">Создать мероприятие</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if events %}
|
||||||
|
<div style="overflow-x: auto;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
<th>Гостей</th>
|
||||||
|
<th>Зачекинились</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in events %}
|
||||||
|
<tr style="{% if event.is_passed %}opacity: 0.6;{% endif %}">
|
||||||
|
<td>{{ event.name }}</td>
|
||||||
|
<td>{{ event.created_at }}</td>
|
||||||
|
<td>{{ event.guest_count }}</td>
|
||||||
|
<td>{{ event.checked_in_count }}/{{ event.guest_count }}</td>
|
||||||
|
<td>
|
||||||
|
{% if event.is_passed %}
|
||||||
|
<span class="badge badge-passed">Завершено</span>
|
||||||
|
{% elif event.is_active %}
|
||||||
|
<span class="badge badge-active">Идёт</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-inactive">Неактивно</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<a href="{{ url_for('guest_search', event_id=event.id) }}" class="btn btn-small">Смотреть</a>
|
||||||
|
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-small btn-edit">Редактировать</a>
|
||||||
|
<form method="POST" action="{{ url_for('toggle_event_passed', event_id=event.id) }}" style="display: inline;">
|
||||||
|
<button type="submit" class="btn btn-small {% if event.is_passed %}btn-reactivate{% else %}btn-passed{% endif %}">
|
||||||
|
{% if event.is_passed %}Открыть{% else %}Закрыть{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="text-align: center; padding: 40px;">
|
||||||
|
<p style="color: #666; font-size: 18px;">Еще нет мероприятий.</p>
|
||||||
|
<a href="{{ url_for('create_event') }}" class="btn" style="margin-top: 20px;">Создайте Ваше первое мероприятие</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-passed {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
background: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #e0a800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-passed {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-passed:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reactivate {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reactivate:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Check-in System</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
//max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a, .btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: background 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover, .btn:hover {
|
||||||
|
background: #764ba2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-messages {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checked-in {
|
||||||
|
color: #28a745;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-checked-in {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="nav">
|
||||||
|
<h1><a href="/">На главную</a></h1>
|
||||||
|
<h1>Система регистрации участников мероприятия</h1>
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{% if current_user.is_authenticated %}
|
||||||
|
<span style="margin-right: 20px;">
|
||||||
|
Вы вошли как: {{ current_user.username }} ({{ current_user.role }})
|
||||||
|
</span>
|
||||||
|
<a href="{{ url_for('logout') }}" class="btn-danger">Выйти</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<div class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Создать новое мероприятие</h2>
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px;">Название мероприятия:</label>
|
||||||
|
<input type="text" name="event_name" required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;"
|
||||||
|
placeholder="Введите название мероприятия">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px;">Список участников (файл Excel):</label>
|
||||||
|
<input type="file" name="guest_list" accept=".xlsx,.xls" required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
<small style="color: #666;">
|
||||||
|
Файл Excel должен содержать как минимум столбцы с заголовком 'Фамилия' и 'Имя'.
|
||||||
|
Дополинтельные столбцы будут добавлены в систему автоматически при наличии.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">Создать мероприятие</button>
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn" style="background: #6c757d;">Отмена</a>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 30px; padding: 20px; background: #f8f9fa; border-radius: 5px;">
|
||||||
|
<h3>Пример Excel формата:</h3>
|
||||||
|
<p>Содержимое таблицы должно выглядет примерно так:</p>
|
||||||
|
<table style="width: auto; margin-top: 10px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Фамилия</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Отчество</th>
|
||||||
|
<th>Номер столика</th>
|
||||||
|
<th>Диетические предпочтения</th>
|
||||||
|
<th>Телефон</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Иванов</td>
|
||||||
|
<td>Иван</td>
|
||||||
|
<td>Иванович</td>
|
||||||
|
<td>5</td>
|
||||||
|
<td>Веган</td>
|
||||||
|
<td>+1234567890</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Петрова</td>
|
||||||
|
<td>Анна</td>
|
||||||
|
<td>Сергеевна</td>
|
||||||
|
<td>3</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>+0987654321</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<strong>Обязательные столбцы:</strong> Фамилия, Имя<br>
|
||||||
|
<strong>Опциональный столбец:</strong> Отчество<br>
|
||||||
|
<strong>Дополнительные столбцы:</strong> любой дополнительный столбец будет добавлен динамически при наличии
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Редактировать мероприятие: {{ event.name }}</h2>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 30px; padding: 20px; background: #fff3cd; border-radius: 5px; border: 1px solid #ffc107;">
|
||||||
|
<strong>⚠️ ВНИМАНИЕ:</strong> Загрузка нового файла Excel приведет к замене текущего списка участников и сбросу статуса регистрации..
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px;">Название мероприятия:</label>
|
||||||
|
<input type="text" name="event_name" value="{{ event.name }}" required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px;">Обновить список участников (файл Excel - опционально):</label>
|
||||||
|
<input type="file" name="guest_list" accept=".xlsx,.xls"
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
<small style="color: #666;">
|
||||||
|
Формат Excel: должны быть как минимум столбцы 'Фамилия' и 'Имя'
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if columns %}
|
||||||
|
<div style="margin-bottom: 15px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
||||||
|
<strong>Список столбцов в данном мероприятии:</strong>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px;">
|
||||||
|
{% for column in columns %}
|
||||||
|
<span style="background: #667eea; color: white; padding: 5px 10px; border-radius: 15px; font-size: 14px;">
|
||||||
|
{{ column }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button type="submit" class="btn">Обновить мероприятие</button>
|
||||||
|
<a href="{{ url_for('admin_dashboard') }}" class="btn" style="background: #6c757d;">Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Выберите мероприятие для регистрации</h2>
|
||||||
|
|
||||||
|
{% if events %}
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 20px; margin-top: 20px;">
|
||||||
|
{% for event in events %}
|
||||||
|
<div style="padding: 20px; border: 2px solid #667eea; border-radius: 10px; text-align: center;">
|
||||||
|
<h3>{{ event.name }}</h3>
|
||||||
|
<p style="color: #666; margin: 10px 0;">Создано: {{ event.created_at[:10] }}</p>
|
||||||
|
<a href="{{ url_for('guest_search', event_id=event.id) }}" class="btn">Открыть список участников</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>Нет активных мероприятий.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,999 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Мероприятие: {{ event.name }}</h2>
|
||||||
|
|
||||||
|
<!-- Statistics and Export Section -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding: 15px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 10px; color: white; flex-wrap: wrap; gap: 15px;">
|
||||||
|
<div style="display: flex; gap: 30px; align-items: center; flex-wrap: wrap;">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 28px; font-weight: bold;" id="totalGuests">0</div>
|
||||||
|
<div style="font-size: 14px;">Всего участников</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 28px; font-weight: bold; color: #2ecc71;" id="checkedInGuests">0</div>
|
||||||
|
<div style="font-size: 14px;">Зарегались</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 28px; font-weight: bold; color: #e74c3c;" id="notCheckedInGuests">0</div>
|
||||||
|
<div style="font-size: 14px;">НЕ зарегались</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 28px; font-weight: bold;" id="checkinPercentage">0%</div>
|
||||||
|
<div style="font-size: 14px;">Ход регистрации</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px;">
|
||||||
|
<button onclick="openFilterModal()" class="btn" style="background: #f39c12; font-size: 16px; padding: 12px 24px; white-space: nowrap;">
|
||||||
|
🔍 Фильтры
|
||||||
|
</button>
|
||||||
|
<a href="{{ url_for('export_event_stats', event_id=event.id) }}" class="btn" style="background: #27ae60; font-size: 16px; padding: 12px 24px; white-space: nowrap;">
|
||||||
|
📊 Экспорт статистики
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Filters Display -->
|
||||||
|
<div id="activeFilters" style="display: none; margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; border: 1px solid #dee2e6;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<strong>Активные фильтры:</strong>
|
||||||
|
<button onclick="clearAllFilters()" class="btn btn-small" style="background: #dc3545; padding: 5px 10px; font-size: 12px;">Очистить фильтры</button>
|
||||||
|
</div>
|
||||||
|
<div id="activeFiltersList" style="margin-top: 10px; display: flex; flex-wrap: wrap; gap: 5px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Modal -->
|
||||||
|
<div id="filterModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; justify-content: center; align-items: center;">
|
||||||
|
<div style="background: white; border-radius: 10px; padding: 30px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h3 style="margin: 0;">Отфильтровать список гостей</h3>
|
||||||
|
<button onclick="closeFilterModal()" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search filter -->
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px; font-weight: bold;">Быстрый поиск:</label>
|
||||||
|
<input type="text" id="quickSearch" placeholder="Поиск по всем полям..."
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;"
|
||||||
|
oninput="applyQuickSearch()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid #eee;">
|
||||||
|
|
||||||
|
<!-- Column-specific filters -->
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 10px; font-weight: bold;">Фильтровать по столбцу:</label>
|
||||||
|
<div id="columnFilters">
|
||||||
|
<!-- Dynamic column filters will be added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Check-in status filter -->
|
||||||
|
<div style="margin-bottom: 20px;">
|
||||||
|
<label style="display: block; margin-bottom: 10px; font-weight: bold;">Статус регистрации:</label>
|
||||||
|
<select id="statusFilter" onchange="applyFilters()"
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
<option value="all">ВСЕ</option>
|
||||||
|
<option value="checked_in">Зареган</option>
|
||||||
|
<option value="not_checked_in">Не зареган</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: flex-end;">
|
||||||
|
<button onclick="clearAllFilters()" class="btn" style="background: #6c757d;">Очистить все фильтры</button>
|
||||||
|
<button onclick="applyFiltersAndClose()" class="btn" style="background: #28a745;">Применить фильтры</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Guest Modal -->
|
||||||
|
<div id="editModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1001; justify-content: center; align-items: center;">
|
||||||
|
<div style="background: white; border-radius: 10px; padding: 30px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
<h3 style="margin: 0;">Изменить данные гостя</h3>
|
||||||
|
<button onclick="closeEditModal()" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666;">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="editGuestForm" onsubmit="saveGuestEdit(event)">
|
||||||
|
<input type="hidden" id="editGuestId">
|
||||||
|
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px; font-weight: 500;">Фамилия:</label>
|
||||||
|
<input type="text" id="editSurname" required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px; font-weight: 500;">Имя:</label>
|
||||||
|
<input type="text" id="editName" required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="editSecondNameField" style="margin-bottom: 15px; display: none;">
|
||||||
|
<label style="display: block; margin-bottom: 5px; font-weight: 500;">Отчество:</label>
|
||||||
|
<input type="text" id="editSecondName"
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="editAdditionalFields" style="margin-bottom: 15px;">
|
||||||
|
<!-- Dynamic additional fields will be added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
||||||
|
<button type="button" onclick="closeEditModal()" class="btn" style="background: #6c757d;">Отмена</button>
|
||||||
|
<button type="submit" class="btn" style="background: #28a745;">Применить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 20px 0;">
|
||||||
|
<h3>Поиск по фамилии</h3>
|
||||||
|
|
||||||
|
<!-- Letter Buttons -->
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 20px;">
|
||||||
|
<button class="btn letter-btn active" onclick="searchByLetter('ALL')" style="background: #28a745;">
|
||||||
|
ВСЕ
|
||||||
|
</button>
|
||||||
|
{% for letter in letters %}
|
||||||
|
<button class="btn letter-btn" onclick="searchByLetter('{{ letter }}')">{{ letter }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Two-Letter Combinations -->
|
||||||
|
<div id="twoLetterButtons" style="display: none; flex-wrap: wrap; gap: 10px; margin-bottom: 20px;">
|
||||||
|
<h4 style="width: 100%;">Подробный поиск:</h4>
|
||||||
|
<div id="twoLetterContainer" style="display: flex; flex-wrap: wrap; gap: 5px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Results -->
|
||||||
|
<div id="results" style="margin-top: 20px;">
|
||||||
|
<div style="text-align: center; padding: 40px; color: #666;">
|
||||||
|
<p>Загружаю список...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentEventId = {{ event.id }};
|
||||||
|
let currentColumns = [];
|
||||||
|
let currentGuests = [];
|
||||||
|
let filteredGuests = [];
|
||||||
|
let currentSearchTerm = 'ALL';
|
||||||
|
let allGuests = [];
|
||||||
|
let activeFilters = {};
|
||||||
|
let quickSearchTerm = '';
|
||||||
|
|
||||||
|
function searchByLetter(letter) {
|
||||||
|
currentSearchTerm = letter;
|
||||||
|
|
||||||
|
document.querySelectorAll('.letter-btn').forEach(btn => {
|
||||||
|
btn.style.background = '#667eea';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
const buttons = document.querySelectorAll('.letter-btn');
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
if (btn.textContent.trim() === letter) {
|
||||||
|
btn.style.background = '#764ba2';
|
||||||
|
btn.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (letter === 'ALL') {
|
||||||
|
document.getElementById('twoLetterButtons').style.display = 'none';
|
||||||
|
fetchGuests(letter);
|
||||||
|
} else if (letter.length === 1) {
|
||||||
|
fetchAllGuestsForCombinations(letter);
|
||||||
|
} else {
|
||||||
|
document.getElementById('twoLetterButtons').style.display = 'none';
|
||||||
|
fetchGuests(letter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAllGuestsForCombinations(firstLetter) {
|
||||||
|
const url = `/api/search_guests/${currentEventId}?term=${encodeURIComponent(firstLetter)}`;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
allGuests = data.guests;
|
||||||
|
const combinations = generateTwoLetterCombinations(firstLetter, data.guests);
|
||||||
|
showTwoLetterButtonsDynamic(firstLetter, combinations);
|
||||||
|
currentColumns = data.columns;
|
||||||
|
currentGuests = data.guests;
|
||||||
|
applyFilters();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching guests:', error);
|
||||||
|
document.getElementById('results').innerHTML = '<p style="color: red; text-align: center;">Не смог загрузить список. Пожалуйста, попробуйте еще раз.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateTwoLetterCombinations(firstLetter, guests) {
|
||||||
|
const secondChars = new Set();
|
||||||
|
|
||||||
|
guests.forEach(guest => {
|
||||||
|
const surname = guest.surname || '';
|
||||||
|
if (surname.length >= 2) {
|
||||||
|
const secondChar = surname.charAt(1);
|
||||||
|
secondChars.add(secondChar);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const combinations = Array.from(secondChars).sort((a, b) => {
|
||||||
|
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return combinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTwoLetterButtonsDynamic(firstLetter, combinations) {
|
||||||
|
const container = document.getElementById('twoLetterButtons');
|
||||||
|
const buttonContainer = document.getElementById('twoLetterContainer');
|
||||||
|
|
||||||
|
container.style.display = 'block';
|
||||||
|
|
||||||
|
if (combinations.length === 0) {
|
||||||
|
buttonContainer.innerHTML = '<p style="color: #666;">Невозможен подробный поиск</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
combinations.forEach(secondChar => {
|
||||||
|
const combination = firstLetter + secondChar;
|
||||||
|
const escapedCombination = combination.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||||
|
html += `
|
||||||
|
<button class="btn"
|
||||||
|
onclick="searchByLetter('${escapedCombination}')"
|
||||||
|
style="margin: 2px; font-size: 14px; padding: 8px 12px;">
|
||||||
|
${combination}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
buttonContainer.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchGuests(searchTerm) {
|
||||||
|
document.getElementById('results').innerHTML = '<div style="text-align: center; padding: 40px; color: #666;"><p>Загружаю список...</p></div>';
|
||||||
|
|
||||||
|
const url = `/api/search_guests/${currentEventId}?term=${encodeURIComponent(searchTerm)}`;
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
currentColumns = data.columns;
|
||||||
|
currentGuests = data.guests;
|
||||||
|
applyFilters();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching guests:', error);
|
||||||
|
document.getElementById('results').innerHTML = '<p style="color: red; text-align: center;">Не получилось загрузить список. Пожалуйста, попробуйте еще раз.</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit Guest Functions
|
||||||
|
function openEditModal(guestId) {
|
||||||
|
// Find guest in current guests list
|
||||||
|
const guest = currentGuests.find(g => g.id === guestId);
|
||||||
|
if (!guest) return;
|
||||||
|
|
||||||
|
// Set guest ID
|
||||||
|
document.getElementById('editGuestId').value = guestId;
|
||||||
|
|
||||||
|
// Set basic fields
|
||||||
|
document.getElementById('editSurname').value = guest.surname || '';
|
||||||
|
document.getElementById('editName').value = guest.name || '';
|
||||||
|
|
||||||
|
// Show/hide second name field
|
||||||
|
const secondNameField = document.getElementById('editSecondNameField');
|
||||||
|
if (currentColumns.includes('second_name')) {
|
||||||
|
secondNameField.style.display = 'block';
|
||||||
|
document.getElementById('editSecondName').value = guest.second_name || '';
|
||||||
|
} else {
|
||||||
|
secondNameField.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build additional fields
|
||||||
|
const additionalFieldsContainer = document.getElementById('editAdditionalFields');
|
||||||
|
let additionalHtml = '';
|
||||||
|
|
||||||
|
const additionalCols = currentColumns.filter(col => !['name', 'surname', 'second_name'].includes(col));
|
||||||
|
|
||||||
|
additionalCols.forEach(col => {
|
||||||
|
const value = guest.additional_data[col] || '';
|
||||||
|
const displayName = /^[a-zA-Z]/.test(col) ?
|
||||||
|
col.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) : col;
|
||||||
|
|
||||||
|
additionalHtml += `
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px; font-weight: 500;">${displayName}:</label>
|
||||||
|
<input type="text" name="additional_${col}" data-column="${col}" value="${escapeHtml(value)}"
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
additionalFieldsContainer.innerHTML = additionalHtml;
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
document.getElementById('editModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
document.getElementById('editModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGuestEdit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const guestId = document.getElementById('editGuestId').value;
|
||||||
|
const name = document.getElementById('editName').value.trim();
|
||||||
|
const surname = document.getElementById('editSurname').value.trim();
|
||||||
|
const secondName = document.getElementById('editSecondName')?.value.trim() || '';
|
||||||
|
|
||||||
|
if (!name || !surname) {
|
||||||
|
alert('Фамилия и Имя обязательны для заполнения');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect additional data
|
||||||
|
const additionalData = {};
|
||||||
|
document.querySelectorAll('#editAdditionalFields input').forEach(input => {
|
||||||
|
const column = input.dataset.column;
|
||||||
|
additionalData[column] = input.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare data for API
|
||||||
|
const updateData = {
|
||||||
|
name: name,
|
||||||
|
surname: surname,
|
||||||
|
second_name: secondName,
|
||||||
|
additional_data: additionalData
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disable submit button
|
||||||
|
const submitBtn = document.querySelector('#editGuestForm button[type="submit"]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = 'Сохраняю...';
|
||||||
|
|
||||||
|
// Send update to server
|
||||||
|
fetch(`/api/update_guest/${guestId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
// Update guest in all arrays
|
||||||
|
updateGuestInArrays(data.guest);
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
closeEditModal();
|
||||||
|
|
||||||
|
// Refresh the display
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showToast('Данные гостя успещно обновлены!', 'success');
|
||||||
|
} else {
|
||||||
|
alert('Ошибка: ' + data.message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Не удалось сохранить. Пожалуйста, попробуйте еще раз.');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
// Re-enable submit button
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Сохранить изменения';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGuestInArrays(updatedGuest) {
|
||||||
|
// Update in currentGuests
|
||||||
|
const currentIndex = currentGuests.findIndex(g => g.id === updatedGuest.id);
|
||||||
|
if (currentIndex !== -1) {
|
||||||
|
currentGuests[currentIndex] = {...currentGuests[currentIndex], ...updatedGuest};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in filteredGuests
|
||||||
|
const filteredIndex = filteredGuests.findIndex(g => g.id === updatedGuest.id);
|
||||||
|
if (filteredIndex !== -1) {
|
||||||
|
filteredGuests[filteredIndex] = {...filteredGuests[filteredIndex], ...updatedGuest};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in allGuests
|
||||||
|
if (allGuests.length > 0) {
|
||||||
|
const allIndex = allGuests.findIndex(g => g.id === updatedGuest.id);
|
||||||
|
if (allIndex !== -1) {
|
||||||
|
allGuests[allIndex] = {...allGuests[allIndex], ...updatedGuest};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toast notification
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 15px 25px;
|
||||||
|
background: ${type === 'success' ? '#28a745' : '#dc3545'};
|
||||||
|
color: white;
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 10000;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
font-weight: 500;
|
||||||
|
`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.style.animation = 'slideOutRight 0.3s ease-out';
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add toast animations
|
||||||
|
const toastStyle = document.createElement('style');
|
||||||
|
toastStyle.textContent = `
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); opacity: 0; }
|
||||||
|
to { transform: translateX(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slideOutRight {
|
||||||
|
from { transform: translateX(0); opacity: 1; }
|
||||||
|
to { transform: translateX(100%); opacity: 0; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(toastStyle);
|
||||||
|
|
||||||
|
// Filter functions
|
||||||
|
function openFilterModal() {
|
||||||
|
document.getElementById('filterModal').style.display = 'flex';
|
||||||
|
buildColumnFilters();
|
||||||
|
|
||||||
|
document.getElementById('statusFilter').value = activeFilters.status || 'all';
|
||||||
|
document.getElementById('quickSearch').value = quickSearchTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFilterModal() {
|
||||||
|
document.getElementById('filterModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColumnFilters() {
|
||||||
|
const container = document.getElementById('columnFilters');
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
currentColumns.forEach(column => {
|
||||||
|
if (['name', 'surname', 'second_name'].includes(column)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueValues = new Set();
|
||||||
|
currentGuests.forEach(guest => {
|
||||||
|
let value = guest.additional_data[column] || '';
|
||||||
|
if (value) {
|
||||||
|
uniqueValues.add(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uniqueValues.size > 0) {
|
||||||
|
const sortedValues = Array.from(uniqueValues).sort((a, b) => {
|
||||||
|
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayName = /^[a-zA-Z]/.test(column) ?
|
||||||
|
column.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) : column;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px; font-weight: 500;">
|
||||||
|
${displayName}:
|
||||||
|
</label>
|
||||||
|
<select class="column-filter" data-column="${column}" onchange="updateColumnFilter(this)"
|
||||||
|
style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
<option value="">All</option>
|
||||||
|
${sortedValues.map(val => `<option value="${escapeHtml(val)}" ${activeFilters[column] === val ? 'selected' : ''}>${val}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (html === '') {
|
||||||
|
html = '<p style="color: #666;">Нет дополнительных столбцов для фильтрации.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColumnFilter(selectElement) {
|
||||||
|
const column = selectElement.dataset.column;
|
||||||
|
const value = selectElement.value;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
activeFilters[column] = value;
|
||||||
|
} else {
|
||||||
|
delete activeFilters[column];
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveFiltersDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyQuickSearch() {
|
||||||
|
quickSearchTerm = document.getElementById('quickSearch').value.toLowerCase();
|
||||||
|
applyFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
filteredGuests = [...currentGuests];
|
||||||
|
|
||||||
|
if (quickSearchTerm) {
|
||||||
|
filteredGuests = filteredGuests.filter(guest => {
|
||||||
|
const searchableText = [
|
||||||
|
guest.name,
|
||||||
|
guest.surname,
|
||||||
|
guest.second_name,
|
||||||
|
...Object.values(guest.additional_data)
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
return searchableText.includes(quickSearchTerm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(activeFilters).forEach(([column, value]) => {
|
||||||
|
if (column === 'status') return;
|
||||||
|
|
||||||
|
filteredGuests = filteredGuests.filter(guest => {
|
||||||
|
const guestValue = guest.additional_data[column] || '';
|
||||||
|
return guestValue === value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeFilters.status === 'checked_in') {
|
||||||
|
filteredGuests = filteredGuests.filter(guest => guest.checked_in);
|
||||||
|
} else if (activeFilters.status === 'not_checked_in') {
|
||||||
|
filteredGuests = filteredGuests.filter(guest => !guest.checked_in);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateActiveFiltersDisplay();
|
||||||
|
displayResults(filteredGuests, currentColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFiltersAndClose() {
|
||||||
|
const statusValue = document.getElementById('statusFilter').value;
|
||||||
|
if (statusValue && statusValue !== 'all') {
|
||||||
|
activeFilters.status = statusValue;
|
||||||
|
} else {
|
||||||
|
delete activeFilters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
quickSearchTerm = document.getElementById('quickSearch').value.toLowerCase();
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
closeFilterModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllFilters() {
|
||||||
|
activeFilters = {};
|
||||||
|
quickSearchTerm = '';
|
||||||
|
document.getElementById('quickSearch').value = '';
|
||||||
|
document.getElementById('statusFilter').value = 'all';
|
||||||
|
|
||||||
|
document.querySelectorAll('.column-filter').forEach(select => {
|
||||||
|
select.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredGuests = [...currentGuests];
|
||||||
|
updateActiveFiltersDisplay();
|
||||||
|
displayResults(filteredGuests, currentColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveFiltersDisplay() {
|
||||||
|
const container = document.getElementById('activeFilters');
|
||||||
|
const listContainer = document.getElementById('activeFiltersList');
|
||||||
|
|
||||||
|
const allFilters = {...activeFilters};
|
||||||
|
if (quickSearchTerm) {
|
||||||
|
allFilters['Search'] = quickSearchTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterKeys = Object.keys(allFilters);
|
||||||
|
|
||||||
|
if (filterKeys.length === 0) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.style.display = 'block';
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
filterKeys.forEach(key => {
|
||||||
|
const value = allFilters[key];
|
||||||
|
const displayKey = key === 'Search' ? 'Search' :
|
||||||
|
(/^[a-zA-Z]/.test(key) ? key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) : key);
|
||||||
|
const displayValue = key === 'status' ? (value === 'checked_in' ? 'Зареган' : 'Не зареган') : value;
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<span style="background: #667eea; color: white; padding: 5px 10px; border-radius: 15px; font-size: 12px; display: inline-flex; align-items: center; gap: 5px;">
|
||||||
|
${displayKey}: ${displayValue}
|
||||||
|
<button onclick="removeFilter('${key}')" style="background: none; border: none; color: white; cursor: pointer; font-size: 16px; padding: 0; line-height: 1;">×</button>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
listContainer.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFilter(key) {
|
||||||
|
if (key === 'Search') {
|
||||||
|
quickSearchTerm = '';
|
||||||
|
document.getElementById('quickSearch').value = '';
|
||||||
|
} else {
|
||||||
|
delete activeFilters[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters();
|
||||||
|
|
||||||
|
if (document.getElementById('filterModal').style.display === 'flex') {
|
||||||
|
if (key === 'status') {
|
||||||
|
document.getElementById('statusFilter').value = 'all';
|
||||||
|
} else if (key !== 'Search') {
|
||||||
|
const select = document.querySelector(`.column-filter[data-column="${key}"]`);
|
||||||
|
if (select) select.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatsDisplay(total, checkedIn) {
|
||||||
|
const notCheckedIn = total - checkedIn;
|
||||||
|
const percentage = total > 0 ? Math.round((checkedIn / total) * 100) : 0;
|
||||||
|
|
||||||
|
document.getElementById('totalGuests').textContent = total;
|
||||||
|
document.getElementById('checkedInGuests').textContent = checkedIn;
|
||||||
|
document.getElementById('notCheckedInGuests').textContent = notCheckedIn;
|
||||||
|
document.getElementById('checkinPercentage').textContent = percentage + '%';
|
||||||
|
|
||||||
|
const percentageElement = document.getElementById('checkinPercentage');
|
||||||
|
if (percentage >= 80) {
|
||||||
|
percentageElement.style.color = '#2ecc71';
|
||||||
|
} else if (percentage >= 50) {
|
||||||
|
percentageElement.style.color = '#f39c12';
|
||||||
|
} else {
|
||||||
|
percentageElement.style.color = '#e74c3c';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayResults(guests, columns) {
|
||||||
|
const resultsDiv = document.getElementById('results');
|
||||||
|
|
||||||
|
const checkedInCount = guests.filter(g => g.checked_in).length;
|
||||||
|
updateStatsDisplay(guests.length, checkedInCount);
|
||||||
|
|
||||||
|
if (guests.length === 0) {
|
||||||
|
resultsDiv.innerHTML = '<p style="color: #666; text-align: center; padding: 20px;">Поиск не дал результата.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="overflow-x: auto;">
|
||||||
|
<table id="guestTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Фамилия</th>
|
||||||
|
<th>Имя</th>
|
||||||
|
${columns.includes('second_name') ? '<th>Отчество</th>' : ''}
|
||||||
|
${columns.filter(col => !['name', 'surname', 'second_name'].includes(col))
|
||||||
|
.map(col => {
|
||||||
|
let displayName = col;
|
||||||
|
if (/^[a-zA-Z]/.test(col)) {
|
||||||
|
displayName = col.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
}
|
||||||
|
return `<th>${displayName}</th>`;
|
||||||
|
}).join('')}
|
||||||
|
<th>Status</th>
|
||||||
|
<th style="min-width: 160px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
guests.forEach((guest, index) => {
|
||||||
|
const checkedInClass = guest.checked_in ? 'checked-in' : 'not-checked-in';
|
||||||
|
const rowClass = guest.checked_in ? 'row-checked-in' : '';
|
||||||
|
|
||||||
|
let statusHtml = guest.checked_in ? '✓ Зареган' : 'Не зареган';
|
||||||
|
if (guest.checked_in && guest.checked_in_at) {
|
||||||
|
const checkinTime = new Date(guest.checked_in_at).toLocaleString();
|
||||||
|
statusHtml += `<br><small style="font-size: 11px;">${checkinTime}</small>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<tr id="guest-row-${guest.id}" class="${rowClass}">
|
||||||
|
<td>${index + 1}</td>
|
||||||
|
<td>${escapeHtml(guest.surname)}</td>
|
||||||
|
<td>${escapeHtml(guest.name)}</td>
|
||||||
|
${columns.includes('second_name') ? `<td>${escapeHtml(guest.second_name || '-')}</td>` : ''}
|
||||||
|
${columns.filter(col => !['name', 'surname', 'second_name'].includes(col))
|
||||||
|
.map(col => `<td>${escapeHtml(guest.additional_data[col] || '-')}</td>`).join('')}
|
||||||
|
<td class="${checkedInClass}" id="status-cell-${guest.id}">
|
||||||
|
${statusHtml}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; gap: 5px;">
|
||||||
|
<button class="btn btn-small ${guest.checked_in ? 'btn-uncheck' : 'btn-checkin'}"
|
||||||
|
onclick="toggleCheckIn(${guest.id}, ${!guest.checked_in})"
|
||||||
|
id="checkin-btn-${guest.id}">
|
||||||
|
${guest.checked_in ? 'Снять регистр' : 'Зарегать'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-small btn-edit"
|
||||||
|
onclick="openEditModal(${guest.id})">
|
||||||
|
✏️ Править
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
|
||||||
|
const filterInfo = Object.keys(activeFilters).length > 0 || quickSearchTerm ?
|
||||||
|
'<div style="margin-top: 5px; color: #666; font-style: italic;">Результат фильтрации</div>' : '';
|
||||||
|
|
||||||
|
html += filterInfo;
|
||||||
|
|
||||||
|
resultsDiv.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCheckIn(guestId, checkIn) {
|
||||||
|
const action = checkIn ? 'check in' : 'uncheck';
|
||||||
|
const guest = currentGuests.find(g => g.id === guestId);
|
||||||
|
const guestName = guest ? `${guest.name} ${guest.surname}` : 'this guest';
|
||||||
|
|
||||||
|
if (confirm(`Confirm ${action} for ${guestName}?`)) {
|
||||||
|
const button = document.getElementById(`checkin-btn-${guestId}`);
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = 'Processing...';
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`/api/checkin/${guestId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ check_in: checkIn })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
const guestIndex = currentGuests.findIndex(g => g.id === guestId);
|
||||||
|
if (guestIndex !== -1) {
|
||||||
|
currentGuests[guestIndex].checked_in = checkIn;
|
||||||
|
currentGuests[guestIndex].checked_in_at = data.checked_in_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredIndex = filteredGuests.findIndex(g => g.id === guestId);
|
||||||
|
if (filteredIndex !== -1) {
|
||||||
|
filteredGuests[filteredIndex].checked_in = checkIn;
|
||||||
|
filteredGuests[filteredIndex].checked_in_at = data.checked_in_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allGuests.length > 0) {
|
||||||
|
const allGuestIndex = allGuests.findIndex(g => g.id === guestId);
|
||||||
|
if (allGuestIndex !== -1) {
|
||||||
|
allGuests[allGuestIndex].checked_in = checkIn;
|
||||||
|
allGuests[allGuestIndex].checked_in_at = data.checked_in_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGuestRow(guestId, checkIn, data.checked_in_at);
|
||||||
|
|
||||||
|
const totalGuests = filteredGuests.length;
|
||||||
|
const checkedInCount = filteredGuests.filter(g => g.checked_in).length;
|
||||||
|
updateStatsDisplay(totalGuests, checkedInCount);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + data.message);
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = checkIn ? 'Зарегать' : 'Снять регистр';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
alert('Возникла ошибка. Пожалуйста, попробуйте повторить.');
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = checkIn ? 'Зарегать' : 'Снять регистр';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGuestRow(guestId, checkedIn, checkedInAt) {
|
||||||
|
const row = document.getElementById(`guest-row-${guestId}`);
|
||||||
|
const button = document.getElementById(`checkin-btn-${guestId}`);
|
||||||
|
const statusCell = document.getElementById(`status-cell-${guestId}`);
|
||||||
|
|
||||||
|
if (!row || !button || !statusCell) {
|
||||||
|
console.error('Could not find elements for guest', guestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkedIn) {
|
||||||
|
row.classList.add('row-checked-in');
|
||||||
|
} else {
|
||||||
|
row.classList.remove('row-checked-in');
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCell.className = checkedIn ? 'checked-in' : 'not-checked-in';
|
||||||
|
|
||||||
|
let statusHtml = checkedIn ? '✓ Зареган' : 'Не зареган';
|
||||||
|
if (checkedIn && checkedInAt) {
|
||||||
|
const checkinTime = new Date(checkedInAt).toLocaleString();
|
||||||
|
statusHtml += `<br><small style="font-size: 11px;">${checkinTime}</small>`;
|
||||||
|
}
|
||||||
|
statusCell.innerHTML = statusHtml;
|
||||||
|
|
||||||
|
button.className = `btn btn-small ${checkedIn ? 'btn-uncheck' : 'btn-checkin'}`;
|
||||||
|
button.textContent = checkedIn ? 'Снять регистр' : 'Зарегать';
|
||||||
|
button.disabled = false;
|
||||||
|
button.onclick = () => toggleCheckIn(guestId, !checkedIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modals when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const filterModal = document.getElementById('filterModal');
|
||||||
|
const editModal = document.getElementById('editModal');
|
||||||
|
|
||||||
|
if (event.target === filterModal) {
|
||||||
|
closeFilterModal();
|
||||||
|
}
|
||||||
|
if (event.target === editModal) {
|
||||||
|
closeEditModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all guests on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
searchByLetter('ALL');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.row-checked-in {
|
||||||
|
background-color: #d4edda !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-checked-in:hover {
|
||||||
|
background-color: #c3e6cb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-checkin {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-checkin:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-uncheck {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-uncheck:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
background: #3498db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checked-in {
|
||||||
|
color: #155724;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-checked-in {
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.letter-btn.active {
|
||||||
|
background: #764ba2 !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-checked-in:hover {
|
||||||
|
background-color: #c3e6cb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#twoLetterButtons {
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#filterModal > div,
|
||||||
|
#editModal > div {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-filter:focus,
|
||||||
|
#quickSearch:focus,
|
||||||
|
#statusFilter:focus,
|
||||||
|
#editGuestForm input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div style="max-width: 400px; margin: 50px auto;">
|
||||||
|
<h2>Вход</h2>
|
||||||
|
<form method="POST">
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px;">Логин:</label>
|
||||||
|
<input type="text" name="username" required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 15px;">
|
||||||
|
<label style="display: block; margin-bottom: 5px;">Пароль:</label>
|
||||||
|
<input type="password" name="password" required
|
||||||
|
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn" style="width: 100%;">ВОЙТИ</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user