Update
This commit is contained in:
parent
996ff00fd7
commit
c0e01c6dae
209
app/Commands/SendActivityDigest.php
Normal file
209
app/Commands/SendActivityDigest.php
Normal file
@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use App\Models\ActivityLogModel;
|
||||
use App\Models\UserModel;
|
||||
|
||||
class SendActivityDigest extends BaseCommand
|
||||
{
|
||||
protected $group = 'Activity';
|
||||
protected $name = 'activity:digest';
|
||||
protected $description = 'Send activity log digest email to admin users (daily/weekly/monthly)';
|
||||
protected $usage = 'activity:digest [daily|weekly|monthly]';
|
||||
protected $arguments = [
|
||||
'period' => 'Digest period: daily, weekly, or monthly (default: daily)',
|
||||
];
|
||||
|
||||
public function run(array $params = [])
|
||||
{
|
||||
$period = $params[0] ?? 'daily';
|
||||
|
||||
if (!in_array($period, ['daily', 'weekly', 'monthly'])) {
|
||||
CLI::error('Invalid period. Use: daily, weekly, or monthly');
|
||||
return;
|
||||
}
|
||||
|
||||
$activityModel = new ActivityLogModel();
|
||||
$userModel = new UserModel();
|
||||
|
||||
// Determine date range
|
||||
$startDate = match($period) {
|
||||
'daily' => date('Y-m-d H:i:s', strtotime('-1 day')),
|
||||
'weekly' => date('Y-m-d H:i:s', strtotime('-7 days')),
|
||||
'monthly' => date('Y-m-d H:i:s', strtotime('-30 days')),
|
||||
default => date('Y-m-d H:i:s', strtotime('-1 day')),
|
||||
};
|
||||
|
||||
// Get activity summary for the period
|
||||
$db = \Config\Database::connect();
|
||||
$logs = $db->table('activity_logs')
|
||||
->where('activity_at >=', $startDate)
|
||||
->orderBy('activity_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
if (empty($logs)) {
|
||||
CLI::write('No activity found for ' . $period . ' digest', 'yellow');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get admin users
|
||||
$admins = $userModel->where('role', 'admin')->findAll();
|
||||
|
||||
if (empty($admins)) {
|
||||
CLI::error('No admin users found to send digest to');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email to each admin
|
||||
$email = service('email');
|
||||
$emailConfig = config('Email');
|
||||
|
||||
$successCount = 0;
|
||||
$failCount = 0;
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
$html = $this->generateDigestHTML($logs, $period, $admin);
|
||||
|
||||
$email->setFrom($emailConfig->fromEmail, $emailConfig->fromName)
|
||||
->setTo($admin['email'])
|
||||
->setSubject(ucfirst($period) . ' Activity Digest - ' . date('Y-m-d'))
|
||||
->setMessage($html);
|
||||
|
||||
if ($email->send(false)) {
|
||||
$successCount++;
|
||||
CLI::write('Email sent to: ' . $admin['email'], 'green');
|
||||
} else {
|
||||
$failCount++;
|
||||
CLI::error('Failed to send email to: ' . $admin['email']);
|
||||
}
|
||||
|
||||
$email->clear();
|
||||
}
|
||||
|
||||
CLI::write("\nDigest email summary:", 'cyan');
|
||||
CLI::write('Sent: ' . $successCount, 'green');
|
||||
CLI::write('Failed: ' . $failCount, 'red');
|
||||
}
|
||||
|
||||
protected function generateDigestHTML($logs, $period, $admin)
|
||||
{
|
||||
$totalActions = count($logs);
|
||||
|
||||
// Group by action
|
||||
$byAction = [];
|
||||
foreach ($logs as $log) {
|
||||
$action = $log['action'];
|
||||
$byAction[$action] = ($byAction[$action] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Get critical actions
|
||||
$criticalActions = array_filter($logs, function($log) {
|
||||
return stripos($log['action'], 'delete') !== false;
|
||||
});
|
||||
|
||||
$actionTypeCount = count($byAction);
|
||||
$criticalActionCount = count($criticalActions);
|
||||
|
||||
$html = <<<HTML
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #667eea; color: white; padding: 20px; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #f9fafb; padding: 20px; border-radius: 0 0 8px 8px; }
|
||||
.summary-cards { display: flex; gap: 10px; margin: 20px 0; }
|
||||
.card { background: white; padding: 15px; border-radius: 5px; flex: 1; border-left: 4px solid #667eea; }
|
||||
.card-value { font-size: 24px; font-weight: bold; }
|
||||
.card-label { font-size: 12px; color: #666; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th { background: #e5e7eb; padding: 10px; text-align: left; }
|
||||
td { border-bottom: 1px solid #e5e7eb; padding: 10px; }
|
||||
.critical { color: #dc2626; font-weight: bold; }
|
||||
.footer { font-size: 12px; color: #999; margin-top: 20px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>Activity Digest Report</h1>
|
||||
<p>Dear {$admin['first_name']}, here is your {$period} activity summary</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="summary-cards">
|
||||
<div class="card">
|
||||
<div class="card-value">{$totalActions}</div>
|
||||
<div class="card-label">Total Actions</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-value">{$actionTypeCount}</div>
|
||||
<div class="card-label">Action Types</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-value">{$criticalActionCount}</div>
|
||||
<div class="card-label">Critical Actions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Top Actions</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
HTML;
|
||||
|
||||
arsort($byAction);
|
||||
foreach (array_slice($byAction, 0, 10) as $action => $count) {
|
||||
$isCritical = stripos($action, 'delete') !== false ? 'class="critical"' : '';
|
||||
$html .= "<tr {$isCritical}><td>{$action}</td><td>{$count}</td></tr>";
|
||||
}
|
||||
|
||||
$html .= <<<HTML
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Critical Actions (Deletions)</h2>
|
||||
HTML;
|
||||
|
||||
if (!empty($criticalActions)) {
|
||||
$html .= '<table><thead><tr><th>Time</th><th>User</th><th>Action</th><th>Target</th></tr></thead><tbody>';
|
||||
foreach (array_slice($criticalActions, 0, 20) as $log) {
|
||||
$userId = $log['activity_user_id'] ?? 'System';
|
||||
$targetType = $log['target_user_type'] ?? '-';
|
||||
$html .= "<tr>";
|
||||
$html .= "<td>" . date('Y-m-d H:i', strtotime($log['activity_at'])) . "</td>";
|
||||
$html .= "<td>{$userId}</td>";
|
||||
$html .= "<td class='critical'>{$log['action']}</td>";
|
||||
$html .= "<td>{$targetType}</td>";
|
||||
$html .= "</tr>";
|
||||
}
|
||||
$html .= '</tbody></table>';
|
||||
} else {
|
||||
$html .= '<p style="color: #22c55e;">No critical actions detected.</p>';
|
||||
}
|
||||
|
||||
$html .= <<<HTML
|
||||
<div class="footer">
|
||||
<p>This is an automated email. Please do not reply to this message.</p>
|
||||
<p>Generated on {date('Y-m-d H:i:s')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
HTML;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@ -88,5 +88,5 @@ class Autoload extends AutoloadConfig
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public $helpers = ['form', 'url', 'encryption'];
|
||||
public $helpers = ['form', 'url', 'encryption', 'activity'];
|
||||
}
|
||||
|
||||
@ -47,3 +47,7 @@ $routes->post('/check-email', 'Admin::checkEmail');
|
||||
$routes->get('admin/doctors/data', 'Admin::getDoctors');
|
||||
$routes->get('admin/patients/data', 'Admin::getPatients');
|
||||
$routes->get('/admin/activity-log', 'ActivityLog::index');
|
||||
$routes->get('/admin/activity/analytics', 'ActivityLog::analytics');
|
||||
// $routes->post('/admin/activity-log/datatable', 'ActivityLog::datatable');
|
||||
// $routes->get('/admin/activity-log/summary', 'ActivityLog::getSummary');
|
||||
// $routes->get('/admin/activity-log/critical', 'ActivityLog::getCritical');
|
||||
|
||||
@ -12,17 +12,87 @@ class ActivityLog extends BaseController
|
||||
return $r;
|
||||
}
|
||||
|
||||
$logModel = new ActivityLogModel();
|
||||
$filters = [
|
||||
'action' => trim((string) $this->request->getGet('action')),
|
||||
'role' => trim((string) $this->request->getGet('role')),
|
||||
'date_from' => trim((string) $this->request->getGet('date_from')),
|
||||
'date_to' => trim((string) $this->request->getGet('date_to')),
|
||||
];
|
||||
$activityModel = new ActivityLogModel();
|
||||
|
||||
// Get filter parameters
|
||||
$search = $this->request->getGet('search') ?? '';
|
||||
$action = $this->request->getGet('action') ?? '';
|
||||
$userType = $this->request->getGet('user_type') ?? '';
|
||||
$dateFrom = $this->request->getGet('date_from') ?? '';
|
||||
$dateTo = $this->request->getGet('date_to') ?? '';
|
||||
$ip = $this->request->getGet('ip') ?? '';
|
||||
|
||||
// Build filters array
|
||||
$filters = [];
|
||||
if (!empty($search)) $filters['search'] = $search;
|
||||
if (!empty($action)) $filters['action'] = $action;
|
||||
if (!empty($userType)) $filters['user_type'] = $userType;
|
||||
if (!empty($dateFrom)) $filters['date_from'] = $dateFrom;
|
||||
if (!empty($dateTo)) $filters['date_to'] = $dateTo;
|
||||
if (!empty($ip)) $filters['ip'] = $ip;
|
||||
|
||||
// Get filtered logs
|
||||
$logs = $activityModel->getFilteredLogs($filters, 200);
|
||||
|
||||
return view('admin/activity_log', [
|
||||
'logs' => $logModel->getFiltered($filters),
|
||||
'filters' => $filters,
|
||||
'logs' => $logs,
|
||||
'totalLogs' => count($logs),
|
||||
'actionSummary' => $activityModel->getActionSummary(),
|
||||
'roleSummary' => $activityModel->getRoleSummary(),
|
||||
'filters' => [
|
||||
'search' => $search,
|
||||
'action' => $action,
|
||||
'user_type' => $userType,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
'ip' => $ip,
|
||||
],
|
||||
'availableActions' => $activityModel->getAvailableActions(),
|
||||
'actionIcons' => [
|
||||
'login' => 'box-arrow-in-right',
|
||||
'logout' => 'box-arrow-right',
|
||||
'register_patient' => 'person-plus',
|
||||
'register_doctor' => 'person-badge-plus',
|
||||
'add_doctor' => 'person-badge-plus',
|
||||
'update_doctor' => 'person-badge',
|
||||
'delete_doctor' => 'person-badge-x',
|
||||
'add_patient' => 'person-plus',
|
||||
'update_patient' => 'person',
|
||||
'delete_patient' => 'person-x',
|
||||
'book_appointment' => 'calendar-plus',
|
||||
'update_appointment' => 'calendar-check',
|
||||
'cancel_appointment' => 'calendar-x',
|
||||
'view_appointment' => 'calendar-event',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function analytics()
|
||||
{
|
||||
if ($r = $this->requireRole('admin')) {
|
||||
return $r;
|
||||
}
|
||||
|
||||
$period = $this->request->getGet('period') ?? '7_days';
|
||||
if (! in_array($period, ['7_days', '30_days'], true)) {
|
||||
$period = '7_days';
|
||||
}
|
||||
|
||||
$activityModel = new ActivityLogModel();
|
||||
$summary = $activityModel->getSummary($period);
|
||||
|
||||
return view('admin/activity_analytics', [
|
||||
'period' => $period,
|
||||
'summary' => $summary,
|
||||
'actionLabels' => json_encode(array_keys($summary['by_action'])),
|
||||
'actionCounts' => json_encode(array_values($summary['by_action'])),
|
||||
'typeLabels' => json_encode(array_keys($summary['by_role'])),
|
||||
'typeCounts' => json_encode(array_values($summary['by_role'])),
|
||||
'userLabels' => json_encode(array_column($summary['most_active_users'], 'actor')),
|
||||
'userCounts' => json_encode(array_column($summary['most_active_users'], 'count')),
|
||||
'uniqueIPs' => $summary['unique_ips'],
|
||||
'criticalActions' => $activityModel->getCriticalActions(50),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AlterActivityLogsTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
// Drop the old table
|
||||
$this->db->disableForeignKeyChecks();
|
||||
$this->forge->dropTable('activity_logs', true);
|
||||
$this->db->enableForeignKeyChecks();
|
||||
|
||||
// Create the new table with the specified structure
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'BIGINT',
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'ip' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 45,
|
||||
'null' => true,
|
||||
],
|
||||
'action' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 100,
|
||||
'null' => false,
|
||||
],
|
||||
'description' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'activity_user_id' => [
|
||||
'type' => 'BIGINT',
|
||||
'null' => true,
|
||||
],
|
||||
'activity_user_type' => [
|
||||
'type' => 'ENUM',
|
||||
'constraint' => ['admin', 'doctor', 'patient'],
|
||||
'null' => true,
|
||||
],
|
||||
'target_user_id' => [
|
||||
'type' => 'BIGINT',
|
||||
'null' => true,
|
||||
],
|
||||
'target_user_type' => [
|
||||
'type' => 'ENUM',
|
||||
'constraint' => ['admin', 'doctor', 'patient'],
|
||||
'null' => true,
|
||||
],
|
||||
'activity_page' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
],
|
||||
'activity_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('activity_user_id');
|
||||
$this->forge->addKey('target_user_id');
|
||||
$this->forge->addKey('action');
|
||||
$this->forge->addKey('activity_at');
|
||||
$this->forge->addKey('ip');
|
||||
|
||||
$this->forge->createTable('activity_logs');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
// Revert to original table structure if rollback
|
||||
$this->db->disableForeignKeyChecks();
|
||||
$this->forge->dropTable('activity_logs', true);
|
||||
$this->db->enableForeignKeyChecks();
|
||||
|
||||
// Recreate original structure
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'BIGINT',
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'actor_user_id' => [
|
||||
'type' => 'BIGINT',
|
||||
'null' => true,
|
||||
],
|
||||
'actor_role' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
],
|
||||
'action' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 100,
|
||||
],
|
||||
'description' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'target_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 100,
|
||||
'null' => true,
|
||||
],
|
||||
'target_id' => [
|
||||
'type' => 'BIGINT',
|
||||
'null' => true,
|
||||
],
|
||||
'ip_address' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 45,
|
||||
'null' => true,
|
||||
],
|
||||
'user_agent' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->createTable('activity_logs');
|
||||
}
|
||||
}
|
||||
100
app/Helpers/activity_helper.php
Normal file
100
app/Helpers/activity_helper.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Activity Log Helper Functions
|
||||
*
|
||||
* Provides reusable functions for activity logging operations
|
||||
* throughout the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get the current activity user ID from session
|
||||
*
|
||||
* @return int|null The logged-in user ID or null if guest
|
||||
*/
|
||||
function getActivityUserId(): ?int
|
||||
{
|
||||
$userId = session()->get('id');
|
||||
return $userId ? (int) $userId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current activity user type/role from session
|
||||
*
|
||||
* @return string The user role (admin, doctor, patient) or 'guest'
|
||||
*/
|
||||
function getActivityUserType(): string
|
||||
{
|
||||
return session()->get('role') ?: 'guest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current page/path being accessed
|
||||
*
|
||||
* @return string The current request path
|
||||
*/
|
||||
function getActivityPage(): string
|
||||
{
|
||||
$request = service('request');
|
||||
return $request->getPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the client IP address
|
||||
*
|
||||
* @return string|null The client IP address or null if unavailable
|
||||
*/
|
||||
function getActivityIP(): ?string
|
||||
{
|
||||
$request = service('request');
|
||||
if (method_exists($request, 'getIPAddress')) {
|
||||
return $request->getIPAddress();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all activity metadata at once
|
||||
*
|
||||
* Useful for logging operations that need complete activity context
|
||||
*
|
||||
* @return array Array containing user_id, user_type, page, and ip
|
||||
*/
|
||||
function getActivityMetadata(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => getActivityUserId(),
|
||||
'user_type' => getActivityUserType(),
|
||||
'page' => getActivityPage(),
|
||||
'ip' => getActivityIP(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is authenticated
|
||||
*
|
||||
* @return bool True if user is logged in, false otherwise
|
||||
*/
|
||||
function isActivityUserAuthenticated(): bool
|
||||
{
|
||||
return getActivityUserId() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted user identifier for logging
|
||||
*
|
||||
* Returns a string like "User #123 (admin)" or "Guest"
|
||||
*
|
||||
* @return string Formatted user identifier
|
||||
*/
|
||||
function getFormattedActivityUser(): string
|
||||
{
|
||||
$userId = getActivityUserId();
|
||||
$userType = getActivityUserType();
|
||||
|
||||
if ($userId === null) {
|
||||
return 'Guest';
|
||||
}
|
||||
|
||||
return "User #{$userId} ({$userType})";
|
||||
}
|
||||
3
app/Helpers/time_helper.php
Normal file
3
app/Helpers/time_helper.php
Normal file
@ -0,0 +1,3 @@
|
||||
<?php
|
||||
|
||||
// Time helper intentionally left empty. Activity timestamps are stored using the configured app timezone.
|
||||
@ -10,70 +10,194 @@ class ActivityLogModel extends Model
|
||||
protected $primaryKey = 'id';
|
||||
protected $useAutoIncrement = true;
|
||||
protected $returnType = 'array';
|
||||
protected $useSoftDeletes = false;
|
||||
protected $protectFields = true;
|
||||
protected $allowedFields = [
|
||||
'actor_user_id',
|
||||
'actor_role',
|
||||
'action',
|
||||
'description',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'created_at',
|
||||
];
|
||||
protected $allowedFields = ['ip', 'action', 'description', 'activity_user_id', 'activity_user_type', 'target_user_id', 'target_user_type', 'activity_page', 'activity_at'];
|
||||
|
||||
public function log(string $action, string $description, ?string $targetType = null, ?int $targetId = null): void
|
||||
protected bool $allowEmptyInserts = false;
|
||||
protected bool $updateOnlyChanged = true;
|
||||
|
||||
protected array $casts = [];
|
||||
protected array $castHandlers = [];
|
||||
|
||||
protected $useTimestamps = false;
|
||||
protected $dateFormat = 'datetime';
|
||||
protected $createdField = 'activity_at';
|
||||
protected $updatedField = 'updated_at';
|
||||
protected $deletedField = 'deleted_at';
|
||||
|
||||
protected $validationRules = [];
|
||||
protected $validationMessages = [];
|
||||
protected $skipValidation = false;
|
||||
protected $cleanValidationRules = true;
|
||||
|
||||
protected $allowCallbacks = true;
|
||||
protected $beforeInsert = [];
|
||||
protected $afterInsert = [];
|
||||
protected $beforeUpdate = [];
|
||||
protected $afterUpdate = [];
|
||||
protected $beforeFind = [];
|
||||
protected $afterFind = [];
|
||||
protected $beforeDelete = [];
|
||||
protected $afterDelete = [];
|
||||
|
||||
public function log(string $action, ?string $description = null, ?string $targetType = null, ?int $targetId = null, ?int $actorId = null, ?string $actorRole = null, ?string $ipAddress = null, ?string $userAgent = null): bool
|
||||
{
|
||||
$request = service('request');
|
||||
$actorId = $actorId ?? session()->get('id');
|
||||
$actorRole = $actorRole ?? session()->get('role') ?? 'guest';
|
||||
|
||||
$this->insert([
|
||||
'actor_user_id' => session()->get('id') ? (int) session()->get('id') : null,
|
||||
'actor_role' => session()->get('role') ?: null,
|
||||
$request = service('request');
|
||||
$ipAddress = $ipAddress ?? ($request->hasHeader('X-Forwarded-For') ? trim(explode(',', $request->getHeaderLine('X-Forwarded-For'))[0]) : $request->getIPAddress());
|
||||
$userAgent = $userAgent ?? ($request->hasHeader('User-Agent') ? $request->getHeaderLine('User-Agent') : null);
|
||||
$validTargetTypes = ['admin', 'doctor', 'patient'];
|
||||
$targetUserType = in_array($targetType, $validTargetTypes, true) ? $targetType : null;
|
||||
|
||||
return (bool) $this->insert([
|
||||
'ip' => $ipAddress,
|
||||
'action' => $action,
|
||||
'description' => $description,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'ip_address' => method_exists($request, 'getIPAddress') ? $request->getIPAddress() : null,
|
||||
'user_agent' => method_exists($request, 'getUserAgent') ? $request->getUserAgent()->getAgentString() : null,
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'activity_user_id' => $actorId,
|
||||
'activity_user_type' => $actorRole,
|
||||
'target_user_id' => $targetId,
|
||||
'target_user_type' => $targetUserType,
|
||||
'activity_page' => $request->getPath(),
|
||||
'activity_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getFiltered(array $filters = []): array
|
||||
public function getFilteredLogs(array $filters = [], int $limit = 200): array
|
||||
{
|
||||
$builder = $this->db->table('activity_logs al')
|
||||
->select("al.*, CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, '')) AS actor_name, u.email AS actor_email")
|
||||
->join('users u', 'u.id = al.actor_user_id', 'left');
|
||||
$builder = $this->select('activity_logs.*, COALESCE(NULLIF(CONCAT(users.first_name, " ", users.last_name), " "), users.email, "Guest") AS actor_name')
|
||||
->join('users', 'users.id = activity_logs.activity_user_id', 'left');
|
||||
|
||||
if (! empty($filters['action'])) {
|
||||
$builder->like('al.action', $filters['action']);
|
||||
// Apply filters
|
||||
if (!empty($filters['search'])) {
|
||||
$search = $filters['search'];
|
||||
$builder->groupStart()
|
||||
->like('users.first_name', $search)
|
||||
->orLike('users.last_name', $search)
|
||||
->orLike('users.email', $search)
|
||||
->orLike('activity_logs.description', $search)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if (! empty($filters['role'])) {
|
||||
$builder->where('al.actor_role', $filters['role']);
|
||||
if (!empty($filters['action'])) {
|
||||
$builder->where('activity_logs.action', $filters['action']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_from'])) {
|
||||
$builder->where('DATE(al.created_at) >=', $filters['date_from']);
|
||||
if (!empty($filters['user_type'])) {
|
||||
$builder->where('activity_logs.activity_user_type', $filters['user_type']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_to'])) {
|
||||
$builder->where('DATE(al.created_at) <=', $filters['date_to']);
|
||||
if (!empty($filters['date_from'])) {
|
||||
$builder->where('activity_logs.activity_at >=', $filters['date_from'] . ' 00:00:00');
|
||||
}
|
||||
|
||||
return $builder
|
||||
->orderBy('al.created_at', 'DESC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
if (!empty($filters['date_to'])) {
|
||||
$builder->where('activity_logs.activity_at <=', $filters['date_to'] . ' 23:59:59');
|
||||
}
|
||||
|
||||
public function getRecent(int $limit = 8): array
|
||||
if (!empty($filters['ip'])) {
|
||||
$builder->where('activity_logs.ip', $filters['ip']);
|
||||
}
|
||||
|
||||
return $builder->orderBy('activity_logs.activity_at', 'DESC')
|
||||
->findAll($limit);
|
||||
}
|
||||
|
||||
public function getAvailableActions(): array
|
||||
{
|
||||
return $this->db->table('activity_logs')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->getResultArray();
|
||||
$rows = $this->select('action')
|
||||
->distinct()
|
||||
->orderBy('action', 'ASC')
|
||||
->findAll();
|
||||
|
||||
return array_column($rows, 'action');
|
||||
}
|
||||
|
||||
public function getActionSummary(): array
|
||||
{
|
||||
$rows = $this->select('action, COUNT(*) AS count')
|
||||
->groupBy('action')
|
||||
->orderBy('count', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return array_column($rows, 'count', 'action');
|
||||
}
|
||||
|
||||
public function getRoleSummary(): array
|
||||
{
|
||||
$rows = $this->select('activity_user_type AS actor_role, COUNT(*) AS count')
|
||||
->groupBy('activity_user_type')
|
||||
->orderBy('count', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return array_column($rows, 'count', 'actor_role');
|
||||
}
|
||||
|
||||
public function getSummary(string $period = '7_days'): array
|
||||
{
|
||||
$period = $period === '30_days' ? '30_days' : '7_days';
|
||||
$startDate = $period === '30_days' ? date('Y-m-d H:i:s', strtotime('-30 days')) : date('Y-m-d H:i:s', strtotime('-7 days'));
|
||||
|
||||
return [
|
||||
'total_actions' => (int) $this->where('activity_at >=', $startDate)->countAllResults(false),
|
||||
'by_action' => $this->getActionSummaryForPeriod($startDate),
|
||||
'by_role' => $this->getRoleSummaryForPeriod($startDate),
|
||||
'most_active_users' => $this->getMostActiveUsers($startDate),
|
||||
'unique_ips' => $this->getUniqueIPs($startDate),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getActionSummaryForPeriod(string $startDate): array
|
||||
{
|
||||
$rows = $this->select('action, COUNT(*) AS count')
|
||||
->where('activity_at >=', $startDate)
|
||||
->groupBy('action')
|
||||
->orderBy('count', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return array_column($rows, 'count', 'action');
|
||||
}
|
||||
|
||||
protected function getRoleSummaryForPeriod(string $startDate): array
|
||||
{
|
||||
$rows = $this->select('activity_user_type AS actor_role, COUNT(*) AS count')
|
||||
->where('activity_at >=', $startDate)
|
||||
->groupBy('activity_user_type')
|
||||
->orderBy('count', 'DESC')
|
||||
->findAll();
|
||||
|
||||
return array_column($rows, 'count', 'actor_role');
|
||||
}
|
||||
|
||||
protected function getMostActiveUsers(string $startDate, int $limit = 10): array
|
||||
{
|
||||
return $this->select('COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), ""), users.email, "Guest") AS actor, COUNT(activity_logs.id) AS count')
|
||||
->join('users', 'users.id = activity_logs.activity_user_id', 'left')
|
||||
->where('activity_logs.activity_at >=', $startDate)
|
||||
->groupBy('activity_logs.activity_user_id')
|
||||
->orderBy('count', 'DESC')
|
||||
->findAll($limit);
|
||||
}
|
||||
|
||||
protected function getUniqueIPs(string $startDate, int $limit = 10): array
|
||||
{
|
||||
return $this->select('ip, COUNT(*) AS count')
|
||||
->where('activity_at >=', $startDate)
|
||||
->groupBy('ip')
|
||||
->orderBy('count', 'DESC')
|
||||
->findAll($limit);
|
||||
}
|
||||
|
||||
public function getCriticalActions(int $limit = 50): array
|
||||
{
|
||||
$criticalActions = ['admin_login', 'admin_password_change', 'system_error', 'security_breach'];
|
||||
|
||||
return $this->select('activity_logs.*, COALESCE(NULLIF(TRIM(CONCAT(users.first_name, " ", users.last_name)), ""), users.email, "System") AS actor_name, users.email AS actor_email')
|
||||
->join('users', 'users.id = activity_logs.activity_user_id', 'left')
|
||||
->whereIn('action', $criticalActions)
|
||||
->orderBy('activity_at', 'DESC')
|
||||
->findAll($limit);
|
||||
}
|
||||
}
|
||||
|
||||
322
app/Views/admin/activity_analytics.php
Normal file
322
app/Views/admin/activity_analytics.php
Normal file
@ -0,0 +1,322 @@
|
||||
<?php
|
||||
// Helper function to format labels for charts
|
||||
function formatLabel($label) {
|
||||
if (empty($label)) return 'Unknown';
|
||||
return strlen($label) > 20 ? substr($label, 0, 20) . '...' : $label;
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Activity Analytics Dashboard</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
|
||||
<link rel="stylesheet" href="<?= base_url('css/dashboard.css') ?>">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 350px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.stat-card.green { background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); }
|
||||
.stat-card.blue { background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); }
|
||||
.stat-card.orange { background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); }
|
||||
.stat-value { font-size: 2.5rem; font-weight: bold; margin: 0; }
|
||||
.stat-label { font-size: 0.9rem; opacity: 0.9; margin: 0.5rem 0 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="app-body overview-layout">
|
||||
<aside class="ov-sidebar" id="sidebar">
|
||||
<div class="ov-brand"><h1><i class="bi bi-hospital me-1"></i> DoctGuide</h1><span>Control Panel</span></div>
|
||||
<nav class="ov-nav">
|
||||
<div class="ov-nav__section">Main</div>
|
||||
<a href="<?= base_url('admin/dashboard') ?>" class="ov-nav__link"><i class="bi bi-speedometer2"></i> Dashboard</a>
|
||||
<div class="ov-nav__section">Tools</div>
|
||||
<a href="<?= base_url('admin/activity-log') ?>" class="ov-nav__link"><i class="bi bi-clipboard-data"></i> Activity Log</a>
|
||||
<a href="<?= base_url('admin/activity/analytics') ?>" class="ov-nav__link active"><i class="bi bi-graph-up"></i> Analytics</a>
|
||||
</nav>
|
||||
<div class="ov-sidebar__footer"><a href="<?= base_url('logout') ?>"><i class="bi bi-box-arrow-left"></i> Logout</a></div>
|
||||
</aside>
|
||||
|
||||
<div class="ov-main" id="mainContent">
|
||||
<header class="ov-topbar">
|
||||
<div class="d-flex align-items-center">
|
||||
<button class="ov-toggle-btn" onclick="toggleSidebar()" title="Toggle Sidebar"><i class="bi bi-list" id="toggleIcon"></i></button>
|
||||
<p class="ov-topbar__title mb-0">Activity Analytics</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="ov-content">
|
||||
<div class="ov-panel mb-4">
|
||||
<div class="ov-panel__header">
|
||||
<h2 class="ov-panel__title">Analytics Dashboard</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm" style="width: auto;" onchange="changeAnalyticsPeriod(this.value)">
|
||||
<option value="7_days" <?= $period === '7_days' ? 'selected' : '' ?>>Last 7 Days</option>
|
||||
<option value="30_days" <?= $period === '30_days' ? 'selected' : '' ?>>Last 30 Days</option>
|
||||
</select>
|
||||
<a href="<?= base_url('admin/activity-log') ?>" class="btn btn-sm btn-outline-secondary">Back to Logs</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-panel__body">
|
||||
<!-- Summary Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<p class="stat-value"><?= $summary['total_actions'] ?? 0 ?></p>
|
||||
<p class="stat-label">Total Actions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card green">
|
||||
<p class="stat-value"><?= count($summary['by_action'] ?? []) ?></p>
|
||||
<p class="stat-label">Action Types</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card blue">
|
||||
<p class="stat-value"><?= count($summary['by_role'] ?? []) ?></p>
|
||||
<p class="stat-label">Active Roles</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card orange">
|
||||
<p class="stat-value"><?= count($summary['most_active_users'] ?? []) ?></p>
|
||||
<p class="stat-label">Active Users</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (($summary['total_actions'] ?? 0) == 0): ?>
|
||||
<!-- No Data Message -->
|
||||
<div class="alert alert-info" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>No Activity Data Available</strong>
|
||||
<p class="mb-0">There are no activity logs recorded yet. Once you start using the system (login, create/update records, etc.), the analytics dashboard will display detailed statistics and charts.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- Charts -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h5 class="mb-3">Actions Distribution</h5>
|
||||
<div class="chart-container">
|
||||
<canvas id="actionsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<h5 class="mb-3">Activity by Role</h5>
|
||||
<div class="chart-container">
|
||||
<canvas id="rolesChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<h5 class="mb-3">Most Active Users</h5>
|
||||
<div class="chart-container">
|
||||
<canvas id="usersChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<h5 class="mb-3">Top IP Addresses</h5>
|
||||
<div style="max-height: 350px; overflow-y: auto;">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP Address</th>
|
||||
<th class="text-end">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!empty($uniqueIPs)): ?>
|
||||
<?php foreach (array_slice($uniqueIPs, 0, 10) as $ip): ?>
|
||||
<tr>
|
||||
<td><code><?= esc($ip['ip']) ?></code></td>
|
||||
<td class="text-end"><span class="badge bg-info"><?= $ip['count'] ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="2" class="text-muted text-center py-3">No IP data available</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical Actions Section -->
|
||||
<div class="ov-panel">
|
||||
<div class="ov-panel__header">
|
||||
<h2 class="ov-panel__title">Critical Actions (Recent)</h2>
|
||||
<span class="badge bg-danger"><?= count($criticalActions) ?> critical actions</span>
|
||||
</div>
|
||||
<div class="ov-panel__body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>User</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($criticalActions)): ?>
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-3">No critical actions found</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach (array_slice($criticalActions, 0, 50) as $action): ?>
|
||||
<tr style="border-left: 4px solid #dc2626;">
|
||||
<td class="text-nowrap small"><?= date('Y-m-d H:i', strtotime($action['activity_at'])) ?></td>
|
||||
<td>
|
||||
<strong><?= esc(trim($action['actor_name'] ?? 'System')) ?></strong><br>
|
||||
<small class="text-muted"><?= esc($action['actor_email'] ?? '') ?></small>
|
||||
</td>
|
||||
<td><span class="badge bg-danger"><?= esc($action['action']) ?></span></td>
|
||||
<td><?= esc($action['target_user_type'] ?? '-') ?> #<?= $action['target_user_id'] ?? 'N/A' ?></td>
|
||||
<td><?= esc($action['description']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('mainContent');
|
||||
const icon = document.getElementById('toggleIcon');
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('expanded');
|
||||
icon.className = sidebar.classList.contains('collapsed') ? 'bi bi-layout-sidebar' : 'bi bi-list';
|
||||
}
|
||||
|
||||
function changeAnalyticsPeriod(period) {
|
||||
window.location.href = '<?= base_url('admin/activity/analytics') ?>?period=' + period;
|
||||
}
|
||||
|
||||
// Chart color palettes
|
||||
const chartColors = {
|
||||
primary: '#667eea',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
};
|
||||
|
||||
// Get data
|
||||
const actionLabels = <?= $actionLabels ?>;
|
||||
const actionCounts = <?= $actionCounts ?>;
|
||||
const typeLabels = <?= $typeLabels ?>;
|
||||
const typeCounts = <?= $typeCounts ?>;
|
||||
const userLabels = <?= $userLabels ?>;
|
||||
const userCounts = <?= $userCounts ?>;
|
||||
|
||||
// Only create charts if data is available
|
||||
if (actionLabels.length > 0) {
|
||||
// Actions Chart
|
||||
const actionsCtx = document.getElementById('actionsChart')?.getContext('2d');
|
||||
if (actionsCtx) {
|
||||
new Chart(actionsCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: actionLabels.map(l => l.length > 15 ? l.substring(0, 15) + '...' : l),
|
||||
datasets: [{
|
||||
data: actionCounts,
|
||||
backgroundColor: [
|
||||
chartColors.primary, chartColors.success, chartColors.warning,
|
||||
chartColors.danger, chartColors.info, '#8b5cf6', '#ec4899', '#f97316'
|
||||
],
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'bottom' }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// User Types Chart
|
||||
const rolesCtx = document.getElementById('rolesChart')?.getContext('2d');
|
||||
if (rolesCtx && typeLabels.length > 0) {
|
||||
new Chart(rolesCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: typeLabels,
|
||||
datasets: [{
|
||||
label: 'Count',
|
||||
data: typeCounts,
|
||||
backgroundColor: chartColors.primary,
|
||||
borderColor: chartColors.primary,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { x: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Users Chart
|
||||
const usersCtx = document.getElementById('usersChart')?.getContext('2d');
|
||||
if (usersCtx && userLabels.length > 0) {
|
||||
new Chart(usersCtx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: userLabels.map(l => l && l.length > 15 ? l.substring(0, 15) + '...' : l),
|
||||
datasets: [{
|
||||
label: 'Actions',
|
||||
data: userCounts,
|
||||
backgroundColor: chartColors.success,
|
||||
borderColor: chartColors.success,
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true } }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -8,8 +8,75 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="<?= base_url('css/app.css') ?>">
|
||||
<link rel="stylesheet" href="<?= base_url('css/dashboard.css') ?>">
|
||||
<style>
|
||||
.stat-card .ov-stat__icon {
|
||||
background: var(--bs-primary) !important;
|
||||
color: white !important;
|
||||
}
|
||||
.stat-card:nth-child(2) .ov-stat__icon {
|
||||
background: var(--bs-success) !important;
|
||||
}
|
||||
.stat-card:nth-child(3) .ov-stat__icon {
|
||||
background: var(--bs-info) !important;
|
||||
}
|
||||
.avatar-circle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.avatar-circle.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.description-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-responsive {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.table thead th {
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.badge {
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.form-check-input:checked {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
#selectedCount {
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
}
|
||||
.input-group-text {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style> <link rel="stylesheet" href="<?= base_url('css/doctors.css') ?>">
|
||||
</head>
|
||||
<body class="app-body overview-layout">
|
||||
|
||||
<aside class="ov-sidebar" id="sidebar">
|
||||
<div class="ov-brand"><h1><i class="bi bi-hospital me-1"></i> DoctGuide</h1><span>Control Panel</span></div>
|
||||
<nav class="ov-nav">
|
||||
@ -51,79 +118,187 @@
|
||||
</header>
|
||||
|
||||
<main class="ov-content">
|
||||
<!-- Search & Filter Section -->
|
||||
<div class="ov-panel mb-4">
|
||||
<div class="ov-panel__header">
|
||||
<h2 class="ov-panel__title">Track System Activity</h2>
|
||||
<a href="<?= base_url('admin/dashboard') ?>" class="btn btn-sm btn-outline-secondary px-3">Back to dashboard</a>
|
||||
<h2 class="ov-panel__title mb-0">
|
||||
<button class="btn btn-link p-0 text-decoration-none" onclick="toggleFilters()" id="filterToggle">
|
||||
<i class="bi bi-funnel me-2"></i>Search & Filters
|
||||
<i class="bi bi-chevron-down ms-2" id="filterIcon"></i>
|
||||
</button>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="ov-panel__body">
|
||||
<form method="get" action="<?= base_url('admin/activity-log') ?>" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="action">Action</label>
|
||||
<input type="text" class="form-control" id="action" name="action" value="<?= esc($filters['action'] ?? '') ?>" placeholder="login, create_doctor...">
|
||||
<div class="ov-panel__body" id="filterSection" style="display: none;">
|
||||
<form method="GET" action="<?= base_url('admin/activity-log') ?>" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">Search</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
value="<?= esc($filters['search'] ?? '') ?>"
|
||||
placeholder="Search by name, email, or description">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="role">Role</label>
|
||||
<select class="form-select" id="role" name="role">
|
||||
<option value="">All roles</option>
|
||||
<option value="admin" <?= ($filters['role'] ?? '') === 'admin' ? 'selected' : '' ?>>Admin</option>
|
||||
<option value="doctor" <?= ($filters['role'] ?? '') === 'doctor' ? 'selected' : '' ?>>Doctor</option>
|
||||
<option value="patient" <?= ($filters['role'] ?? '') === 'patient' ? 'selected' : '' ?>>Patient</option>
|
||||
<div class="col-md-2">
|
||||
<label for="action" class="form-label">Action</label>
|
||||
<select class="form-select" id="action" name="action">
|
||||
<option value="">All Actions</option>
|
||||
<?php foreach ($availableActions as $actionType): ?>
|
||||
<option value="<?= esc($actionType) ?>" <?= ($filters['action'] ?? '') === $actionType ? 'selected' : '' ?>>
|
||||
<?= esc(ucwords(str_replace('_', ' ', $actionType))) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="date_from">From</label>
|
||||
<input type="date" class="form-control" id="date_from" name="date_from" value="<?= esc($filters['date_from'] ?? '') ?>">
|
||||
<label for="user_type" class="form-label">User Type</label>
|
||||
<select class="form-select" id="user_type" name="user_type">
|
||||
<option value="">All Types</option>
|
||||
<option value="admin" <?= ($filters['user_type'] ?? '') === 'admin' ? 'selected' : '' ?>>Admin</option>
|
||||
<option value="doctor" <?= ($filters['user_type'] ?? '') === 'doctor' ? 'selected' : '' ?>>Doctor</option>
|
||||
<option value="patient" <?= ($filters['user_type'] ?? '') === 'patient' ? 'selected' : '' ?>>Patient</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label" for="date_to">To</label>
|
||||
<input type="date" class="form-control" id="date_to" name="date_to" value="<?= esc($filters['date_to'] ?? '') ?>">
|
||||
<label for="date_from" class="form-label">From Date</label>
|
||||
<input type="date" class="form-control" id="date_from" name="date_from"
|
||||
value="<?= esc($filters['date_from'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="date_to" class="form-label">To Date</label>
|
||||
<input type="date" class="form-control" id="date_to" name="date_to"
|
||||
value="<?= esc($filters['date_to'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="ip" class="form-label">IP Address</label>
|
||||
<input type="text" class="form-control" id="ip" name="ip"
|
||||
value="<?= esc($filters['ip'] ?? '') ?>"
|
||||
placeholder="Filter by IP address">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search me-1"></i>Search
|
||||
</button>
|
||||
<a href="<?= base_url('admin/activity-log') ?>" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>Clear Filters
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end gap-2">
|
||||
<button type="submit" class="btn btn-app-primary px-4 py-2 w-100">Filter</button>
|
||||
<a href="<?= base_url('admin/activity-log') ?>" class="btn btn-outline-secondary px-4 py-2 w-100">Reset</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ov-panel">
|
||||
<div class="ov-panel__header">
|
||||
<h2 class="ov-panel__title">Entries</h2>
|
||||
<span class="badge bg-light text-dark border"><?= count($logs) ?> records</span>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="ov-stat stat-card">
|
||||
<div class="ov-stat__icon bg-primary">
|
||||
<i class="bi bi-clipboard-data"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="ov-stat__label">Total Entries</div>
|
||||
<p class="ov-stat__value"><?= esc($totalLogs) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="ov-stat stat-card">
|
||||
<div class="ov-stat__icon bg-success">
|
||||
<i class="bi bi-list-check"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="ov-stat__label">Action Types</div>
|
||||
<p class="ov-stat__value"><?= esc(count($actionSummary)) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="ov-stat stat-card">
|
||||
<div class="ov-stat__icon bg-info">
|
||||
<i class="bi bi-people"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="ov-stat__label">Actor Roles</div>
|
||||
<p class="ov-stat__value"><?= esc(count($roleSummary)) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ov-panel mb-4">
|
||||
<div class="ov-panel__header d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center gap-2">
|
||||
<div>
|
||||
<h2 class="ov-panel__title mb-1">Recent Activity</h2>
|
||||
<p class="text-muted mb-0">Latest actions recorded from the application.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= base_url('admin/activity/analytics') ?>" class="btn btn-sm btn-outline-secondary">View Analytics</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ov-panel__body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table ov-mini-table mb-0">
|
||||
<thead>
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Time</th>
|
||||
<th class="text-nowrap">Timestamp</th>
|
||||
<th>Actor</th>
|
||||
<th>Role</th>
|
||||
<th>Action</th>
|
||||
<th>Description</th>
|
||||
<th>Target</th>
|
||||
<th>Description</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($logs === []): ?>
|
||||
<?php if (empty($logs)): ?>
|
||||
<tr>
|
||||
<td colspan="7" class="ps-3 text-muted">No activity found.</td>
|
||||
<td colspan="7" class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox fs-1 d-block mb-2 text-muted"></i>
|
||||
No activity logs found matching your criteria.
|
||||
</td>
|
||||
</tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($logs as $log): ?>
|
||||
<tr>
|
||||
<td class="ps-3 text-nowrap"><?= esc($log['created_at']) ?></td>
|
||||
<td>
|
||||
<div class="fw-medium"><?= esc(trim((string) ($log['actor_name'] ?? ''))) !== '' ? esc(trim((string) $log['actor_name'])) : 'System' ?></div>
|
||||
<div class="text-muted small"><?= esc($log['actor_email'] ?? '') ?></div>
|
||||
<td class="text-nowrap small">
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-medium"><?= esc(date('M d, Y', strtotime($log['activity_at']))) ?></span>
|
||||
<small class="text-muted"><?= esc(date('H:i:s', strtotime($log['activity_at']))) ?></small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-circle me-2 bg-<?= $log['activity_user_type'] === 'admin' ? 'primary' : ($log['activity_user_type'] === 'doctor' ? 'success' : 'info') ?> text-white small">
|
||||
<?= strtoupper(substr(!empty($log['actor_name']) ? $log['actor_name'] : 'G', 0, 1)) ?>
|
||||
</div>
|
||||
<span class="fw-medium"><?= esc($log['actor_name'] ?? 'Guest') ?></span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-<?= $log['activity_user_type'] === 'admin' ? 'primary' : ($log['activity_user_type'] === 'doctor' ? 'success' : 'info') ?> text-uppercase small">
|
||||
<?= esc($log['activity_user_type'] ?? 'guest') ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary text-uppercase small">
|
||||
<i class="bi bi-<?= esc($actionIcons[$log['action']] ?? 'activity') ?> me-1"></i>
|
||||
<?= esc(str_replace('_', ' ', $log['action'])) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($log['target_user_type']): ?>
|
||||
<span class="badge bg-light text-dark small">
|
||||
<?= esc($log['target_user_type']) ?> #<?= esc($log['target_user_id']) ?>
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="text-muted">-</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<div class="description-cell">
|
||||
<?= esc($log['description'] ?? '-') ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<code class="small bg-light px-2 py-1 rounded"><?= esc($log['ip'] ?? '-') ?></code>
|
||||
</td>
|
||||
<td><?= esc($log['actor_role'] ?? '-') ?></td>
|
||||
<td><span class="badge bg-secondary"><?= esc($log['action']) ?></span></td>
|
||||
<td><?= esc($log['description']) ?></td>
|
||||
<td><?= esc(($log['target_type'] ?? '-') . ((isset($log['target_id']) && $log['target_id'] !== null) ? ' #' . $log['target_id'] : '')) ?></td>
|
||||
<td class="text-nowrap"><?= esc($log['ip_address'] ?? '-') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
@ -144,6 +319,21 @@ function toggleSidebar() {
|
||||
main.classList.toggle('expanded');
|
||||
icon.className = sidebar.classList.contains('collapsed') ? 'bi bi-layout-sidebar' : 'bi bi-list';
|
||||
}
|
||||
|
||||
function toggleNavDropdown(event, element) {
|
||||
event.preventDefault();
|
||||
element.parentElement.classList.toggle('open');
|
||||
}
|
||||
|
||||
function toggleFilters() {
|
||||
const filterSection = document.getElementById('filterSection');
|
||||
const filterIcon = document.getElementById('filterIcon');
|
||||
const isVisible = filterSection.style.display !== 'none';
|
||||
|
||||
filterSection.style.display = isVisible ? 'none' : 'block';
|
||||
filterIcon.className = isVisible ? 'bi bi-chevron-down ms-2' : 'bi bi-chevron-up ms-2';
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6
temp_check.php
Normal file
6
temp_check.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require 'c:\xampp\htdocs\appointment_doctor\vendor\autoload.php';
|
||||
$appConfig = new Config\App();
|
||||
echo 'App timezone: ' . $appConfig->appTimezone . PHP_EOL;
|
||||
echo 'Time now: ' . CodeIgniter\I18n\Time::now($appConfig->appTimezone)->toDateTimeString() . PHP_EOL;
|
||||
echo 'PHP date: ' . date('Y-m-d H:i:s') . PHP_EOL;
|
||||
Loading…
x
Reference in New Issue
Block a user