469 lines
23 KiB
HTML
469 lines
23 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Рассылки - Домовой Бот{% endblock %}
|
||
|
||
{% block nav_broadcast %}active{% endblock %}
|
||
|
||
{% block extra_style %}
|
||
<style>
|
||
#preview { max-width: 400px; margin-top: 1rem; display: none; }
|
||
#preview img { max-width: 100%; border-radius: 8px; }
|
||
.smart-options { display: none; background: #f0f7ff; padding: 20px; border-radius: 8px; margin-top: 15px; border: 2px solid #667eea; }
|
||
.smart-options.active { display: block; }
|
||
.broadcast-type-selector { display: flex; gap: 15px; margin-bottom: 20px; }
|
||
.broadcast-type-option { flex: 1; padding: 20px; border: 2px solid #e2e8f0; border-radius: 8px; cursor: pointer; transition: all 0.3s; text-align: center; }
|
||
.broadcast-type-option:hover { border-color: #667eea; background: #f8f9ff; }
|
||
.broadcast-type-option.active { border-color: #667eea; background: #eef2ff; }
|
||
.broadcast-type-option h4 { margin-bottom: 10px; }
|
||
.broadcast-type-option .icon { font-size: 2.5em; margin-bottom: 10px; }
|
||
.nav-tabs .nav-link { color: #495057; }
|
||
.nav-tabs .nav-link.active { font-weight: bold; color: #667eea; }
|
||
.broadcast-item { background: white; padding: 15px; border-radius: 8px; margin-bottom: 10px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||
.badge-smart { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; }
|
||
.badge-regular { background: #6c757d; color: white; }
|
||
.progress-mini { width: 100px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; display: inline-block; vertical-align: middle; margin-left: 5px; }
|
||
.progress-mini-fill { height: 100%; background: linear-gradient(90deg, #48bb78, #38a169); }
|
||
.readers-table { font-size: 0.9em; }
|
||
.readers-table th { background: #f8f9fa; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<h2 class="mb-4"><i class="bi bi-broadcast"></i> Рассылки</h2>
|
||
|
||
<!-- Табы -->
|
||
<ul class="nav nav-tabs mb-4" id="broadcastTabs" role="tablist">
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link active" id="create-tab" data-bs-toggle="tab" data-bs-target="#create" type="button" role="tab">
|
||
<i class="bi bi-plus-circle"></i> Создать рассылку
|
||
</button>
|
||
</li>
|
||
<li class="nav-item" role="presentation">
|
||
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
|
||
<i class="bi bi-clock-history"></i> История и статистика
|
||
{% if broadcasts %}
|
||
<span class="badge bg-primary ms-1">{{ broadcasts|length }}</span>
|
||
{% endif %}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
|
||
<div class="tab-content" id="broadcastTabContent">
|
||
<!-- ВКЛАДКА: Создание рассылки -->
|
||
<div class="tab-pane fade show active" id="create" role="tabpanel">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<!-- Тип рассылки -->
|
||
<label class="form-label fw-bold">Тип рассылки:</label>
|
||
<div class="broadcast-type-selector">
|
||
<div class="broadcast-type-option active" onclick="selectBroadcastType('smart')" id="typeSmart">
|
||
<div class="icon">✨</div>
|
||
<h4>Умная</h4>
|
||
<p class="text-muted small">С отслеживанием прочтений и статистикой</p>
|
||
</div>
|
||
</div>
|
||
|
||
<form id="broadcastForm" enctype="multipart/form-data">
|
||
<input type="hidden" id="broadcastType" value="smart">
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Текст рассылки</label>
|
||
<textarea class="form-control" id="broadcastText" rows="5"
|
||
placeholder="Введите текст рассылки..." required></textarea>
|
||
<small class="text-muted">Поддерживается HTML: <b>жирный</b>, <i>курсив</i>, <code>код</code></small>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label text-info">🖼️ Использовать готовое фото:</label>
|
||
<select class="form-select bg-dark text-info border-info" id="broadcastArtifactPhoto">
|
||
<option value="">-- Не выбрано (или загрузить новое) --</option>
|
||
{% for a in artifacts if a.file_type == 'photo' %}
|
||
<option value="{{ a.file_id }}">{{ a.filename }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label text-warning">📄 Использовать готовый документ:</label>
|
||
<select class="form-select bg-dark text-warning border-warning" id="broadcastArtifactDocument">
|
||
<option value="">-- Не выбрано (или загрузить новое) --</option>
|
||
{% for a in artifacts if a.file_type == 'document' %}
|
||
<option value="{{ a.file_id }}">{{ a.filename }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label">Загрузить новую картинку (необязательно)</label>
|
||
<input type="file" class="form-control" id="broadcastPhoto" accept="image/*" onchange="previewImage()">
|
||
<small class="text-muted">Если выбран артефакт выше, загрузка файла будет проигнорирована.</small>
|
||
<div id="preview"><img id="previewImg" alt="Предпросмотр"></div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">Кому отправить:</label>
|
||
<select class="form-select" id="broadcastRecipients">
|
||
<option value="all_and_chat">✅ Всем верифицированным + в чат</option>
|
||
<option value="all_verified">✅ Только верифицированным в личку</option>
|
||
<option value="ig_only">👥 Только Инициативной Группе</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<div class="mb-3">
|
||
<label class="form-label">Фильтр по подъезду:</label>
|
||
<select class="form-select" id="broadcastStaircase">
|
||
<option value="all">🏠 Весь дом (Все подъезды)</option>
|
||
<option value="1">🚪 Подъезд 1</option>
|
||
<option value="2">🚪 Подъезд 2</option>
|
||
<option value="3">🚪 Подъезд 3</option>
|
||
<option value="4">🚪 Подъезд 4</option>
|
||
<option value="5">🚪 Подъезд 5</option>
|
||
<option value="6">🚪 Подъезд 6</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Опции умной рассылки (всегда включены) -->
|
||
<div class="smart-options active">
|
||
<h5 class="mb-3"><i class="bi bi-stars"></i> Возможности умной рассылки</h5>
|
||
<div class="alert alert-info mb-0">
|
||
<i class="bi bi-info-circle"></i>
|
||
<b>После отправки вы сможете:</b>
|
||
<ul class="mb-0 mt-2">
|
||
<li>Посмотреть кто прочитал сообщение</li>
|
||
<li>Увидеть кто НЕ прочитал</li>
|
||
<li>Отправить напоминание непрочитавшим</li>
|
||
<li>Просмотреть общую статистику во вкладке "История"</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<button type="button" class="btn btn-outline-secondary btn-lg mt-3 me-2" onclick="testBroadcast()">
|
||
<i class="bi bi-shield-check"></i> 🧪 Тест (без отправки)
|
||
</button>
|
||
<button type="submit" class="btn btn-primary btn-lg mt-3">
|
||
<i class="bi bi-send"></i> ✨ Отправить умную рассылку
|
||
</button>
|
||
</form>
|
||
|
||
<div id="result" class="mt-4"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mt-4">
|
||
<div class="card-body">
|
||
<h5>Как использовать:</h5>
|
||
<ol>
|
||
<li>Введите текст объявления</li>
|
||
<li>Прикрепите картинку (необязательно)</li>
|
||
<li>Выберите получателей</li>
|
||
<li>Нажмите "Отправить умную рассылку"</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ВКЛАДКА: История и статистика -->
|
||
<div class="tab-pane fade" id="history" role="tabpanel">
|
||
{% if broadcasts %}
|
||
<div id="broadcastsList">
|
||
{% for b in broadcasts %}
|
||
<div class="broadcast-item">
|
||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||
<div>
|
||
<span class="badge {{ 'badge-smart' if b.broadcast_type == 'smart' else 'badge-regular' }}">
|
||
{{ '✨ Умная' if b.broadcast_type == 'smart' else '📢 Обычная' }}
|
||
</span>
|
||
<strong class="ms-2">#{{ b.id }}</strong>
|
||
</div>
|
||
<small class="text-muted">{{ b.sent_at }}</small>
|
||
</div>
|
||
|
||
<p class="mb-2">{{ b.text[:150] }}{{ '...' if b.text|length > 150 else '' }}</p>
|
||
|
||
<div class="d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<small class="text-muted">
|
||
📤 Отправлено: <b>{{ b.total_sent }}</b> |
|
||
✅ Прочитали: <b>{{ b.read_count }}</b> |
|
||
❌ Не прочитали: <b>{{ b.unread_count }}</b>
|
||
</small>
|
||
{% if b.total_sent > 0 %}
|
||
<div class="progress-mini mt-1">
|
||
<div class="progress-mini-fill" style="width: {{ b.read_percent }}%"></div>
|
||
</div>
|
||
<small class="ms-1">{{ b.read_percent }}%</small>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if b.broadcast_type == 'smart' %}
|
||
<div>
|
||
<button class="btn btn-sm btn-outline-success" onclick="showReaders({{ b.id }}, '{{ b.text[:50] }}')">
|
||
<i class="bi bi-eye"></i> Кто прочитал
|
||
</button>
|
||
{% if not b.is_reminder_sent %}
|
||
<button class="btn btn-sm btn-outline-warning" onclick="sendReminder({{ b.id }})">
|
||
<i class="bi bi-bell"></i> Напомнить
|
||
</button>
|
||
{% else %}
|
||
<small class="text-muted ms-2"><i class="bi bi-check-circle"></i> Напоминание отправлено</small>
|
||
{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="alert alert-info">
|
||
<i class="bi bi-info-circle"></i> Рассылок пока нет. Создайте первую во вкладке "Создать рассылку"!
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Модальное окно: кто прочитал -->
|
||
<div class="modal fade" id="readersModal" tabindex="-1">
|
||
<div class="modal-dialog modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="readersModalTitle">📊 Кто прочитал</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<ul class="nav nav-tabs mb-3" id="readersTabs" role="tablist">
|
||
<li class="nav-item">
|
||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#readersRead">✅ Прочитали</button>
|
||
</li>
|
||
<li class="nav-item">
|
||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#readersUnread">❌ Не прочитали</button>
|
||
</li>
|
||
</ul>
|
||
<div class="tab-content">
|
||
<div class="tab-pane fade show active" id="readersRead">
|
||
<div id="readersList" class="readers-table">Загрузка...</div>
|
||
</div>
|
||
<div class="tab-pane fade" id="readersUnread">
|
||
<div id="nonReadersList" class="readers-table">Загрузка...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_script %}
|
||
<script>
|
||
let currentBroadcastId = null;
|
||
|
||
// Выбор типа рассылки (теперь только умная)
|
||
function selectBroadcastType(type) {
|
||
document.getElementById('broadcastType').value = type;
|
||
}
|
||
|
||
// Предпросмотр изображения
|
||
function previewImage() {
|
||
const file = document.getElementById('broadcastPhoto').files[0];
|
||
const preview = document.getElementById('preview');
|
||
const previewImg = document.getElementById('previewImg');
|
||
|
||
if (file) {
|
||
const reader = new FileReader();
|
||
reader.onload = function(e) {
|
||
previewImg.src = e.target.result;
|
||
preview.style.display = 'block';
|
||
}
|
||
reader.readAsDataURL(file);
|
||
} else {
|
||
preview.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Тестовая рассылка (без отправки!)
|
||
async function testBroadcast() {
|
||
const text = document.getElementById('broadcastText').value;
|
||
const recipients = document.getElementById('broadcastRecipients').value;
|
||
const staircase = document.getElementById('broadcastStaircase').value;
|
||
const resultDiv = document.getElementById('result');
|
||
|
||
if (!text.trim()) {
|
||
alert('Введите текст рассылки!');
|
||
return;
|
||
}
|
||
|
||
resultDiv.innerHTML = '<div class="alert alert-info">🧪 Тестирование (без отправки)...</div>';
|
||
|
||
const formData = new FormData();
|
||
formData.append('text', text);
|
||
formData.append('recipients', recipients);
|
||
formData.append('staircase', staircase);
|
||
formData.append('has_read_button', 'true');
|
||
formData.append('dry_run', 'true');
|
||
|
||
try {
|
||
const r = await fetch('/api/broadcast/create', { method: 'POST', body: formData });
|
||
const result = await r.json();
|
||
|
||
if (result.success) {
|
||
resultDiv.innerHTML = `
|
||
<div class="alert alert-success">
|
||
✅ <b>Тест успешен!</b><br><br>
|
||
📊 Рассылка получила бы: <b>${result.recipients_count} пользователей</b><br>
|
||
👥 Получатели: ${recipients}<br><br>
|
||
<i>Сообщения НЕ отправлялись</i>
|
||
</div>
|
||
`;
|
||
} else {
|
||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
|
||
}
|
||
} catch (err) {
|
||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
|
||
}
|
||
}
|
||
|
||
// Отправка формы
|
||
document.getElementById('broadcastForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const text = document.getElementById('broadcastText').value;
|
||
const photo = document.getElementById('broadcastPhoto').files[0];
|
||
const artifactPhotoId = document.getElementById('broadcastArtifactPhoto').value;
|
||
const artifactDocumentId = document.getElementById('broadcastArtifactDocument').value;
|
||
const recipients = document.getElementById('broadcastRecipients').value;
|
||
const staircase = document.getElementById('broadcastStaircase').value;
|
||
const resultDiv = document.getElementById('result');
|
||
|
||
if (!text.trim()) {
|
||
alert('Введите текст рассылки!');
|
||
return;
|
||
}
|
||
|
||
// Двойное подтверждение для реальной отправки
|
||
const confirmed = confirm(
|
||
'⚠️ ВНИМАНИЕ! Это отправит сообщения реальным пользователям!\n\n' +
|
||
`Получатели: ${recipients}\n` +
|
||
`Подъезд: ${staircase === 'all' ? 'Весь дом' : staircase}\n\n` +
|
||
'Вы уверены?'
|
||
);
|
||
|
||
if (!confirmed) return;
|
||
|
||
resultDiv.innerHTML = '<div class="alert alert-info">📤 Отправка рассылки...</div>';
|
||
|
||
const formData = new FormData();
|
||
formData.append('text', text);
|
||
formData.append('recipients', recipients);
|
||
formData.append('staircase', staircase);
|
||
formData.append('has_read_button', 'true');
|
||
formData.append('track_reads', 'true');
|
||
|
||
if (artifactPhotoId) {
|
||
formData.append('artifact_photo_id', artifactPhotoId);
|
||
}
|
||
if (artifactDocumentId) {
|
||
formData.append('artifact_document_id', artifactDocumentId);
|
||
}
|
||
if (photo && !artifactPhotoId) {
|
||
formData.append('photo', photo);
|
||
}
|
||
|
||
try {
|
||
const r = await fetch('/api/broadcast/create', { method: 'POST', body: formData });
|
||
const result = await r.json();
|
||
|
||
if (result.success) {
|
||
resultDiv.innerHTML = `<div class="alert alert-success">✅ ${result.message}<br><br>📊 Перейдите во вкладку "История и статистика" для просмотра результатов →</div>`;
|
||
document.getElementById('broadcastForm').reset();
|
||
document.getElementById('preview').style.display = 'none';
|
||
|
||
// Перезагружаем страницу через 2 секунды чтобы обновить историю
|
||
setTimeout(() => location.reload(), 2000);
|
||
} else {
|
||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${result.error || 'Неизвестная'}</div>`;
|
||
}
|
||
} catch (err) {
|
||
resultDiv.innerHTML = `<div class="alert alert-danger">❌ Ошибка: ${err.message}</div>`;
|
||
}
|
||
});
|
||
|
||
// Показать кто прочитал/не прочитал
|
||
async function showReaders(broadcastId, title) {
|
||
currentBroadcastId = broadcastId;
|
||
document.getElementById('readersModalTitle').textContent = '📊 ' + title;
|
||
|
||
const modal = new bootstrap.Modal(document.getElementById('readersModal'));
|
||
modal.show();
|
||
|
||
// Загружаем данные
|
||
try {
|
||
const [readersResp, nonReadersResp] = await Promise.all([
|
||
fetch(`/api/broadcast/readers/${broadcastId}`),
|
||
fetch(`/api/broadcast/non_readers/${broadcastId}`)
|
||
]);
|
||
|
||
const readers = await readersResp.json();
|
||
const nonReaders = await nonReadersResp.json();
|
||
|
||
// Прочитали
|
||
if (readers.readers && readers.readers.length > 0) {
|
||
document.getElementById('readersList').innerHTML = readers.readers.map(r => `
|
||
<div class="d-flex justify-content-between p-2 border-bottom">
|
||
<div>
|
||
<strong>${r.name || 'user' + r.user_id}</strong>
|
||
${r.apartment ? `<span class="text-muted">(кв. ${r.apartment})</span>` : ''}
|
||
</div>
|
||
<small class="text-muted">${r.read_at}</small>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
document.getElementById('readersList').innerHTML = '<p class="text-muted">Пока никто не прочитал</p>';
|
||
}
|
||
|
||
// Не прочитали
|
||
if (nonReaders.non_readers && nonReaders.non_readers.length > 0) {
|
||
document.getElementById('nonReadersList').innerHTML = nonReaders.non_readers.map(r => `
|
||
<div class="d-flex justify-content-between p-2 border-bottom">
|
||
<div>
|
||
<strong>${r.name || 'user' + r.user_id}</strong>
|
||
${r.apartment ? `<span class="text-muted">(кв. ${r.apartment})</span>` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
document.getElementById('nonReadersList').innerHTML = '<p class="text-success"><i class="bi bi-check-circle"></i> Все прочитали!</p>';
|
||
}
|
||
} catch (err) {
|
||
console.error('Ошибка загрузки данных:', err);
|
||
}
|
||
}
|
||
|
||
// Отправить напоминание
|
||
async function sendReminder(broadcastId) {
|
||
if (!confirm('Отправить напоминание непрочитавшим?')) return;
|
||
|
||
try {
|
||
const resp = await fetch(`/api/broadcast/send_reminder/${broadcastId}`, { method: 'POST' });
|
||
const result = await resp.json();
|
||
|
||
if (result.success) {
|
||
alert('✅ ' + result.message);
|
||
location.reload();
|
||
} else {
|
||
alert('❌ Ошибка: ' + (result.error || 'Неизвестная'));
|
||
}
|
||
} catch (err) {
|
||
alert('❌ Ошибка: ' + err.message);
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|