First commit
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Broadcast Notifications{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-megaphone-fill me-2"></i>Broadcast Notification</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Send an in-app notification to all active iOS app users in the selected roles.
|
||||
Messages are delivered within 60 seconds via the app's background poll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Compose Form ─────────────────────────────────────────────────── #}
|
||||
<div class="col-lg-5">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<i class="bi bi-send-fill me-2"></i><strong>Compose Message</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('broadcast.send') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label fw-semibold">
|
||||
Title <span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
maxlength="255" required placeholder="e.g. App Update Available">
|
||||
<div class="form-text">Appears as the notification banner title on iPad.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="body" class="form-label fw-semibold">
|
||||
Message <span class="text-danger">*</span>
|
||||
</label>
|
||||
<textarea class="form-control" id="body" name="body"
|
||||
rows="4" required maxlength="500"
|
||||
placeholder="e.g. A new version of JanitorialQC is available. Please update to v1.3 from the App Store."></textarea>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<span class="form-text mb-0">The full message body shown in the notification and in-app inbox.</span>
|
||||
<span id="bodyCounter" class="form-text mb-0 text-muted">0 / 500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-semibold">
|
||||
Target Roles <span class="text-danger">*</span>
|
||||
</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
{% for role in roles %}
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
name="roles" value="{{ role }}"
|
||||
id="role_{{ role }}"
|
||||
{% if role == 'inspector' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="role_{{ role }}">
|
||||
{{ role_labels[role] }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="form-text">Only active users in the selected roles will receive this message.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary btn-lg"
|
||||
onclick="return confirm('Send this broadcast to all selected users?')">
|
||||
<i class="bi bi-send-fill me-2"></i>Send Broadcast
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Broadcast History ─────────────────────────────────────────────── #}
|
||||
<div class="col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i><strong>Recent Broadcasts</strong>
|
||||
<span class="text-muted fw-normal ms-2">(last 50)</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if history %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Sent</th>
|
||||
<th>Title</th>
|
||||
<th>Roles</th>
|
||||
<th class="text-center">Recipients</th>
|
||||
<th>By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in history %}
|
||||
<tr class="broadcast-row" style="cursor:pointer;"
|
||||
data-title="{{ b.title | e }}"
|
||||
data-body="{{ b.body | e }}"
|
||||
data-sent-at="{{ b.sent_at.strftime('%b %-d, %Y %I:%M %p') }}"
|
||||
data-sent-by="{{ (b.sent_by.display_name if b.sent_by else '—') | e }}"
|
||||
data-recipients="{{ b.recipient_count }}"
|
||||
data-roles="{{ b.target_roles | join(',') }}">
|
||||
<td class="text-nowrap text-muted small">
|
||||
{{ b.sent_at.strftime('%b %-d, %Y') }}<br>
|
||||
<span class="text-muted" style="font-size:.75rem;">
|
||||
{{ b.sent_at.strftime('%I:%M %p') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ b.title }}</div>
|
||||
<div class="text-muted small text-truncate" style="max-width:220px;">{{ b.body }}</div>
|
||||
</td>
|
||||
<td>
|
||||
{% for role in b.target_roles %}
|
||||
<span class="badge bg-secondary me-1">
|
||||
{{ role_labels.get(role, role) }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-primary rounded-pill">
|
||||
{{ b.recipient_count }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{{ b.sent_by.display_name if b.sent_by else '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-megaphone fs-1 d-block mb-2 opacity-25"></i>
|
||||
No broadcasts sent yet.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
|
||||
{# ── Broadcast Detail Modal ──────────────────────────────────────────────── #}
|
||||
<div class="modal fade" id="broadcastDetailModal" tabindex="-1" aria-labelledby="broadcastDetailTitle" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title" id="broadcastDetailTitle">
|
||||
<i class="bi bi-megaphone-fill me-2"></i><span id="bdTitle"></span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="bdBody" class="mb-3" style="white-space:pre-wrap;"></p>
|
||||
<hr>
|
||||
<dl class="row mb-0 small text-muted">
|
||||
<dt class="col-sm-4">Sent</dt>
|
||||
<dd class="col-sm-8" id="bdSentAt"></dd>
|
||||
<dt class="col-sm-4">By</dt>
|
||||
<dd class="col-sm-8" id="bdSentBy"></dd>
|
||||
<dt class="col-sm-4">Recipients</dt>
|
||||
<dd class="col-sm-8" id="bdRecipients"></dd>
|
||||
<dt class="col-sm-4">Roles</dt>
|
||||
<dd class="col-sm-8" id="bdRoles"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var ROLE_LABELS = {{ role_labels | tojson }};
|
||||
|
||||
document.querySelectorAll('.broadcast-row').forEach(function(row) {
|
||||
row.addEventListener('click', function() {
|
||||
var roles = this.dataset.roles ? this.dataset.roles.split(',') : [];
|
||||
document.getElementById('bdTitle').textContent = this.dataset.title;
|
||||
document.getElementById('bdBody').textContent = this.dataset.body;
|
||||
document.getElementById('bdSentAt').textContent = this.dataset.sentAt;
|
||||
document.getElementById('bdSentBy').textContent = this.dataset.sentBy;
|
||||
document.getElementById('bdRecipients').textContent = this.dataset.recipients + ' user(s)';
|
||||
document.getElementById('bdRoles').textContent = roles.map(function(r) {
|
||||
return ROLE_LABELS[r] || r;
|
||||
}).join(', ');
|
||||
new bootstrap.Modal(document.getElementById('broadcastDetailModal')).show();
|
||||
});
|
||||
});
|
||||
|
||||
(function () {
|
||||
var textarea = document.getElementById('body');
|
||||
var counter = document.getElementById('bodyCounter');
|
||||
var MAX = 500;
|
||||
|
||||
function update() {
|
||||
var len = textarea.value.length;
|
||||
counter.textContent = len + ' / ' + MAX;
|
||||
counter.className = 'form-text mb-0 ' + (len >= MAX ? 'text-danger fw-semibold' : len >= MAX * 0.9 ? 'text-warning' : 'text-muted');
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', update);
|
||||
update();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user