Add files via upload

This commit is contained in:
Yamusa85
2026-06-18 20:21:11 +03:00
committed by GitHub
parent e2f9a8ed9f
commit a9091dbc79
9 changed files with 2583 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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 %}
+160
View File
@@ -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 %}
+44
View File
@@ -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 %}
+999
View File
@@ -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;">&times;</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;">&times;</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, '&quot;');
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;">&times;</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 %}
+25
View File
@@ -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 %}