feat: admin app version mgmt + notification system
- New tables: app_config, notifications - Admin views: App Version editor, Notification sender - API: GET /api/notifications, POST /api/notifications/:id/read, POST /api/notifications/read-all - App version now stored in DB, editable by super admin - Notifications: send to all users or specific user - Android notification models + API client - Sidebar: App Version + Notifications links for super_admin
This commit is contained in:
86
views/admin/app_version.php
Normal file
86
views/admin/app_version.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php $config = $config ?? []; ?>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<h1 class="page-title">App Version</h1>
|
||||
<p class="page-subtitle">Manage the Android application version and update settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="versionForm">
|
||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="app_version">Version Number</label>
|
||||
<input type="text" id="app_version" name="app_version" class="form-input"
|
||||
value="<?= htmlspecialchars($config['app_version'] ?? '1.0.0') ?>"
|
||||
placeholder="e.g. 1.0.1" required>
|
||||
<small class="form-hint">Semantic version (X.Y.Z). Must be higher than previous to trigger updates.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="apk_url">APK URL</label>
|
||||
<input type="text" id="apk_url" name="apk_url" class="form-input"
|
||||
value="<?= htmlspecialchars($config['apk_url'] ?? '/sysadmin.apk') ?>"
|
||||
placeholder="e.g. /sysadmin.apk" required>
|
||||
<small class="form-hint">Path or URL to the APK file.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="force_update">Force Update</label>
|
||||
<select id="force_update" name="force_update" class="form-select">
|
||||
<option value="0" <?= ($config['force_update'] ?? '0') === '0' ? 'selected' : '' ?>>Optional — user can skip</option>
|
||||
<option value="1" <?= ($config['force_update'] ?? '0') === '1' ? 'selected' : '' ?>>Forced — user must update</option>
|
||||
</select>
|
||||
<small class="form-hint">If forced, users cannot dismiss the update dialog.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="release_notes">Release Notes</label>
|
||||
<textarea id="release_notes" name="release_notes" class="form-input" rows="6"
|
||||
placeholder="• What changed • Bug fixes • New features"><?= htmlspecialchars($config['release_notes'] ?? '') ?></textarea>
|
||||
<small class="form-hint">One change per line. These will be shown in the update dialog on Android.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> Save Version
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('versionForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const btn = this.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Saving...';
|
||||
|
||||
fetch('/admin/app-version', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: new URLSearchParams(new FormData(this)),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', data.message);
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-save"></i> Save Version';
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('error', 'Failed to save version');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-save"></i> Save Version';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
175
views/admin/notifications.php
Normal file
175
views/admin/notifications.php
Normal file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
$notifications = $notifications ?? [];
|
||||
$users = $users ?? [];
|
||||
$data = $notifications['data'] ?? [];
|
||||
?>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<h1 class="page-title">Notifications</h1>
|
||||
<p class="page-subtitle">Send push notifications to Android app users</p>
|
||||
</div>
|
||||
<div class="page-header-right">
|
||||
<button class="btn btn-primary" onclick="showSendModal()">
|
||||
<i class="fas fa-paper-plane"></i> Send Notification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Type</th>
|
||||
<th>Title</th>
|
||||
<th>Message</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($data)): ?>
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted">No notifications sent yet.</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($data as $note): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($note['created_at'] ?? '') ?></td>
|
||||
<td>
|
||||
<span class="badge badge-<?= match($note['type'] ?? 'info') {
|
||||
'success' => 'success',
|
||||
'warning' => 'warning',
|
||||
'error' => 'danger',
|
||||
default => 'info',
|
||||
} ?>">
|
||||
<?= htmlspecialchars(ucfirst($note['type'] ?? 'info')) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><strong><?= htmlspecialchars($note['title'] ?? '') ?></strong></td>
|
||||
<td><?= htmlspecialchars(mb_substr($note['message'] ?? '', 0, 80)) ?><?= mb_strlen($note['message'] ?? '') > 80 ? '...' : '' ?></td>
|
||||
<td><?= $note['user_id'] ? htmlspecialchars($note['target_username'] ?? "User #{$note['user_id']}") : '<em>All Users</em>' ?></td>
|
||||
<td>
|
||||
<span class="status-badge <?= $note['is_read'] ? 'status-online' : 'status-offline' ?>">
|
||||
<?= $note['is_read'] ? 'Read' : 'Unread' ?>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php if (($notifications['total_pages'] ?? 1) > 1): ?>
|
||||
<div class="pagination">
|
||||
<?php for ($i = 1; $i <= $notifications['total_pages']; $i++): ?>
|
||||
<a href="?page=<?= $i ?>" class="page-link <?= $i === ($notifications['page'] ?? 1) ? 'active' : '' ?>">
|
||||
<?= $i ?>
|
||||
</a>
|
||||
<?php endfor; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal" id="notificationModal">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Send Notification</h3>
|
||||
<button class="modal-close" onclick="closeModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="notificationForm">
|
||||
<input type="hidden" name="_csrf_token" value="<?= $this->csrfToken() ?>">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="noteTitle">Title</label>
|
||||
<input type="text" id="noteTitle" name="title" class="form-input" required maxlength="255"
|
||||
placeholder="e.g. Maintenance scheduled">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="noteMessage">Message</label>
|
||||
<textarea id="noteMessage" name="message" class="form-input" rows="4" required
|
||||
placeholder="Notification content..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="noteType">Type</label>
|
||||
<select id="noteType" name="type" class="form-select">
|
||||
<option value="info">Info</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="noteUser">Send To</label>
|
||||
<select id="noteUser" name="user_id" class="form-select">
|
||||
<option value="">All Users (general notification)</option>
|
||||
<?php foreach ($users['data'] as $u): ?>
|
||||
<option value="<?= $u['id'] ?>"><?= htmlspecialchars($u['username']) ?> (<?= htmlspecialchars($u['email']) ?>)</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<small class="form-hint">Leave as "All Users" to send to everyone.</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="sendNotification()">
|
||||
<i class="fas fa-paper-plane"></i> Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showSendModal() {
|
||||
document.getElementById('notificationModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('notificationModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function sendNotification() {
|
||||
const form = document.getElementById('notificationForm');
|
||||
const formData = new FormData(form);
|
||||
const btn = document.querySelector('#notificationModal .btn-primary');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Sending...';
|
||||
|
||||
fetch('/admin/notifications/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: new URLSearchParams(formData),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast('success', data.message);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast('error', data.message);
|
||||
}
|
||||
closeModal();
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
||||
})
|
||||
.catch(() => {
|
||||
showToast('error', 'Failed to send notification');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user