Merge pull request 'Admin: version management + notifications + auto-update' (#20) from feat/android-app-redesign into master
Reviewed-on: #20 Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
This commit was merged in pull request #20.
This commit is contained in:
@@ -49,4 +49,16 @@ interface ApiService {
|
|||||||
|
|
||||||
@GET("api/app-version")
|
@GET("api/app-version")
|
||||||
suspend fun checkVersion(): ApiResponse<AppVersionResponse>
|
suspend fun checkVersion(): ApiResponse<AppVersionResponse>
|
||||||
|
|
||||||
|
@GET("api/notifications")
|
||||||
|
suspend fun getNotifications(
|
||||||
|
@Query("page") page: Int = 1,
|
||||||
|
@Query("per_page") perPage: Int = 20
|
||||||
|
): NotificationListResponse
|
||||||
|
|
||||||
|
@POST("api/notifications/{id}/read")
|
||||||
|
suspend fun markNotificationRead(@Path("id") id: Int): ApiResponse<Map<String, Boolean>>
|
||||||
|
|
||||||
|
@POST("api/notifications/read-all")
|
||||||
|
suspend fun markAllNotificationsRead(): ApiResponse<Map<String, Boolean>>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,3 +25,12 @@ data class AppVersionResponse(
|
|||||||
val force_update: Boolean = false,
|
val force_update: Boolean = false,
|
||||||
val release_notes: String = ""
|
val release_notes: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationListResponse(
|
||||||
|
val success: Boolean = true,
|
||||||
|
val data: List<AppNotification> = emptyList(),
|
||||||
|
val error: String? = null,
|
||||||
|
val pagination: Pagination? = null,
|
||||||
|
val unread_count: Int = 0
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.devlab.app.data.model
|
||||||
|
|
||||||
|
@kotlinx.serialization.Serializable
|
||||||
|
data class AppNotification(
|
||||||
|
val id: Int = 0,
|
||||||
|
val title: String = "",
|
||||||
|
val message: String = "",
|
||||||
|
val type: String = "info",
|
||||||
|
val is_read: Int = 0,
|
||||||
|
val created_at: String = "",
|
||||||
|
)
|
||||||
24
database/migrations/005_app_config_and_notifications.sql
Normal file
24
database/migrations/005_app_config_and_notifications.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
|
`key` VARCHAR(64) PRIMARY KEY,
|
||||||
|
`value` TEXT NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('app_version', '1.0.1');
|
||||||
|
INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('apk_url', '/sysadmin.apk');
|
||||||
|
INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('force_update', '0');
|
||||||
|
INSERT IGNORE INTO app_config (`key`, `value`) VALUES ('release_notes', '• Initial release\n• Server management\n• SSH terminal');
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT UNSIGNED DEFAULT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
type ENUM('info','warning','success','error') NOT NULL DEFAULT 'info',
|
||||||
|
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
read_at DATETIME DEFAULT NULL,
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_is_read (is_read),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
Binary file not shown.
@@ -91,6 +91,10 @@ $router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class, Rol
|
|||||||
|
|
||||||
$router->get('/audit', [AdminController::class, 'auditLogs']);
|
$router->get('/audit', [AdminController::class, 'auditLogs']);
|
||||||
$router->get('/security', [AdminController::class, 'securitySettings']);
|
$router->get('/security', [AdminController::class, 'securitySettings']);
|
||||||
|
$router->get('/app-version', [AdminController::class, 'appVersion']);
|
||||||
|
$router->post('/app-version', [AdminController::class, 'appVersion'], [CSRFMiddleware::class]);
|
||||||
|
$router->get('/notifications', [AdminController::class, 'notifications']);
|
||||||
|
$router->post('/notifications/send', [AdminController::class, 'sendNotification'], [CSRFMiddleware::class]);
|
||||||
});
|
});
|
||||||
|
|
||||||
$router->get('/api/status', [ApiController::class, 'status']);
|
$router->get('/api/status', [ApiController::class, 'status']);
|
||||||
@@ -104,6 +108,9 @@ $router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand
|
|||||||
$router->post('/api/auth/login', [ApiController::class, 'login']);
|
$router->post('/api/auth/login', [ApiController::class, 'login']);
|
||||||
$router->post('/api/auth/register', [ApiController::class, 'register']);
|
$router->post('/api/auth/register', [ApiController::class, 'register']);
|
||||||
$router->get('/api/app-version', [ApiController::class, 'appVersion']);
|
$router->get('/api/app-version', [ApiController::class, 'appVersion']);
|
||||||
|
$router->get('/api/notifications', [ApiController::class, 'notifications']);
|
||||||
|
$router->post('/api/notifications/:id/read', [ApiController::class, 'markNotificationRead']);
|
||||||
|
$router->post('/api/notifications/read-all', [ApiController::class, 'markAllNotificationsRead']);
|
||||||
$router->get('/api/users', [ApiController::class, 'users']);
|
$router->get('/api/users', [ApiController::class, 'users']);
|
||||||
$router->get('/api/profile', [ApiController::class, 'profile']);
|
$router->get('/api/profile', [ApiController::class, 'profile']);
|
||||||
$router->put('/api/profile', [ApiController::class, 'updateProfile']);
|
$router->put('/api/profile', [ApiController::class, 'updateProfile']);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace ServerManager\Controllers;
|
namespace ServerManager\Controllers;
|
||||||
|
|
||||||
use ServerManager\Core\App;
|
use ServerManager\Core\App;
|
||||||
|
use ServerManager\Core\Database;
|
||||||
use ServerManager\Core\Session;
|
use ServerManager\Core\Session;
|
||||||
use ServerManager\Core\Validator;
|
use ServerManager\Core\Validator;
|
||||||
|
use ServerManager\Models\Notification;
|
||||||
use ServerManager\Models\User;
|
use ServerManager\Models\User;
|
||||||
use ServerManager\Services\AuditService;
|
use ServerManager\Services\AuditService;
|
||||||
|
|
||||||
@@ -210,4 +212,79 @@ class AdminController
|
|||||||
'title' => 'Security Settings - ServerManager',
|
'title' => 'Security Settings - ServerManager',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function appVersion(): void
|
||||||
|
{
|
||||||
|
$this->requireSuperAdmin();
|
||||||
|
|
||||||
|
$db = Database::getInstance();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$fields = ['app_version', 'apk_url', 'force_update', 'release_notes'];
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (isset($_POST[$field])) {
|
||||||
|
$db->update('app_config', ['value' => $_POST[$field]], '`key` = ?', [$field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->auditService->log('app_version_updated', 'App version config updated');
|
||||||
|
$this->view->json(['success' => true, 'message' => 'App version updated successfully.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$configs = $db->fetchAll('SELECT * FROM app_config');
|
||||||
|
$config = [];
|
||||||
|
foreach ($configs as $row) {
|
||||||
|
$config[$row['key']] = $row['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->view->display('admin.app_version', [
|
||||||
|
'title' => 'App Version - ServerManager',
|
||||||
|
'config' => $config,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifications(): void
|
||||||
|
{
|
||||||
|
$this->requireSuperAdmin();
|
||||||
|
|
||||||
|
$notificationModel = new Notification();
|
||||||
|
$page = (int) ($_GET['page'] ?? 1);
|
||||||
|
$allNotifications = $notificationModel->getAll($page, 20);
|
||||||
|
|
||||||
|
$userModel = new User();
|
||||||
|
$users = $userModel->getAll(1, 200);
|
||||||
|
|
||||||
|
$this->view->display('admin.notifications', [
|
||||||
|
'title' => 'Notifications - ServerManager',
|
||||||
|
'notifications' => $allNotifications,
|
||||||
|
'users' => $users,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendNotification(): void
|
||||||
|
{
|
||||||
|
$this->requireSuperAdmin();
|
||||||
|
|
||||||
|
$title = $_POST['title'] ?? '';
|
||||||
|
$message = $_POST['message'] ?? '';
|
||||||
|
$type = $_POST['type'] ?? 'info';
|
||||||
|
$userId = !empty($_POST['user_id']) ? (int) $_POST['user_id'] : null;
|
||||||
|
|
||||||
|
if (empty($title) || empty($message)) {
|
||||||
|
$this->view->json(['success' => false, 'message' => 'Title and message are required.']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$notificationModel = new Notification();
|
||||||
|
$notificationModel->create([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'title' => $title,
|
||||||
|
'message' => $message,
|
||||||
|
'type' => $type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->auditService->log('notification_sent', "Notification: {$title}" . ($userId ? " to user #{$userId}" : ' (all users)'));
|
||||||
|
|
||||||
|
$this->view->json(['success' => true, 'message' => 'Notification sent successfully.']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -468,17 +468,68 @@ class ApiController
|
|||||||
|
|
||||||
public function appVersion(): void
|
public function appVersion(): void
|
||||||
{
|
{
|
||||||
|
$db = \ServerManager\Core\Database::getInstance();
|
||||||
|
$configs = $db->fetchAll('SELECT * FROM app_config');
|
||||||
|
$config = [];
|
||||||
|
foreach ($configs as $row) {
|
||||||
|
$config[$row['key']] = $row['value'];
|
||||||
|
}
|
||||||
|
|
||||||
$this->view->json([
|
$this->view->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'version' => '1.0.1',
|
'version' => $config['app_version'] ?? '1.0.0',
|
||||||
'apk_url' => '/sysadmin.apk',
|
'apk_url' => $config['apk_url'] ?? '/sysadmin.apk',
|
||||||
'force_update' => false,
|
'force_update' => ($config['force_update'] ?? '0') === '1',
|
||||||
'release_notes' => '• Gráficos de métricas en tiempo real\n• Nuevo diseño con animaciones\n• Modo oscuro\n• Perfil de usuario\n• Mejoras de rendimiento',
|
'release_notes' => $config['release_notes'] ?? '',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notifications(): void
|
||||||
|
{
|
||||||
|
$user = $this->authenticateRequest();
|
||||||
|
|
||||||
|
$notificationModel = new \ServerManager\Models\Notification();
|
||||||
|
$page = (int) ($_GET['page'] ?? 1);
|
||||||
|
$perPage = (int) ($_GET['per_page'] ?? 20);
|
||||||
|
|
||||||
|
$result = $notificationModel->getForUser((int) $user['id'], $page, $perPage);
|
||||||
|
$unreadCount = $notificationModel->getUnreadCount((int) $user['id']);
|
||||||
|
|
||||||
|
$this->view->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => $result['data'],
|
||||||
|
'pagination' => [
|
||||||
|
'page' => $result['page'],
|
||||||
|
'per_page' => $result['per_page'],
|
||||||
|
'total' => $result['total'],
|
||||||
|
'total_pages' => $result['total_pages'],
|
||||||
|
],
|
||||||
|
'unread_count' => $unreadCount,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markNotificationRead(int $id): void
|
||||||
|
{
|
||||||
|
$user = $this->authenticateRequest();
|
||||||
|
|
||||||
|
$notificationModel = new \ServerManager\Models\Notification();
|
||||||
|
$notificationModel->markAsRead($id, (int) $user['id']);
|
||||||
|
|
||||||
|
$this->view->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllNotificationsRead(): void
|
||||||
|
{
|
||||||
|
$user = $this->authenticateRequest();
|
||||||
|
|
||||||
|
$notificationModel = new \ServerManager\Models\Notification();
|
||||||
|
$notificationModel->markAllAsRead((int) $user['id']);
|
||||||
|
|
||||||
|
$this->view->json(['success' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
public function login(): void
|
public function login(): void
|
||||||
{
|
{
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||||
|
|||||||
99
src/Models/Notification.php
Normal file
99
src/Models/Notification.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ServerManager\Models;
|
||||||
|
|
||||||
|
use ServerManager\Core\Database;
|
||||||
|
|
||||||
|
class Notification
|
||||||
|
{
|
||||||
|
private Database $db;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->db = Database::getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(array $data): int
|
||||||
|
{
|
||||||
|
$data['created_at'] = date('Y-m-d H:i:s');
|
||||||
|
return $this->db->insert('notifications', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(int $id): ?array
|
||||||
|
{
|
||||||
|
return $this->db->fetch('SELECT * FROM notifications WHERE id = ?', [$id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getForUser(int $userId, int $page = 1, int $perPage = 20): array
|
||||||
|
{
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$total = $this->db->fetch(
|
||||||
|
"SELECT COUNT(*) as total FROM notifications WHERE user_id IS NULL OR user_id = ?",
|
||||||
|
[$userId]
|
||||||
|
);
|
||||||
|
$items = $this->db->fetchAll(
|
||||||
|
"SELECT * FROM notifications WHERE user_id IS NULL OR user_id = ?
|
||||||
|
ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
[$userId, $perPage, $offset]
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
'data' => $items,
|
||||||
|
'total' => (int) ($total['total'] ?? 0),
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUnreadCount(int $userId): int
|
||||||
|
{
|
||||||
|
$result = $this->db->fetch(
|
||||||
|
"SELECT COUNT(*) as total FROM notifications
|
||||||
|
WHERE (user_id IS NULL OR user_id = ?) AND is_read = 0",
|
||||||
|
[$userId]
|
||||||
|
);
|
||||||
|
return (int) ($result['total'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAsRead(int $id, int $userId): void
|
||||||
|
{
|
||||||
|
$this->db->update(
|
||||||
|
'notifications',
|
||||||
|
['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')],
|
||||||
|
'id = ? AND (user_id IS NULL OR user_id = ?)',
|
||||||
|
[$id, $userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllAsRead(int $userId): void
|
||||||
|
{
|
||||||
|
$this->db->update(
|
||||||
|
'notifications',
|
||||||
|
['is_read' => 1, 'read_at' => date('Y-m-d H:i:s')],
|
||||||
|
'(user_id IS NULL OR user_id = ?) AND is_read = 0',
|
||||||
|
[$userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAll(int $page = 1, int $perPage = 20): array
|
||||||
|
{
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$total = $this->db->fetch("SELECT COUNT(*) as total FROM notifications");
|
||||||
|
$items = $this->db->fetchAll(
|
||||||
|
"SELECT n.*, u.username as target_username
|
||||||
|
FROM notifications n
|
||||||
|
LEFT JOIN users u ON n.user_id = u.id
|
||||||
|
ORDER BY n.created_at DESC LIMIT ? OFFSET ?",
|
||||||
|
[$perPage, $offset]
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
'data' => $items,
|
||||||
|
'total' => (int) ($total['total'] ?? 0),
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => (int) ceil((int) ($total['total'] ?? 0) / $perPage),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
@@ -66,6 +66,18 @@
|
|||||||
<span>Security</span>
|
<span>Security</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/admin/app-version" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/app-version') ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-mobile-alt"></i>
|
||||||
|
<span>App Version</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="/admin/notifications" class="nav-link <?= str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin/notifications') ? 'active' : '' ?>">
|
||||||
|
<i class="fas fa-bell"></i>
|
||||||
|
<span>Notifications</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<?php elseif (is_admin()): ?>
|
<?php elseif (is_admin()): ?>
|
||||||
<li class="nav-divider"></li>
|
<li class="nav-divider"></li>
|
||||||
<li class="nav-section">Administration</li>
|
<li class="nav-section">Administration</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user