Initial commit: InviteMe production redesign

This commit is contained in:
InviteMe Developer
2026-09-01 10:52:35 +03:00
commit 0d0868d998
71 changed files with 6912 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}Аналитика — InviteMe{% endblock %}
{% block content %}
<header class="page-header"><div><div class="eyebrow">Live-аналитика</div><h1 class="page-title">{{ event.name }}</h1><p class="page-subtitle">Сводка по приглашениям, ответам и фактическим входам.</p></div><a class="btn btn-primary" href="{{ url_for('main.check_in') }}">Открыть контроль входа</a></header>
<div class="stats-grid"><div class="stat-card"><div class="stat-label">Всего гостей</div><div class="stat-value">{{ total }}</div><div class="stat-meta">в списке мероприятия</div></div><div class="stat-card"><div class="stat-label">Уже пришли</div><div class="stat-value text-success">{{ checked_in }}</div><div class="stat-meta">успешных входов</div></div><div class="stat-card"><div class="stat-label">Ожидаются</div><div class="stat-value">{{ not_checked_in }}</div><div class="stat-meta">ещё не отметились</div></div><div class="stat-card"><div class="stat-label">Посещаемость</div><div class="stat-value">{{ attendance_percent }}%</div><div class="stat-meta">от общего списка</div></div></div>
<h2 class="h5 mb-3">Подтверждения участия</h2>
<div class="row mb-4">
<div class="col-md-4"><div class="card"><div class="card-body"><h5 class="card-title">Подтвердили</h5><p class="display-6 text-success">{{ rsvp_confirmed }}</p></div></div></div>
<div class="col-md-4"><div class="card"><div class="card-body"><h5 class="card-title">Отказались</h5><p class="display-6 text-muted">{{ rsvp_declined }}</p></div></div></div>
<div class="col-md-4"><div class="card"><div class="card-body"><h5 class="card-title">Ожидается ответ</h5><p class="display-6 text-warning">{{ rsvp_pending }}</p></div></div></div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Быстрый поиск</h5>
<form action="{{ url_for('main.admin_guests') }}" method="GET" class="d-flex">
<input type="text" name="q" class="form-control me-2" placeholder="Фамилия или ФИО" required>
<button type="submit" class="btn btn-primary">Найти</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Действия</h5>
<a href="{{ url_for('main.admin_report') }}" class="btn btn-success">Выгрузить отчёт Excel</a>
<form action="{{ url_for('main.admin_send_invitations') }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary">Разослать приглашения</button>
</form>
</div>
</div>
</div>
</div>
<h2 class="h5 mt-5 mb-3">Последние входы</h2><div class="table-card table-responsive"><table class="table table-hover">
<thead>
<tr>
<th>Время</th>
<th>ФИО</th>
<th>Отдел</th>
</tr>
</thead>
<tbody>
{% for check_in in recent_check_ins %}
<tr>
<td>{{ check_in.checked_in_at | localtime('%d.%m.%Y %H:%M:%S') }}</td>
<td>{{ check_in.guest.full_name }}</td>
<td>{{ check_in.guest.department }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-muted">Пока никто не отмечен</td>
</tr>
{% endfor %}
</tbody>
</table></div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Рассылка приглашений — InviteMe{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="card-title mb-0">Рассылка приглашений</h4>
<a href="{{ url_for('main.admin_email_settings') }}" class="btn btn-outline-secondary btn-sm">Настройки почты</a>
</div>
{% if event_counts %}
<form method="POST" action="{{ url_for('main.admin_email_send') }}" data-confirm="Начать рассылку приглашений?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="event_id" class="form-label">Мероприятие</label>
<select class="form-select" id="event_id" name="event_id" required>
<option value="">Выберите мероприятие</option>
{% for item in event_counts %}
<option value="{{ item.event.id }}">
{{ item.event.name }} — неотправлено: {{ item.unsent }}{% if item.failed %}, ошибок: {{ item.failed }}{% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="alert alert-info">
Перед рассылкой убедитесь, что в <a href="{{ url_for('main.admin_email_settings') }}">настройках почты</a> указаны корректные SMTP-данные.
</div>
<button type="submit" class="btn btn-primary">
Разослать приглашения
</button>
</form>
{% else %}
<div class="alert alert-warning">У вас пока нет мероприятий для рассылки.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+143
View File
@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Настройки почты — InviteMe{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-body">
<h4 class="card-title mb-4">Настройки почты для рассылки приглашений</h4>
<form method="POST" action="{{ url_for('main.admin_email_settings') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="form-label d-block">Способ отправки</label>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="mail_mode" id="mode_smtp" value="smtp" {% if settings.mail_mode == 'smtp' %}checked{% endif %}>
<label class="form-check-label" for="mode_smtp">SMTP</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="mail_mode" id="mode_ews" value="ews" {% if settings.mail_mode == 'ews' %}checked{% endif %}>
<label class="form-check-label" for="mode_ews">EWS (Exchange)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="mail_mode" id="mode_outlook" value="outlook" {% if settings.mail_mode == 'outlook' %}checked{% endif %}>
<label class="form-check-label" for="mode_outlook">Локальный Outlook</label>
</div>
<div class="form-text text-muted">
SMTP/EWS — для серверной установки. Outlook — только если InviteMe запущен на ПК с Outlook.
</div>
</div>
<div id="common-fields">
<div class="mb-3">
<label for="mail_username" class="form-label">Логин / Email</label>
<input type="text" class="form-control" id="mail_username" name="mail_username" value="{{ settings.mail_username }}">
</div>
<div class="mb-3">
<label for="mail_password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="mail_password" name="mail_password" placeholder="Оставьте пустым, чтобы не менять">
</div>
<div class="mb-4">
<label for="mail_default_sender" class="form-label">Имя отправителя (необязательно)</label>
<input type="text" class="form-control" id="mail_default_sender" name="mail_default_sender" value="{{ settings.mail_default_sender or '' }}">
</div>
</div>
<div id="smtp-fields" {% if settings.mail_mode != 'smtp' %}style="display:none;"{% endif %}>
<div class="row">
<div class="col-md-8 mb-3">
<label for="mail_server" class="form-label">SMTP-сервер</label>
<input type="text" class="form-control" id="mail_server" name="mail_server" value="{{ settings.mail_server }}">
</div>
<div class="col-md-4 mb-3">
<label for="mail_port" class="form-label">Порт</label>
<input type="number" class="form-control" id="mail_port" name="mail_port" value="{{ settings.mail_port }}">
</div>
</div>
<div class="mb-4 form-check">
<input type="checkbox" class="form-check-input" id="mail_use_ssl" name="mail_use_ssl" value="1" {% if settings.mail_use_ssl %}checked{% endif %}>
<label class="form-check-label" for="mail_use_ssl">Использовать SSL</label>
</div>
</div>
<div id="ews-fields" {% if settings.mail_mode != 'ews' %}style="display:none;"{% endif %}>
<div class="mb-4">
<label for="ews_url" class="form-label">EWS URL</label>
<input type="text" class="form-control" id="ews_url" name="ews_url" value="{{ settings.ews_url or '' }}" placeholder="https://mail.company.com/EWS/Exchange.asmx">
<div class="form-text text-muted">URL вида <code>https://mail.example.com/EWS/Exchange.asmx</code></div>
</div>
</div>
<div id="outlook-info" {% if settings.mail_mode != 'outlook' %}style="display:none;"{% endif %} class="alert alert-info">
Outlook должен быть установлен и запущен на сервере, где работает приложение. Браузер не может запустить Outlook на твоём компьютере.
</div>
<hr class="my-4">
<h5 class="mb-3">Копия отправленных писем в IMAP</h5>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="imap_save_copy" name="imap_save_copy" value="1" {% if settings.imap_save_copy %}checked{% endif %}>
<label class="form-check-label" for="imap_save_copy">Сохранять копию отправленного письма в папку IMAP</label>
<div class="form-text text-muted">Работает после успешной отправки через SMTP/EWS/Outlook. Для отправки писем по-прежнему используется выбранный выше способ.</div>
</div>
<div id="imap-fields">
<div class="row">
<div class="col-md-8 mb-3">
<label for="imap_server" class="form-label">IMAP-сервер</label>
<input type="text" class="form-control" id="imap_server" name="imap_server" value="{{ settings.imap_server or '' }}" placeholder="imap.example.com">
</div>
<div class="col-md-4 mb-3">
<label for="imap_port" class="form-label">Порт</label>
<input type="number" class="form-control" id="imap_port" name="imap_port" value="{{ settings.imap_port or 993 }}">
</div>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="imap_use_ssl" name="imap_use_ssl" value="1" {% if settings.imap_use_ssl %}checked{% endif %}>
<label class="form-check-label" for="imap_use_ssl">Использовать SSL/TLS</label>
</div>
<div class="mb-3">
<label for="imap_username" class="form-label">IMAP логин (если пустой — используется логин отправки)</label>
<input type="text" class="form-control" id="imap_username" name="imap_username" value="{{ settings.imap_username or '' }}">
</div>
<div class="mb-3">
<label for="imap_password" class="form-label">IMAP пароль (если пустой — используется пароль отправки)</label>
<input type="password" class="form-control" id="imap_password" name="imap_password" placeholder="Оставьте пустым, чтобы не менять">
</div>
<div class="mb-4">
<label for="imap_folder" class="form-label">Папка для сохранения копий</label>
<input type="text" class="form-control" id="imap_folder" name="imap_folder" value="{{ settings.imap_folder or 'Sent' }}" placeholder="Sent">
<div class="form-text text-muted">Название папки на сервере: обычно <code>Sent</code>, <code>Sent Items</code> или <code>Отправленные</code>.</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
<a href="{{ url_for('main.admin_email_send') }}" class="btn btn-outline-primary">Перейти к рассылке</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">Отправить тестовое письмо</h5>
<form method="POST" action="{{ url_for('main.admin_email_test') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group">
<input type="email" class="form-control" name="test_address" placeholder="address@example.com" required>
<button class="btn btn-success" type="submit">Отправить тест</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/email_settings.js') }}"></script>
{% endblock %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}{{ 'Редактировать' if event else 'Создать' }} мероприятие — InviteMe{% endblock %}
{% block content %}
<h1 class="mb-4">{{ 'Редактировать' if event else 'Создать' }} мероприятие</h1>
<div class="row">
<div class="col-md-6">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="name" class="form-label">Название *</label>
<input type="text" class="form-control" id="name" name="name" value="{{ event.name if event else '' }}" required>
</div>
<div class="mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ event.description if event else '' }}</textarea>
</div>
<div class="mb-3">
<label for="location" class="form-label">Место проведения</label>
<input type="text" class="form-control" id="location" name="location" value="{{ event.location if event else '' }}">
</div>
<div class="mb-3">
<label for="event_date" class="form-label">Начало</label>
<input type="datetime-local" class="form-control" id="event_date" name="event_date"
value="{{ event.event_date | localtime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
</div>
<div class="mb-3">
<label for="event_end_date" class="form-label">Окончание</label>
<input type="datetime-local" class="form-control" id="event_end_date" name="event_end_date"
value="{{ event.event_end_date | localtime('%Y-%m-%dT%H:%M') if event and event.event_end_date else '' }}">
</div>
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Приглашение и подтверждение участия</h5>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="rsvp_enabled" name="rsvp_enabled"
{% if not event or event.rsvp_enabled %}checked{% endif %}>
<label class="form-check-label" for="rsvp_enabled">Разрешить гостям подтверждать участие</label>
</div>
<div class="mb-3">
<label for="rsvp_deadline" class="form-label">Подтверждение до</label>
<input type="datetime-local" class="form-control" id="rsvp_deadline" name="rsvp_deadline"
value="{{ event.rsvp_deadline | localtime('%Y-%m-%dT%H:%M') if event and event.rsvp_deadline else '' }}">
</div>
<div class="mb-3">
<label for="invitation_subject" class="form-label">Тема письма</label>
<input type="text" class="form-control" id="invitation_subject" name="invitation_subject" maxlength="255"
value="{{ event.invitation_subject if event and event.invitation_subject else '' }}"
placeholder="Приглашение на мероприятие: название">
</div>
<div class="mb-3">
<label for="invitation_message" class="form-label">Текст для гостя</label>
<textarea class="form-control" id="invitation_message" name="invitation_message" rows="4" maxlength="5000"
placeholder="Организационная информация, дресс-код, контакты">{{ event.invitation_message if event and event.invitation_message else '' }}</textarea>
</div>
</div>
</div>
<div class="mb-3">
<label for="staff_ids" class="form-label">Сотрудники на входе</label>
<select class="form-select" id="staff_ids" name="staff_ids" multiple size="5">
{% for staff in staff_users %}
<option value="{{ staff.id }}" {% if event and staff in event.staff_members %}selected{% endif %}>{{ staff.username }}</option>
{% endfor %}
</select>
<div class="form-text">Только выбранные сотрудники увидят мероприятие и данные его гостей.</div>
</div>
{% if event %}
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="is_active" name="is_active" {% if event.is_active %}checked{% endif %}>
<label class="form-check-label" for="is_active">Активно</label>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Сохранить</button>
{% if event %}
<a href="{{ url_for('main.admin_event_ticket', event_id=event.id) }}" class="btn btn-outline-info">Конструктор билета</a>
{% endif %}
<a href="{{ url_for('main.admin_events') }}" class="btn btn-secondary">Отмена</a>
</form>
</div>
</div>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}Мероприятия — InviteMe{% endblock %}
{% block content %}
<header class="page-header"><div><div class="eyebrow">Планирование</div><h1 class="page-title">Мероприятия</h1><p class="page-subtitle">Настройте даты, площадки, билеты и правила RSVP.</p></div><a href="{{ url_for('main.admin_event_add') }}" class="btn btn-primary">+ Создать мероприятие</a></header>
<div class="row">
{% for event in events %}
<div class="col-md-4 mb-4">
<div class="card h-100 {% if not event.is_active %}border-secondary{% endif %}">
<div class="card-body">
<div class="d-flex justify-content-between gap-2 mb-3"><div><div class="eyebrow">{% if event.is_active %}Активно{% else %}Архив{% endif %}</div><h2 class="h5 card-title mb-0">{{ event.name }}</h2></div><span class="badge {% if event.is_active %}bg-success{% else %}bg-secondary{% endif %} align-self-start">{{ event.total_guests }} гостей</span></div>
<p class="card-text text-muted">
{% if event.event_date %}
{{ event.event_date | localtime('%d.%m.%Y %H:%M') }}<br>
{% endif %}
{% if event.location %}
{{ event.location }}<br>
{% endif %}
Отметились на входе: {{ event.checked_in }}
</p>
{% if event.description %}
<p class="card-text">{{ event.description }}</p>
{% endif %}
<div class="d-flex flex-wrap gap-2">
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-success btn-sm">Выбрать</button>
</form>
<a href="{{ url_for('main.admin_event_edit', event_id=event.id) }}" class="btn btn-outline-primary btn-sm">Изменить</a>
<a href="{{ url_for('main.admin_event_ticket', event_id=event.id) }}" class="btn btn-outline-info btn-sm">Билет</a>
{% if current_user and current_user.is_admin %}
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteEventModal{{ event.id }}">Удалить</button>
{% endif %}
{% if not event.is_active %}
<span class="badge bg-secondary">Неактивно</span>
{% endif %}
</div>
</div>
<div class="card-footer text-muted">
Создано: {{ event.created_at | localdate('%d.%m.%Y') }}
</div>
</div>
</div>
{% if current_user and current_user.is_admin %}
<!-- Modal for hard delete -->
<div class="modal fade" id="deleteEventModal{{ event.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('main.admin_event_delete_hard', event_id=event.id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h5 class="modal-title">Удаление мероприятия</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Вы собираетесь полностью удалить мероприятие <strong>{{ event.name }}</strong>.</p>
<p class="text-danger">Все гости, QR-коды, билеты и логотипы этого мероприятия будут удалены безвозвратно.</p>
<div class="mb-3">
<label for="deleteReason{{ event.id }}" class="form-label">Причина удаления <span class="text-danger">*</span></label>
<textarea class="form-control" id="deleteReason{{ event.id }}" name="reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-danger">Удалить</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="col-12">
<div class="alert alert-info">Пока нет ни одного мероприятия. <a href="{{ url_for('main.admin_event_add') }}">Создайте первое</a>.</div>
</div>
{% endfor %}
</div>
{% endblock %}
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}{{ 'Редактировать' if guest else 'Добавить' }} гостя — InviteMe{% endblock %}
{% block content %}
<h1 class="mb-4">{{ 'Редактировать' if guest else 'Добавить' }} гостя</h1>
<p class="text-muted">Мероприятие: {{ event.name }}</p>
<div class="row">
<div class="col-md-6">
{% if guest %}
<div class="mb-3">
<a href="{{ url_for('main.admin_guest_ticket', guest_id=guest.id) }}" target="_blank" class="btn btn-outline-info">Посмотреть билет</a>
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="btn btn-outline-success">QR-код</a>
</div>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-6 mb-3">
<label for="last_name" class="form-label">Фамилия</label>
<input type="text" class="form-control" id="last_name" name="last_name" value="{{ guest.last_name if guest else '' }}" required>
</div>
<div class="col-md-6 mb-3">
<label for="first_name" class="form-label">Имя</label>
<input type="text" class="form-control" id="first_name" name="first_name" value="{{ guest.first_name if guest else '' }}" required>
</div>
</div>
<div class="mb-3">
<label for="middle_name" class="form-label">Отчество</label>
<input type="text" class="form-control" id="middle_name" name="middle_name" value="{{ guest.middle_name if guest else '' }}">
</div>
<div class="mb-3">
<label for="department" class="form-label">Отдел</label>
<input type="text" class="form-control" id="department" name="department" value="{{ guest.department if guest else '' }}" required>
</div>
<div class="mb-3">
<label for="position" class="form-label">Должность</label>
<input type="text" class="form-control" id="position" name="position" value="{{ guest.position if guest else '' }}">
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{ guest.email if guest else '' }}" required>
</div>
<div class="mb-3">
<label for="phone" class="form-label">Телефон</label>
<input type="text" class="form-control" id="phone" name="phone" value="{{ guest.phone if guest else '' }}">
</div>
<div class="mb-3">
<label for="photo_filename" class="form-label">Название фото</label>
<input type="text" class="form-control" id="photo_filename" name="photo_filename" value="{{ guest.photo_filename if guest else '' }}">
</div>
{% if not guest %}
<div class="mb-3">
<label for="base_url" class="form-label">Базовый URL для QR-кода (опционально)</label>
<input type="url" class="form-control" id="base_url" name="base_url" placeholder="https://events.company.com">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="send_invitation" name="send_invitation" checked>
<label class="form-check-label" for="send_invitation">Отправить приглашение сразу</label>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{{ url_for('main.admin_guests') }}" class="btn btn-secondary">Отмена</a>
</form>
</div>
</div>
{% endblock %}
+110
View File
@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Гости — InviteMe{% endblock %}
{% block content %}
<header class="page-header"><div><div class="eyebrow">Аудитория</div><h1 class="page-title">Гости</h1><p class="page-subtitle">{{ event.name }} · приглашения, ответы и статусы входа.</p></div><a href="{{ url_for('main.admin_guest_add') }}" class="btn btn-primary">+ Добавить гостя</a></header>
<form method="GET" class="row g-3 mb-4">
<div class="col-auto">
<input type="text" name="q" class="form-control" placeholder="Поиск по ФИО, отделу, email..." value="{{ search }}">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">Найти</button>
</div>
</form>
<div class="table-card table-responsive">
<table class="table table-striped table-hover align-middle">
<thead>
<tr>
<th>ФИО</th>
<th>Отдел</th>
<th>Должность</th>
<th>Email</th>
<th>Телефон</th>
<th>Фото</th>
<th>QR</th>
<th>Приглашение</th>
<th>RSVP</th>
<th>Вход</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for guest in guests %}
<tr class="{{ 'table-secondary' if not guest.is_active }}">
<td>{{ guest.build_full_name() }}</td>
<td>{{ guest.department }}</td>
<td>{{ guest.position or '-' }}</td>
<td>{{ guest.email }}</td>
<td>{{ guest.phone or '-' }}</td>
<td>
{% if guest.has_photo_file %}
<a href="{{ guest.photo_url }}" target="_blank" class="badge bg-success text-decoration-none">Есть</a>
{% else %}
<span class="badge bg-warning text-dark" title="{% if guest.photo_filename %}Имя файла указано, но файл не загружен{% else %}Не указано{% endif %}">Нет</span>
{% endif %}
</td>
<td>
{% if guest.qr_filename %}
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-success text-decoration-none">Да</a>
{% else %}
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-secondary text-decoration-none">Нет</a>
{% endif %}
</td>
<td>
{% if guest.invitation_status == 'sent' %}<span class="badge bg-success">Отправлено</span>
{% elif guest.invitation_status == 'failed' %}<span class="badge bg-danger" title="{{ guest.invitation_last_error or '' }}">Ошибка</span>
{% elif guest.invitation_status == 'sending' %}<span class="badge bg-info text-dark">Отправляется</span>
{% else %}<span class="badge bg-secondary">Не отправлено</span>{% endif %}
{% if guest.invitation_attempts %}<small class="d-block text-muted">Попыток: {{ guest.invitation_attempts }}</small>{% endif %}
</td>
<td>
{% if guest.rsvp_status == 'confirmed' %}<span class="badge bg-success">Будет</span>
{% elif guest.rsvp_status == 'declined' %}<span class="badge bg-secondary">Не будет</span>
{% else %}<span class="badge bg-warning text-dark">Ожидается</span>{% endif %}
{% if guest.dietary_requirements or guest.rsvp_comment %}<small class="d-block text-info" title="{{ guest.dietary_requirements or guest.rsvp_comment }}">Есть комментарий</small>{% endif %}
</td>
<td>
{% if guest.is_checked_in %}
<span class="badge bg-success">Пришёл</span>
{% else %}
<span class="badge bg-secondary">Не пришёл</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('main.admin_guest_edit', guest_id=guest.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
<a href="{{ url_for('main.admin_guest_ticket', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-info">Билет</a>
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-success">QR</a>
<a href="{{ url_for('main.public_invitation', token=guest.token) }}" target="_blank" class="btn btn-sm btn-outline-light">Страница</a>
<form action="{{ url_for('main.admin_guest_resend', guest_id=guest.id) }}" method="POST" class="d-inline" data-confirm="Отправить приглашение повторно?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning">Пригласить</button>
</form>
<form action="{{ url_for('main.admin_guest_delete', guest_id=guest.id) }}" method="POST" class="d-inline" data-confirm="Заблокировать гостя?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="11" class="text-center text-muted">Гости не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<nav aria-label="Страницы списка гостей"><ul class="pagination justify-content-center">
{% if pagination.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('main.admin_guests', page=pagination.prev_num, q=search) }}">Назад</a></li>{% endif %}
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}<li class="page-item {% if page_num == pagination.page %}active{% endif %}"><a class="page-link" href="{{ url_for('main.admin_guests', page=page_num, q=search) }}">{{ page_num }}</a></li>
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
{% endfor %}
{% if pagination.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('main.admin_guests', page=pagination.next_num, q=search) }}">Вперёд</a></li>{% endif %}
</ul></nav>
{% endif %}
{% endblock %}
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Аудит-лог — InviteMe{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Аудит-лог</h1>
</div>
<form method="GET" class="row g-3 mb-4">
<div class="col-md-3">
<select name="action" class="form-select">
<option value="">Все действия</option>
{% for action in actions %}
<option value="{{ action }}" {% if action == action_filter %}selected{% endif %}>{{ action }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<input type="text" name="user" class="form-control" placeholder="Пользователь" value="{{ user_filter }}">
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary">Фильтр</button>
<a href="{{ url_for('main.admin_logs') }}" class="btn btn-secondary">Сброс</a>
</div>
</form>
<div class="table-responsive">
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th>Время</th>
<th>Пользователь</th>
<th>IP</th>
<th>Действие</th>
<th>Объект</th>
<th>ID объекта</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
{% for log in logs.items %}
<tr>
<td>{{ log.created_at | localtime('%d.%m.%Y %H:%M:%S') }}</td>
<td>{{ log.username or '-' }}</td>
<td>{{ log.ip_address or '-' }}</td>
<td><span class="badge bg-secondary">{{ log.action }}</span></td>
<td>{{ log.target_type or '-' }}</td>
<td>{{ log.target_id or '-' }}</td>
<td>{{ log.details or '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center text-muted">Записи не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if logs.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if logs.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.admin_logs', page=logs.prev_num, action=action_filter, user=user_filter) }}">Назад</a>
</li>
{% endif %}
{% for page_num in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == logs.page %}
<li class="page-item active"><span class="page-link">{{ page_num }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{{ url_for('main.admin_logs', page=page_num, action=action_filter, user=user_filter) }}">{{ page_num }}</a></li>
{% endif %}
{% else %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endfor %}
{% if logs.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.admin_logs', page=logs.next_num, action=action_filter, user=user_filter) }}">Вперёд</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% block title %}Админ-панель — InviteMe{% endblock %}
{% block content %}
<header class="page-header"><div><div class="eyebrow">Центр управления</div><h1 class="page-title">Добро пожаловать, {{ current_user.username }}</h1><p class="page-subtitle">Управляйте гостями, приглашениями и входом из одного рабочего пространства.</p></div>{% if current_user.role in ('admin', 'organizer') %}<a class="btn btn-primary" href="{{ url_for('main.admin_event_add') }}">+ Создать мероприятие</a>{% endif %}</header>
<div class="module-grid">
{% for card in cards %}
<article class="module-card {% if card.disabled %}opacity-50{% endif %}">
<div class="module-icon">{{ card.icon }}</div>
<h2>{{ card.title }}</h2>
<p>{{ card.description }}</p>
{% if card.disabled %}
<a href="{{ card.url }}" class="btn btn-outline-secondary disabled">Сначала выберите мероприятие</a>
{% else %}
<a href="{{ card.url }}" class="btn btn-primary">{{ card.btn }}</a>
{% endif %}
</article>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Конструктор билета — {{ event.name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Конструктор пригласительного билета</h1>
<a href="{{ url_for('main.admin_events') }}" class="btn btn-secondary">← К мероприятиям</a>
</div>
<div class="row">
<div class="col-lg-5">
<div class="card">
<div class="card-body">
<h5 class="card-title">Настройки дизайна</h5>
<form method="POST" id="ticket-form" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="logo_file" class="form-label">Логотип компании</label>
<input type="file" class="form-control" id="logo_file" name="logo_file" accept=".jpg,.jpeg,.png,.webp">
{% if event.logo_filename %}
<div class="mt-2 d-flex align-items-center gap-2">
<img src="{{ url_for('main.event_logo', event_id=event.id) }}" alt="Логотип" style="max-height: 60px; max-width: 200px;">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="delete_logo" name="delete_logo" value="1">
<label class="form-check-label" for="delete_logo">Удалить</label>
</div>
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="ticket_title" class="form-label">Заголовок билета</label>
<input type="text" class="form-control" id="ticket_title" name="ticket_title" value="{{ event.ticket_title or event.name }}">
</div>
<div class="mb-3">
<label for="ticket_subtitle" class="form-label">Подзаголовок</label>
<input type="text" class="form-control" id="ticket_subtitle" name="ticket_subtitle" value="{{ event.ticket_subtitle or '' }}">
</div>
<div class="mb-3">
<label for="ticket_body_text" class="form-label">Дополнительный текст</label>
<textarea class="form-control" id="ticket_body_text" name="ticket_body_text" rows="3">{{ event.ticket_body_text or '' }}</textarea>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label for="ticket_background_color" class="form-label">Фон</label>
<input type="color" class="form-control form-control-color w-100" id="ticket_background_color" name="ticket_background_color" value="{{ event.ticket_background_color or '#ffffff' }}">
</div>
<div class="col-md-4 mb-3">
<label for="ticket_text_color" class="form-label">Текст</label>
<input type="color" class="form-control form-control-color w-100" id="ticket_text_color" name="ticket_text_color" value="{{ event.ticket_text_color or '#000000' }}">
</div>
<div class="col-md-4 mb-3">
<label for="ticket_accent_color" class="form-label">Акцент</label>
<input type="color" class="form-control form-control-color w-100" id="ticket_accent_color" name="ticket_accent_color" value="{{ event.ticket_accent_color or '#0d6efd' }}">
</div>
</div>
<div class="mb-3">
<label for="ticket_layout" class="form-label">Ориентация</label>
<select class="form-select" id="ticket_layout" name="ticket_layout">
<option value="portrait" {% if (event.ticket_layout or 'portrait') == 'portrait' %}selected{% endif %}>Портретная (600×900)</option>
<option value="landscape" {% if (event.ticket_layout or 'portrait') == 'landscape' %}selected{% endif %}>Альбомная (900×600)</option>
</select>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="ticket_show_qr" name="ticket_show_qr" {% if event.ticket_show_qr %}checked{% endif %}>
<label class="form-check-label" for="ticket_show_qr">Показывать QR-код</label>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="ticket_show_photo" name="ticket_show_photo" {% if event.ticket_show_photo %}checked{% endif %}>
<label class="form-check-label" for="ticket_show_photo">Показывать фото гостя</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" id="refresh-preview" class="btn btn-outline-secondary">Обновить предпросмотр</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card">
<div class="card-body text-center">
<h5 class="card-title">Предпросмотр</h5>
<p class="text-muted">Пример билета с тестовыми данными</p>
<img id="ticket-preview" src="{{ url_for('main.admin_event_ticket_preview', event_id=event.id) }}" data-preview-url="{{ url_for('main.admin_event_ticket_preview', event_id=event.id) }}" alt="Предпросмотр билета" class="img-fluid border shadow-sm" style="max-height: 80vh;">
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/ticket_constructor.js') }}"></script>
{% endblock %}
+112
View File
@@ -0,0 +1,112 @@
{% extends "base.html" %}
{% block title %}Загрузка данных — InviteMe{% endblock %}
{% block content %}
<h1 class="mb-4">Загрузка данных</h1>
<p class="text-muted">Мероприятие: {{ event.name }}</p>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">1. Загрузить Excel со списком гостей</h5>
<p class="text-muted">Столбцы: Отдел, Фамилия, Имя, Отчество, Должность, Почта, Телефон, Название фото</p>
<a href="{{ url_for('main.admin_upload_template') }}" class="btn btn-outline-info btn-sm mb-3">Скачать шаблон Excel</a>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="excel_file" class="form-label">Excel-файл</label>
<input type="file" class="form-control" id="excel_file" name="excel_file" accept=".xlsx" required>
</div>
<div class="mb-3">
<label for="base_url" class="form-label">Базовый URL для QR-кодов (опционально)</label>
<input type="url" class="form-control" id="base_url" name="base_url" placeholder="https://events.company.com">
</div>
<button type="submit" class="btn btn-primary">Загрузить и сгенерировать QR</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">2. Загрузить фото гостей</h5>
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «Название фото»</p>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="photo_files" class="form-label">Фото (можно несколько)</label>
<input type="file" class="form-control" id="photo_files" name="photo_files" accept=".jpg,.jpeg,.png,.webp" multiple>
</div>
<div class="mb-3">
<label for="zip_file" class="form-label">Или ZIP-архив</label>
<input type="file" class="form-control" id="zip_file" name="zip_file" accept=".zip">
</div>
<button type="submit" class="btn btn-primary">Загрузить фото</button>
</form>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Пример заполнения Excel-таблицы</h5>
<p class="text-muted">Имена столбцов должны совпадать точно. Значение в столбце «Название фото» должно совпадать с именем файла фото, включая расширение.</p>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead>
<tr>
<th>Отдел</th>
<th>Фамилия</th>
<th>Имя</th>
<th>Отчество</th>
<th>Должность</th>
<th>Почта</th>
<th>Телефон</th>
<th>Название фото</th>
</tr>
</thead>
<tbody>
<tr>
<td>IT</td>
<td>Иванов</td>
<td>Иван</td>
<td>Иванович</td>
<td>Разработчик</td>
<td>ivanov@company.com</td>
<td>+7 (999) 111-22-33</td>
<td>ivanov.jpg</td>
</tr>
<tr>
<td>Маркетинг</td>
<td>Петрова</td>
<td>Анна</td>
<td>Сергеевна</td>
<td>Маркетолог</td>
<td>petrova@company.com</td>
<td>+7 (999) 222-33-44</td>
<td>petrova.png</td>
</tr>
<tr>
<td>Продажи</td>
<td>Сидоров</td>
<td>Алексей</td>
<td>Петрович</td>
<td>Менеджер</td>
<td>sidorov@company.com</td>
<td>+7 (999) 333-44-55</td>
<td>sidorov.jpg</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}{{ 'Редактировать' if user else 'Добавить' }} пользователя — InviteMe{% endblock %}
{% block content %}
<h1 class="mb-4">{{ 'Редактировать' if user else 'Добавить' }} пользователя</h1>
<div class="row">
<div class="col-md-6">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label">Логин *</label>
<input type="text" class="form-control" id="username" name="username" value="{{ user.username if user else '' }}" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль {% if user %}(оставьте пустым, чтобы не менять){% else %}*{% endif %}</label>
<input type="password" class="form-control" id="password" name="password" minlength="{{ config.MIN_PASSWORD_LENGTH }}" {% if not user %}required{% endif %}>
<div class="form-text">Минимум {{ config.MIN_PASSWORD_LENGTH }} символов и три группы символов.</div>
</div>
<div class="mb-3">
<label for="role" class="form-label">Роль *</label>
<select class="form-select" id="role" name="role" required>
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Администратор</option>
<option value="organizer" {% if user and user.role == 'organizer' %}selected{% endif %}>Организатор</option>
<option value="staff" {% if user and user.role == 'staff' %}selected{% endif %} {% if not user %}selected{% endif %}>Сотрудник на входе</option>
</select>
</div>
{% if user %}
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="is_active" name="is_active" {% if user.is_active %}checked{% endif %}>
<label class="form-check-label" for="is_active">Активен</label>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{{ url_for('main.admin_users') }}" class="btn btn-secondary">Отмена</a>
</form>
</div>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block title %}Пользователи — InviteMe{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Пользователи</h1>
<a href="{{ url_for('main.admin_user_add') }}" class="btn btn-primary">Добавить пользователя</a>
</div>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Логин</th>
<th>Роль</th>
<th>Статус</th>
<th>Создан</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{{ 'table-secondary' if not user.is_active }}">
<td>{{ user.username }}</td>
<td>
{% if user.role == 'admin' %}
<span class="badge bg-danger">Админ</span>
{% elif user.role == 'organizer' %}
<span class="badge bg-warning text-dark">Организатор</span>
{% else %}
<span class="badge bg-info text-dark">Сотрудник</span>
{% endif %}
</td>
<td>
{% if user.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Заблокирован</span>
{% endif %}
</td>
<td>{{ user.created_at | localtime('%d.%m.%Y %H:%M') }}</td>
<td>
<a href="{{ url_for('main.admin_user_edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% if current_user.id != user.id %}
<form action="{{ url_for('main.admin_user_delete', user_id=user.id) }}" method="POST" class="d-inline" data-confirm="Заблокировать пользователя?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center text-muted">Пользователи не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}InviteMe{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GASXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"><link rel="icon" type="image/png" href="{{ url_for('static', filename='images/inviteme_logo.png') }}">
</head>
<body class="{% if current_user %}has-app-shell{% else %}public-page{% endif %}">
{% macro nav_link(endpoint, label, icon) %}
<a class="sidebar-link {% if request.endpoint == endpoint %}active{% endif %}" href="{{ url_for(endpoint) }}"><svg class="nav-icon" aria-hidden="true"><use href="{{ url_for('static', filename='images/icons.svg') }}#{{ icon }}"></use></svg><span>{{ label }}</span></a>
{% endmacro %}
{% if current_user %}
<div class="app-shell">
<aside class="app-sidebar" id="app-sidebar">
<a class="sidebar-brand" href="{{ url_for('main.admin_panel') }}"><span class="brand-mark">I</span><span><strong>InviteMe</strong><small>Event operations</small></span></a>
<nav class="sidebar-nav" aria-label="Основная навигация">
<p class="nav-caption">Рабочая область</p>
{{ nav_link('main.admin_panel', 'Обзор', 'grid') }}
{% if current_user.role in ('admin', 'organizer') %}{{ nav_link('main.admin_events', 'Мероприятия', 'calendar') }}{% endif %}
{% if current_event and current_user.role in ('admin', 'organizer') %}
{{ nav_link('main.admin_dashboard', 'Аналитика', 'chart') }}{{ nav_link('main.admin_guests', 'Гости', 'users') }}{{ nav_link('main.admin_upload', 'Импорт гостей', 'upload') }}{{ nav_link('main.admin_email_send', 'Рассылки', 'mail') }}
{% endif %}
{{ nav_link('main.check_in', 'Контроль входа', 'scan') }}
{% if current_user.role == 'admin' %}
<p class="nav-caption nav-caption-spaced">Система</p>
{{ nav_link('main.admin_users', 'Пользователи', 'shield') }}{{ nav_link('main.admin_logs', 'Журнал действий', 'log') }}{{ nav_link('main.admin_email_settings', 'Настройки почты', 'settings') }}
{% endif %}
</nav>
<div class="sidebar-profile"><span class="avatar">{{ current_user.username[:1] | upper }}</span><span class="profile-copy"><strong>{{ current_user.username }}</strong><small>{{ current_user.role }}</small></span><form method="POST" action="{{ url_for('main.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="icon-button" type="submit" title="Выйти" aria-label="Выйти"><svg class="nav-icon"><use href="{{ url_for('static', filename='images/icons.svg') }}#logout"></use></svg></button></form></div>
</aside>
<button class="sidebar-backdrop" type="button" data-sidebar-toggle aria-label="Закрыть меню"></button>
<section class="app-main">
<header class="app-topbar">
<button class="icon-button mobile-menu" type="button" data-sidebar-toggle aria-controls="app-sidebar" aria-label="Открыть меню"><svg class="nav-icon"><use href="{{ url_for('static', filename='images/icons.svg') }}#menu"></use></svg></button>
<div class="topbar-context"><small>Рабочее пространство</small><strong>{% if current_event %}{{ current_event.name }}{% else %}Все мероприятия{% endif %}</strong></div>
{% if current_user.role in ('admin', 'organizer') and available_events %}
<div class="dropdown ms-auto"><button class="event-switcher dropdown-toggle" type="button" data-bs-toggle="dropdown"><span class="status-dot"></span>{{ current_event.name if current_event else 'Выбрать мероприятие' }}</button><ul class="dropdown-menu dropdown-menu-end">{% for event in available_events %}<li><form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button type="submit" class="dropdown-item {% if current_event and current_event.id == event.id %}active{% endif %}">{{ event.name }}</button></form></li>{% endfor %}</ul></div>
{% endif %}
</header>
<main class="content-wrap">
{% else %}
<header class="public-header"><a class="sidebar-brand" href="{{ url_for('main.index') }}"><span class="brand-mark">I</span><span><strong>InviteMe</strong><small>Guest experience</small></span></a></header><main class="public-content">
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}<div class="flash-stack">{% for category, message in messages %}<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>{% endfor %}</div>{% endif %}{% endwith %}
{% block content %}{% endblock %}</main>
{% if current_user %}</section></div>{% endif %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script><script src="{{ url_for('static', filename='js/app.js') }}"></script>{% block scripts %}{% endblock %}
</body></html>
+92
View File
@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Проверка приглашения — InviteMe{% endblock %}
{% block content %}
<div class="checkin-container">
<header class="page-header"><div><div class="eyebrow">Операционный режим</div><h1 class="page-title">Контроль входа</h1><p class="page-subtitle">Сканируйте QR-код или быстро найдите гостя по имени.</p></div>{% if checkin_event %}<span class="badge bg-success">● {{ checkin_event.name }}</span>{% endif %}</header>
{% if active_events %}
<div class="row justify-content-center mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Выберите мероприятие</h5>
<form method="POST" action="{{ url_for('main.check_in', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<select id="event-select" name="event_id" class="form-select" data-auto-submit>
<option value="">-- Мероприятие --</option>
{% for event in active_events %}
<option value="{{ event.id }}" {% if checkin_event and checkin_event.id == event.id %}selected{% endif %}>
{{ event.name }}
</option>
{% endfor %}
</select>
</div>
<noscript><button type="submit" class="btn btn-primary">Выбрать</button></noscript>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% if checkin_event %}
<div class="alert alert-info">Работаем с мероприятием: <strong>{{ checkin_event.name }}</strong></div>
{% endif %}
<div class="checkin-grid">
<div>
<div class="card h-100">
<div class="card-body">
<div class="eyebrow">Камера</div><h2 class="h5 card-title">Сканировать QR-код</h2>
<div class="mb-3">
<label for="camera-select" class="form-label">Камера</label>
<select id="camera-select" class="form-select">
<option value="">-- Выберите камеру --</option>
</select>
</div>
<div id="qr-reader" class="qr-reader"></div>
<button id="start-scan" class="btn btn-primary w-100 mt-2">Включить камеру</button>
<button id="stop-scan" class="btn btn-secondary w-100 mt-2 d-none">Выключить камеру</button>
</div>
</div>
</div>
<div>
<div class="card h-100">
<div class="card-body">
<div class="eyebrow">Ручной поиск</div><h2 class="h5 card-title">Найти гостя по ФИО</h2>
<input type="text" id="search-input" class="form-control" placeholder="Введите фамилию..." autocomplete="off">
<div id="search-results" class="list-group mt-2"></div>
</div>
</div>
</div>
</div>
<div id="guest-card" class="card mt-4 d-none">
<div class="card-body text-center">
<img id="guest-photo" src="" alt="Фото гостя" class="guest-photo mb-3">
<h2 id="guest-name" class="card-title"></h2>
<p id="guest-department" class="lead"></p>
<p id="guest-position" class="text-muted"></p>
<p id="guest-email" class="text-muted"></p>
<p id="guest-event" class="text-info"></p>
<div id="check-in-status" class="alert d-none"></div>
<button id="check-in-btn" class="btn btn-success btn-lg w-100">Отметить вход</button>
<button id="undo-check-in-btn" class="btn btn-outline-warning w-100 mt-2 d-none">Отменить отметку входа</button>
<button id="reset-btn" class="btn btn-outline-secondary w-100 mt-2">Сбросить</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"
integrity="sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+"
crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='js/checkin.js') }}"></script>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Приглашение на {{ event_name }}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
<h2 style="color: #0d6efd;">Приглашение на мероприятие</h2>
<p>Здравствуйте, <strong>{{ guest.full_name }}</strong>!</p>
<p>Вы приглашены на мероприятие <strong>{{ event_name }}</strong>.</p>
{% if invitation_message %}
<p>{{ invitation_message }}</p>
{% endif %}
<p style="text-align: center; margin: 24px 0;">
<a href="{{ invitation_url }}" style="display:inline-block;padding:12px 20px;background:#0891b2;color:#fff;text-decoration:none;border-radius:8px;font-weight:bold;">
Открыть приглашение и подтвердить участие
</a>
</p>
{% if ticket_cid %}
<p>Ваш персональный пригласительный билет:</p>
<div style="text-align: center; margin: 20px 0;">
<img src="cid:{{ ticket_cid }}" alt="Пригласительный билет" style="max-width: 100%; border: 1px solid #ddd; border-radius: 8px;">
</div>
{% else %}
<p>Ваш персональный QR-код для входа:</p>
<div style="text-align: center; margin: 20px 0;">
<img src="cid:qr_code_image" alt="QR-код" style="max-width: 250px;">
</div>
{% endif %}
{% if additional_text %}
<p>{{ additional_text }}</p>
{% endif %}
<p style="color: #666; font-size: 14px;">
Пожалуйста, предъявите этот QR-код или билет при входе. Не передавайте их другим лицам.
</p>
</div>
</body>
</html>
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Тестовое письмо InviteMe</title>
</head>
<body style="margin:0;padding:0;background-color:#0f172a;font-family:'Segoe UI',Roboto,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#0f172a;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background:rgba(30,41,59,0.95);border:1px solid rgba(148,163,184,0.15);border-radius:16px;overflow:hidden;">
<tr>
<td style="padding:30px;text-align:center;">
<h2 style="color:#22d3ee;margin:0 0 16px 0;">InviteMe</h2>
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0;">
Это тестовое письмо. Если вы его видите, SMTP-настройки работают корректно.
</p>
</td>
</tr>
<tr>
<td style="padding:20px 30px;background:rgba(15,23,42,0.6);text-align:center;">
<p style="color:#94a3b8;font-size:13px;margin:0;">
Отправлено из панели управления InviteMe
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Приглашение — {{ event.name }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card invitation-card">
<div class="card-body p-4 p-md-5">
<div class="eyebrow">Персональное приглашение</div>
<h1 class="display-5 mb-3">{{ event.name }}</h1>
<p class="lead text-muted">Здравствуйте, <span class="text-white">{{ guest.build_full_name() }}</span>!</p>
{% if event.description %}<p>{{ event.description }}</p>{% endif %}
<div class="row g-3 my-4">
{% if event.event_date %}
<div class="col-md-6"><div class="stat-card"><div class="stat-label">Дата и время</div><div class="h5 mt-2 mb-0">{{ event.event_date | localtime('%d.%m.%Y %H:%M') }}</div></div></div>
{% endif %}
{% if event.location %}
<div class="col-md-6"><div class="stat-card"><div class="stat-label">Место</div><div class="h5 mt-2 mb-0">{{ event.location }}</div></div></div>
{% endif %}
</div>
{% if event.invitation_message %}
<div class="alert alert-info invitation-message">{{ event.invitation_message }}</div>
{% endif %}
<div class="d-flex flex-wrap gap-2 mb-4">
<a class="btn btn-outline-info" href="{{ url_for('main.public_invitation_ticket', token=guest.token) }}">Скачать билет</a>
{% if event.event_date %}
<a class="btn btn-outline-primary" href="{{ url_for('main.public_invitation_calendar', token=guest.token) }}">Добавить в календарь</a>
{% endif %}
</div>
{% if guest.rsvp_status == 'confirmed' %}
<div class="alert alert-success">Вы подтвердили участие.</div>
{% elif guest.rsvp_status == 'declined' %}
<div class="alert alert-secondary">Вы сообщили, что не сможете присутствовать.</div>
{% endif %}
{% if rsvp_open %}
<div class="eyebrow mt-5">RSVP</div><h2 class="h4">Подтвердите участие</h2>
{% if event.rsvp_deadline %}
<p class="text-muted">Ответ можно изменить до {{ event.rsvp_deadline | localtime('%d.%m.%Y %H:%M') }}.</p>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Ваш ответ</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="rsvp_status" id="rsvp-confirmed" value="confirmed" {% if guest.rsvp_status == 'confirmed' %}checked{% endif %} required>
<label class="form-check-label" for="rsvp-confirmed">Буду участвовать</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="rsvp_status" id="rsvp-declined" value="declined" {% if guest.rsvp_status == 'declined' %}checked{% endif %} required>
<label class="form-check-label" for="rsvp-declined">Не смогу прийти</label>
</div>
</div>
</div>
<div class="mb-3">
<label for="dietary_requirements" class="form-label">Особенности питания</label>
<textarea class="form-control" id="dietary_requirements" name="dietary_requirements" maxlength="1000" rows="2">{{ guest.dietary_requirements or '' }}</textarea>
</div>
<div class="mb-3">
<label for="rsvp_comment" class="form-label">Комментарий организатору</label>
<textarea class="form-control" id="rsvp_comment" name="rsvp_comment" maxlength="1000" rows="3">{{ guest.rsvp_comment or '' }}</textarea>
</div>
<button class="btn btn-primary btn-lg" type="submit">Сохранить ответ</button>
</form>
{% elif event.rsvp_enabled %}
<div class="alert alert-warning">Приём ответов завершён.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block title %}Вход — InviteMe{% endblock %}
{% block content %}
<div class="auth-layout"><div class="card auth-card">
<div class="text-center mb-3">
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="login-logo">
</div>
<div class="eyebrow text-center">Панель управления</div><h1 class="h3 text-center mb-2">С возвращением</h1>
<p class="text-muted text-center mb-4">Войдите, чтобы продолжить работу</p>
<form method="POST" action="{{ url_for('main.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label">Логин</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100 mt-2">Войти в систему</button>
</form>
</div></div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Первоначальная настройка — InviteMe{% endblock %}
{% block content %}
<div class="auth-layout"><div class="card auth-card">
<div class="text-center mb-4">
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="login-logo">
</div>
<div class="eyebrow text-center">Первый запуск</div><h1 class="h3 text-center mb-3">Настроим InviteMe</h1>
<p class="text-muted text-center mb-4">
Создайте первого администратора, чтобы начать работу с InviteMe.
</p>
<form method="POST" action="{{ url_for('main.setup') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label">Логин администратора</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="password" name="password" minlength="{{ config.MIN_PASSWORD_LENGTH }}" required>
<div class="form-text">Минимум {{ config.MIN_PASSWORD_LENGTH }} символов и три группы символов.</div>
</div>
<div class="mb-4">
<label for="password_confirm" class="form-label">Повторите пароль</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm" minlength="{{ config.MIN_PASSWORD_LENGTH }}" required>
</div>
<button type="submit" class="btn btn-primary w-100">Создать администратора</button>
</form>
</div></div>
{% endblock %}