Commit 5d367d6d authored by Administrator's avatar Administrator

Update 44 files via Son of Anton

parent 4d7f6023
<?php
declare(strict_types=1);
namespace App\Modules\Dashboard\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Dashboard\Services\DashboardDataService;
class DashboardController extends Controller
{
public function index(Request $request): Response
{
$data = DashboardDataService::getData();
return $this->view('Dashboard.Views.index', $data);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/dashboard', 'Dashboard\Controllers\DashboardController@index', ['auth'], null],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Dashboard\Services;
use App\Core\App;
final class DashboardDataService
{
public static function getData(): array
{
$db = App::getInstance()->db();
$data = [];
// Summary Cards
try {
$data['total_active'] = (int) ($db->selectOne("SELECT COUNT(*) as c FROM members WHERE status = 'active' AND is_archived = 0")['c'] ?? 0);
} catch (\Throwable $e) { $data['total_active'] = 0; }
try {
$data['new_this_month'] = (int) ($db->selectOne("SELECT COUNT(*) as c FROM members WHERE is_archived = 0 AND created_at >= ?", [date('Y-m-01')])['c'] ?? 0);
} catch (\Throwable $e) { $data['new_this_month'] = 0; }
try {
$data['total_revenue_month'] = $db->selectOne("SELECT COALESCE(SUM(amount), 0) as t FROM payments WHERE is_voided = 0 AND payment_date >= ?", [date('Y-m-01')])['t'] ?? '0.00';
} catch (\Throwable $e) { $data['total_revenue_month'] = '0.00'; }
try {
$data['pending_interviews'] = (int) ($db->selectOne("SELECT COUNT(*) as c FROM interviews WHERE decision = 'pending' AND status = 'scheduled'")['c'] ?? 0);
} catch (\Throwable $e) { $data['pending_interviews'] = 0; }
try {
$data['overdue_installments'] = (int) ($db->selectOne("SELECT COUNT(DISTINCT p.member_id) as c FROM installment_plans p JOIN installment_schedule s ON s.installment_plan_id = p.id WHERE p.status = 'active' AND s.status = 'pending' AND s.due_date < CURDATE()")['c'] ?? 0);
} catch (\Throwable $e) { $data['overdue_installments'] = 0; }
try {
$data['total_members_by_branch'] = $db->select("SELECT b.name_ar, COUNT(m.id) as cnt FROM branches b LEFT JOIN members m ON m.branch_id = b.id AND m.is_archived = 0 AND m.status = 'active' WHERE b.is_active = 1 GROUP BY b.id, b.name_ar ORDER BY cnt DESC");
} catch (\Throwable $e) { $data['total_members_by_branch'] = []; }
// Monthly revenue (last 6 months)
try {
$data['monthly_revenue'] = $db->select("SELECT DATE_FORMAT(payment_date, '%Y-%m') as month, SUM(amount) as total FROM payments WHERE is_voided = 0 AND payment_date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) GROUP BY month ORDER BY month ASC");
} catch (\Throwable $e) { $data['monthly_revenue'] = []; }
// Recent activity
try {
$data['recent_activity'] = $db->select("SELECT employee_name, action, entity_type, entity_label, created_at FROM audit_trail ORDER BY created_at DESC LIMIT 15");
} catch (\Throwable $e) { $data['recent_activity'] = []; }
// Alerts
$data['alerts'] = self::getAlerts($db);
return $data;
}
private static function getAlerts($db): array
{
$alerts = [];
// Children approaching 25
try {
$approaching25 = (int) ($db->selectOne("SELECT COUNT(*) as c FROM children WHERE is_archived = 0 AND gender = 'male' AND status = 'active' AND date_of_birth BETWEEN DATE_SUB(CURDATE(), INTERVAL 25 YEAR) AND DATE_SUB(CURDATE(), INTERVAL 24 YEAR 9 MONTH)")['c'] ?? 0);
if ($approaching25 > 0) $alerts[] = ['type' => 'warning', 'message' => "{$approaching25} ابن ذكر يقترب من 25 سنة", 'link' => '/reports/view/RPT_CHILDREN_25'];
} catch (\Throwable $e) {}
// Expiring forms
try {
$expiringForms = (int) ($db->selectOne("SELECT COUNT(*) as c FROM form_submissions WHERE status = 'submitted' AND expires_at IS NOT NULL AND expires_at BETWEEN NOW() AND DATE_ADD(NOW(), INTERVAL 3 DAY)")['c'] ?? 0);
if ($expiringForms > 0) $alerts[] = ['type' => 'danger', 'message' => "{$expiringForms} استمارة تنتهي خلال 3 أيام", 'link' => '/forms/submissions'];
} catch (\Throwable $e) {}
// Overdue installments
try {
$overdueInst = (int) ($db->selectOne("SELECT COUNT(*) as c FROM installment_schedule s JOIN installment_plans p ON p.id = s.installment_plan_id WHERE p.status = 'active' AND s.status = 'pending' AND s.due_date < CURDATE()")['c'] ?? 0);
if ($overdueInst > 0) $alerts[] = ['type' => 'danger', 'message' => "{$overdueInst} قسط متأخر", 'link' => '/installments?overdue_only=1'];
} catch (\Throwable $e) {}
// Honorary expiring
try {
$honoraryExpiring = (int) ($db->selectOne("SELECT COUNT(*) as c FROM honorary_members WHERE is_archived = 0 AND status = 'active' AND end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 30 DAY)")['c'] ?? 0);
if ($honoraryExpiring > 0) $alerts[] = ['type' => 'info', 'message' => "{$honoraryExpiring} عضوية شرفية تنتهي خلال 30 يوم", 'link' => '/honorary'];
} catch (\Throwable $e) {}
return $alerts;
}
}
\ No newline at end of file
<?php
// Placeholder for dynamically registered widgets from WidgetRegistry
// Future modules can register widgets that appear here
?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>لوحة التحكم<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Summary Cards -->
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:15px;margin-bottom:25px;">
<div class="card" style="padding:20px;border-right:4px solid #0D7377;">
<div style="font-size:28px;font-weight:700;color:#0D7377;"><?= number_format($total_active ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">أعضاء نشطون</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #059669;">
<div style="font-size:28px;font-weight:700;color:#059669;"><?= number_format($new_this_month ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">جدد هذا الشهر</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #0284C7;">
<div style="font-size:28px;font-weight:700;color:#0284C7;"><?= money($total_revenue_month ?? '0') ?></div>
<div style="color:#6B7280;font-size:13px;">إيرادات الشهر</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #D97706;">
<div style="font-size:28px;font-weight:700;color:#D97706;"><?= (int) ($pending_interviews ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">مقابلات معلقة</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #DC2626;">
<div style="font-size:28px;font-weight:700;color:#DC2626;"><?= (int) ($overdue_installments ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">أقساط متأخرة</div>
</div>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr;gap:20px;">
<div>
<!-- Revenue Chart -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">الإيرادات الشهرية</h3></div>
<div style="padding:20px;" id="revenue-chart">
<?php $maxRevenue = max(array_column($monthly_revenue ?? [], 'total') ?: [1]); ?>
<div style="display:flex;align-items:flex-end;gap:8px;height:200px;">
<?php foreach (($monthly_revenue ?? []) as $mr): ?>
<?php $pct = $maxRevenue > 0 ? ((float) $mr['total'] / (float) $maxRevenue) * 100 : 0; ?>
<div style="flex:1;text-align:center;">
<div style="background:#0D7377;height:<?= max(4, $pct) ?>%;border-radius:4px 4px 0 0;min-height:4px;transition:height 0.3s;" title="<?= money($mr['total']) ?>"></div>
<div style="font-size:10px;color:#6B7280;margin-top:4px;"><?= e(substr($mr['month'], 5)) ?></div>
</div>
<?php endforeach; ?>
<?php if (empty($monthly_revenue)): ?><div style="width:100%;text-align:center;color:#9CA3AF;padding:60px 0;">لا توجد بيانات</div><?php endif; ?>
</div>
</div>
</div>
<!-- Branch Comparison -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">الأعضاء حسب الفرع</h3></div>
<div style="padding:20px;">
<?php $maxBranch = max(array_column($total_members_by_branch ?? [], 'cnt') ?: [1]); ?>
<?php foreach (($total_members_by_branch ?? []) as $br): ?>
<div style="margin-bottom:12px;">
<div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span style="font-size:13px;"><?= e($br['name_ar']) ?></span><strong><?= number_format((int) $br['cnt']) ?></strong></div>
<div style="background:#E5E7EB;border-radius:4px;height:8px;"><div style="background:#0D7377;border-radius:4px;height:8px;width:<?= $maxBranch > 0 ? ((int) $br['cnt'] / (int) $maxBranch) * 100 : 0 ?>%;"></div></div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<div>
<!-- Alerts -->
<?php if (!empty($alerts)): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#DC2626;">⚠ تنبيهات</h3></div>
<div style="padding:15px;">
<?php foreach ($alerts as $alert): ?>
<a href="<?= e($alert['link'] ?? '#') ?>" style="display:block;padding:10px;margin-bottom:8px;border-radius:6px;background:<?= match($alert['type']) { 'danger' => '#FEF2F2', 'warning' => '#FFF7ED', 'info' => '#EFF6FF', default => '#F9FAFB' } ?>;border:1px solid <?= match($alert['type']) { 'danger' => '#FECACA', 'warning' => '#FED7AA', 'info' => '#BFDBFE', default => '#E5E7EB' } ?>;color:<?= match($alert['type']) { 'danger' => '#DC2626', 'warning' => '#D97706', 'info' => '#0284C7', default => '#6B7280' } ?>;font-size:13px;font-weight:600;text-decoration:none;">
<?= e($alert['message']) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- Recent Activity -->
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">النشاط الأخير</h3></div>
<div style="padding:10px 15px;max-height:400px;overflow-y:auto;">
<?php foreach (($recent_activity ?? []) as $act): ?>
<div style="padding:8px 0;border-bottom:1px solid #F3F4F6;font-size:12px;">
<div style="display:flex;justify-content:space-between;">
<strong style="color:#1A1A2E;"><?= e($act['employee_name'] ?? 'النظام') ?></strong>
<span style="color:#9CA3AF;"><?= e(substr($act['created_at'], 11, 5)) ?></span>
</div>
<div style="color:#6B7280;"><?= e($act['action']) ?><?= e($act['entity_label'] ?? $act['entity_type'] ?? '') ?></div>
</div>
<?php endforeach; ?>
<?php if (empty($recent_activity)): ?><div style="padding:20px;text-align:center;color:#9CA3AF;">لا يوجد نشاط</div><?php endif; ?>
</div>
</div>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
MenuRegistry::register('dashboard', [
'label_ar' => 'لوحة التحكم',
'label_en' => 'Dashboard',
'icon' => 'dashboard',
'route' => '/dashboard',
'permission' => null,
'parent' => null,
'order' => 10,
'children' => [],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Notifications\Models\SmsTemplate;
use App\Modules\Notifications\Models\SmsLog;
use App\Modules\Notifications\Services\SmsService;
class NotificationController extends Controller
{
public function templates(Request $request): Response
{
$templates = SmsTemplate::getAll();
return $this->view('Notifications.Views.templates', ['templates' => $templates]);
}
public function updateTemplate(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$template = $db->selectOne("SELECT * FROM sms_templates WHERE id = ?", [(int) $id]);
if (!$template) return $this->redirect('/notifications/templates')->withError('القالب غير موجود');
$messageAr = trim((string) $request->post('message_template_ar', ''));
if ($messageAr === '') return $this->redirect('/notifications/templates')->withError('نص الرسالة مطلوب');
$db->update('sms_templates', [
'message_template_ar' => $messageAr,
'name_ar' => trim((string) $request->post('name_ar', $template['name_ar'])),
'is_active' => (int) $request->post('is_active', 1),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $id]);
return $this->redirect('/notifications/templates')->withSuccess('تم تحديث القالب');
}
public function log(Request $request): Response
{
$filters = [
'search' => trim((string) $request->get('q', '')),
'status' => $request->get('status', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = SmsLog::search($filters, 30, $page);
return $this->view('Notifications.Views.log', [
'rows' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function sendForm(Request $request): Response
{
$templates = SmsTemplate::getAll();
return $this->view('Notifications.Views.send', ['templates' => $templates]);
}
public function send(Request $request): Response
{
$phone = trim((string) $request->post('phone_number', ''));
$memberId = $request->post('member_id') ? (int) $request->post('member_id') : null;
$templateId = $request->post('template_id') ? (int) $request->post('template_id') : null;
$customMessage = trim((string) $request->post('custom_message', ''));
if ($phone === '') return $this->redirect('/notifications/send')->withError('رقم الهاتف مطلوب');
$message = $customMessage;
if ($templateId && $message === '') {
$template = App::getInstance()->db()->selectOne("SELECT * FROM sms_templates WHERE id = ?", [$templateId]);
$message = $template ? $template['message_template_ar'] : '';
}
if ($message === '') return $this->redirect('/notifications/send')->withError('نص الرسالة مطلوب');
$result = SmsService::send($phone, $message, $memberId, $templateId);
if ($result['success']) {
return $this->redirect('/notifications/log')->withSuccess('تم إرسال الرسالة بنجاح');
}
return $this->redirect('/notifications/send')->withError('فشل الإرسال: ' . ($result['error'] ?? 'خطأ غير معروف'));
}
public function sendBulk(Request $request): Response
{
$filter = $request->post('filter', 'all_active');
$templateId = (int) $request->post('template_id', 0);
$customMessage = trim((string) $request->post('custom_message', ''));
$db = App::getInstance()->db();
$template = $templateId ? $db->selectOne("SELECT * FROM sms_templates WHERE id = ?", [$templateId]) : null;
$messageTemplate = $customMessage ?: ($template ? $template['message_template_ar'] : '');
if ($messageTemplate === '') return $this->redirect('/notifications/send')->withError('نص الرسالة مطلوب');
$members = [];
switch ($filter) {
case 'all_active':
$members = $db->select("SELECT id, full_name_ar, phone_mobile FROM members WHERE status = 'active' AND is_archived = 0 AND phone_mobile IS NOT NULL");
break;
case 'unpaid_subscription':
$year = self::getCurrentFinancialYear();
$members = $db->select(
"SELECT DISTINCT m.id, m.full_name_ar, m.phone_mobile FROM members m
JOIN subscriptions s ON s.member_id = m.id
WHERE s.financial_year = ? AND s.status IN ('pending','overdue')
AND m.is_archived = 0 AND m.phone_mobile IS NOT NULL",
[$year]
);
break;
case 'overdue_installments':
$members = $db->select(
"SELECT DISTINCT m.id, m.full_name_ar, m.phone_mobile FROM members m
JOIN installment_plans p ON p.member_id = m.id
JOIN installment_schedule s ON s.installment_plan_id = p.id
WHERE p.status = 'active' AND s.status = 'pending' AND s.due_date < CURDATE()
AND m.is_archived = 0 AND m.phone_mobile IS NOT NULL"
);
break;
default:
$members = $db->select("SELECT id, full_name_ar, phone_mobile FROM members WHERE status = 'active' AND is_archived = 0 AND phone_mobile IS NOT NULL");
}
$sent = 0;
foreach ($members as $m) {
$msg = str_replace('{member_name}', $m['full_name_ar'], $messageTemplate);
SmsService::queue($m['phone_mobile'], $msg, (int) $m['id'], $templateId ?: null);
$sent++;
}
return $this->redirect('/notifications/log')->withSuccess("تم جدولة {$sent} رسالة للإرسال");
}
private static function getCurrentFinancialYear(): string
{
$month = (int) date('n');
$year = (int) date('Y');
return $month >= 7 ? $year . '/' . ($year + 1) : ($year - 1) . '/' . $year;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Models;
use App\Core\App;
class NotificationQueue
{
public static function add(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('notification_queue', array_merge([
'status' => 'pending',
'attempts' => 0,
'created_at' => date('Y-m-d H:i:s'),
], $data));
}
public static function getPending(int $limit = 50): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM notification_queue WHERE status = 'pending' AND attempts < max_attempts AND (scheduled_at IS NULL OR scheduled_at <= NOW()) ORDER BY priority DESC, created_at ASC LIMIT ?",
[$limit]
);
}
public static function markProcessed(int $id): void
{
$db = App::getInstance()->db();
$db->update('notification_queue', ['status' => 'sent', 'processed_at' => date('Y-m-d H:i:s')], '`id` = ?', [$id]);
}
public static function markFailed(int $id): void
{
$db = App::getInstance()->db();
$db->query("UPDATE notification_queue SET attempts = attempts + 1, status = IF(attempts + 1 >= max_attempts, 'failed', 'pending') WHERE id = ?", [$id]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Models;
use App\Core\App;
use App\Core\Pagination;
class SmsLog
{
public static function create(array $data): int
{
$db = App::getInstance()->db();
return $db->insert('sms_log', $data);
}
public static function search(array $filters, int $perPage = 30, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) { $where .= ' AND s.status = ?'; $params[] = $filters['status']; }
if (!empty($filters['date_from'])) { $where .= ' AND s.created_at >= ?'; $params[] = $filters['date_from'] . ' 00:00:00'; }
if (!empty($filters['date_to'])) { $where .= ' AND s.created_at <= ?'; $params[] = $filters['date_to'] . ' 23:59:59'; }
if (!empty($filters['search'])) {
$where .= ' AND (s.phone_number LIKE ? OR s.message_text LIKE ?)';
$s = '%' . $filters['search'] . '%'; $params[] = $s; $params[] = $s;
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM sms_log s WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT s.*, m.full_name_ar as member_name FROM sms_log s LEFT JOIN members m ON m.id = s.member_id WHERE {$where} ORDER BY s.created_at DESC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
public static function getMonthlyCost(): string
{
$db = App::getInstance()->db();
$firstDay = date('Y-m-01');
$row = $db->selectOne("SELECT COALESCE(SUM(cost), 0) as total FROM sms_log WHERE created_at >= ? AND status = 'sent'", [$firstDay]);
return $row['total'] ?? '0.0000';
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Models;
use App\Core\App;
class SmsTemplate
{
public static function getAll(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT * FROM sms_templates ORDER BY name_ar");
}
public static function findByCode(string $code): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM sms_templates WHERE template_code = ? AND is_active = 1", [$code]);
}
public static function findByEvent(string $event): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM sms_templates WHERE trigger_event = ? AND is_active = 1", [$event]);
}
public static function renderMessage(string $template, array $variables): string
{
$message = $template;
foreach ($variables as $key => $value) {
$message = str_replace('{' . $key . '}', (string) $value, $message);
}
return $message;
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/notifications/templates', 'Notifications\Controllers\NotificationController@templates', ['auth'], 'sms.view_log'],
['POST', '/notifications/templates/{id}', 'Notifications\Controllers\NotificationController@updateTemplate', ['auth'], 'sms.edit_templates'],
['GET', '/notifications/log', 'Notifications\Controllers\NotificationController@log', ['auth'], 'sms.view_log'],
['GET', '/notifications/send', 'Notifications\Controllers\NotificationController@sendForm', ['auth'], 'sms.send_single'],
['POST', '/notifications/send', 'Notifications\Controllers\NotificationController@send', ['auth'], 'sms.send_single'],
['POST', '/notifications/send-bulk', 'Notifications\Controllers\NotificationController@sendBulk', ['auth'], 'sms.send_bulk'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Notifications\Services;
use App\Core\App;
use App\Core\Logger;
use App\Modules\Notifications\Models\SmsLog;
use App\Modules\Notifications\Models\SmsTemplate;
use App\Modules\Notifications\Models\NotificationQueue;
final class SmsService
{
public static function send(string $phone, string $message, ?int $memberId = null, ?int $templateId = null): array
{
$employee = App::getInstance()->currentEmployee();
$smsConfig = config('sms', []);
$enabled = $smsConfig['enabled'] ?? false;
$logId = SmsLog::create([
'member_id' => $memberId,
'phone_number' => $phone,
'template_id' => $templateId,
'message_text' => $message,
'message_type' => 'single',
'status' => 'queued',
'sent_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
if (!$enabled || empty($smsConfig['api_key'])) {
// SMS not configured — mark as sent for dev/testing
$db = App::getInstance()->db();
$db->update('sms_log', [
'status' => 'sent',
'sent_at' => date('Y-m-d H:i:s'),
'provider_response' => 'SMS_DISABLED_DEV_MODE',
], '`id` = ?', [$logId]);
Logger::info("SMS (dev mode): {$phone}{$message}");
return ['success' => true, 'log_id' => $logId, 'dev_mode' => true];
}
// Real SMS sending via HTTP API
try {
$response = self::callProvider($phone, $message, $smsConfig);
$db = App::getInstance()->db();
$db->update('sms_log', [
'status' => 'sent',
'sent_at' => date('Y-m-d H:i:s'),
'provider_message_id' => $response['message_id'] ?? null,
'provider_response' => json_encode($response),
'cost' => $response['cost'] ?? null,
], '`id` = ?', [$logId]);
return ['success' => true, 'log_id' => $logId];
} catch (\Throwable $e) {
$db = App::getInstance()->db();
$db->update('sms_log', [
'status' => 'failed',
'failed_at' => date('Y-m-d H:i:s'),
'failure_reason' => $e->getMessage(),
'retry_count' => 1,
], '`id` = ?', [$logId]);
Logger::error("SMS send failed: {$phone}{$e->getMessage()}");
return ['success' => false, 'error' => $e->getMessage(), 'log_id' => $logId];
}
}
public static function queue(string $phone, string $message, ?int $memberId = null, ?int $templateId = null): int
{
return NotificationQueue::add([
'type' => 'sms',
'recipient_type'=> 'member',
'recipient_id' => $memberId ?? 0,
'template_id' => $templateId,
'message' => $message,
'data_json' => json_encode(['phone' => $phone]),
'priority' => 0,
]);
}
public static function sendFromTemplate(string $templateCode, string $phone, array $variables, ?int $memberId = null): array
{
$template = SmsTemplate::findByCode($templateCode);
if (!$template) {
return ['success' => false, 'error' => "Template not found: {$templateCode}"];
}
$message = SmsTemplate::renderMessage($template['message_template_ar'], $variables);
return self::send($phone, $message, $memberId, (int) $template['id']);
}
public static function processQueue(int $batchSize = 50): array
{
$pending = NotificationQueue::getPending($batchSize);
$results = ['sent' => 0, 'failed' => 0];
foreach ($pending as $item) {
$data = json_decode($item['data_json'] ?? '{}', true) ?? [];
$phone = $data['phone'] ?? '';
if ($phone === '') {
NotificationQueue::markFailed((int) $item['id']);
$results['failed']++;
continue;
}
$result = self::send($phone, $item['message'], (int) $item['recipient_id'] ?: null, $item['template_id'] ? (int) $item['template_id'] : null);
if ($result['success']) {
NotificationQueue::markProcessed((int) $item['id']);
$results['sent']++;
} else {
NotificationQueue::markFailed((int) $item['id']);
$results['failed']++;
}
}
return $results;
}
private static function callProvider(string $phone, string $message, array $config): array
{
$url = $config['api_url'] ?? '';
$apiKey = $config['api_key'] ?? '';
$senderId = $config['sender_id'] ?? 'THECLUB';
if ($url === '' || $apiKey === '') {
throw new \RuntimeException('SMS provider not configured');
}
$postData = http_build_query([
'api_key' => $apiKey,
'to' => $phone,
'message' => $message,
'sender_id' => $senderId,
]);
$ctx = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => $postData,
'timeout' => 30,
],
]);
$response = @file_get_contents($url, false, $ctx);
if ($response === false) {
throw new \RuntimeException('Failed to connect to SMS provider');
}
$decoded = json_decode($response, true);
if (!$decoded) {
return ['raw' => $response, 'message_id' => null, 'cost' => null];
}
return $decoded;
}
}
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل الرسائل النصية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/notifications/log" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">بحث</label><input type="text" name="q" value="<?= e($filters['search'] ?? '') ?>" class="form-input" style="min-width:200px;" placeholder="رقم الهاتف، نص الرسالة..."></div>
<div><label class="form-label" style="font-size:12px;">الحالة</label><select name="status" class="form-select"><option value="">الكل</option><option value="sent" <?= ($filters['status'] ?? '') === 'sent' ? 'selected' : '' ?>>مرسل</option><option value="queued" <?= ($filters['status'] ?? '') === 'queued' ? 'selected' : '' ?>>في الانتظار</option><option value="failed" <?= ($filters['status'] ?? '') === 'failed' ? 'selected' : '' ?>>فشل</option></select></div>
<div><label class="form-label" style="font-size:12px;">من</label><input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input"></div>
<div><label class="form-label" style="font-size:12px;">إلى</label><input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input"></div>
<button type="submit" class="btn btn-outline">بحث</button>
</form>
</div>
<div class="card"><div class="table-responsive"><table class="data-table"><thead><tr><th>التاريخ</th><th>العضو</th><th>الهاتف</th><th>الرسالة</th><th>الحالة</th></tr></thead><tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td style="font-size:12px;white-space:nowrap;"><?= e($r['created_at']) ?></td>
<td style="font-size:13px;"><?= e($r['member_name'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;font-size:13px;"><?= e($r['phone_number']) ?></td>
<td style="font-size:12px;max-width:300px;overflow:hidden;text-overflow:ellipsis;"><?= e(mb_substr($r['message_text'], 0, 80)) ?></td>
<td><span style="color:<?= match($r['status']) { 'sent' => '#059669', 'queued' => '#D97706', 'failed' => '#DC2626', default => '#6B7280' } ?>;font-weight:600;"><?= match($r['status']) { 'sent' => 'مرسل', 'queued' => 'انتظار', 'failed' => 'فشل', 'delivered' => 'مُستلم', default => $r['status'] } ?></span></td>
</tr>
<?php endforeach; ?>
<?php if (empty($rows)): ?><tr><td colspan="5" style="text-align:center;padding:40px;color:#6B7280;">لا توجد رسائل</td></tr><?php endif; ?>
</tbody></table></div></div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إرسال رسالة نصية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div class="card" style="padding:20px;">
<h4 style="color:#0D7377;margin-bottom:15px;">إرسال رسالة فردية</h4>
<form method="POST" action="/notifications/send">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:15px;"><label class="form-label">رقم الهاتف <span style="color:#DC2626;">*</span></label><input type="text" name="phone_number" class="form-input" required style="direction:ltr;text-align:left;" placeholder="01XXXXXXXXX"></div>
<div class="form-group" style="margin-bottom:15px;"><label class="form-label">قالب (اختياري)</label><select name="template_id" class="form-select"><option value="">— رسالة مخصصة —</option><?php foreach ($templates as $t): ?><option value="<?= (int) $t['id'] ?>"><?= e($t['name_ar']) ?></option><?php endforeach; ?></select></div>
<div class="form-group" style="margin-bottom:15px;"><label class="form-label">نص الرسالة</label><textarea name="custom_message" class="form-textarea" rows="4" placeholder="اكتب الرسالة أو اختر قالب..."></textarea></div>
<button type="submit" class="btn btn-primary">إرسال</button>
</form>
</div>
<div class="card" style="padding:20px;">
<h4 style="color:#0D7377;margin-bottom:15px;">إرسال رسائل جماعية</h4>
<form method="POST" action="/notifications/send-bulk">
<?= csrf_field() ?>
<div class="form-group" style="margin-bottom:15px;"><label class="form-label">المجموعة المستهدفة</label><select name="filter" class="form-select"><option value="all_active">جميع الأعضاء النشطين</option><option value="unpaid_subscription">لم يسددوا الاشتراك</option><option value="overdue_installments">أقساط متأخرة</option></select></div>
<div class="form-group" style="margin-bottom:15px;"><label class="form-label">القالب</label><select name="template_id" class="form-select"><option value="">— رسالة مخصصة —</option><?php foreach ($templates as $t): ?><option value="<?= (int) $t['id'] ?>"><?= e($t['name_ar']) ?></option><?php endforeach; ?></select></div>
<div class="form-group" style="margin-bottom:15px;"><label class="form-label">نص مخصص (يتجاوز القالب)</label><textarea name="custom_message" class="form-textarea" rows="4"></textarea></div>
<button type="submit" class="btn btn-primary" onclick="return confirm('إرسال رسائل جماعية — متأكد؟')">إرسال جماعي</button>
</form>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>قوالب الرسائل النصية<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card">
<?php foreach ($templates as $t): ?>
<div style="padding:20px;border-bottom:1px solid #E5E7EB;">
<form method="POST" action="/notifications/templates/<?= (int) $t['id'] ?>">
<?= csrf_field() ?>
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:10px;">
<div>
<strong style="color:#0D7377;"><?= e($t['name_ar']) ?></strong>
<code style="font-size:11px;color:#6B7280;margin-right:10px;"><?= e($t['template_code']) ?></code>
<?php if ($t['trigger_event']): ?><span style="font-size:11px;background:#EFF6FF;color:#0284C7;padding:2px 6px;border-radius:4px;">حدث: <?= e($t['trigger_event']) ?></span><?php endif; ?>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<select name="is_active" class="form-select" style="width:auto;font-size:12px;">
<option value="1" <?= $t['is_active'] ? 'selected' : '' ?>>مفعّل</option>
<option value="0" <?= !$t['is_active'] ? 'selected' : '' ?>>معطّل</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">حفظ</button>
</div>
</div>
<input type="hidden" name="name_ar" value="<?= e($t['name_ar']) ?>">
<textarea name="message_template_ar" class="form-textarea" rows="2" style="font-size:13px;"><?= e($t['message_template_ar']) ?></textarea>
<?php if ($t['variables_json']): ?>
<?php $vars = json_decode($t['variables_json'], true) ?? []; ?>
<div style="margin-top:5px;font-size:11px;color:#9CA3AF;">المتغيرات: <?= e(implode(', ', array_map(fn($v) => '{' . $v . '}', $vars))) ?></div>
<?php endif; ?>
</form>
</div>
<?php endforeach; ?>
<?php if (empty($templates)): ?>
<div style="padding:40px;text-align:center;color:#6B7280;">لا توجد قوالب</div>
<?php endif; ?>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('notifications', [
'label_ar' => 'الإخطارات',
'label_en' => 'Notifications',
'icon' => 'bell',
'route' => '/notifications/log',
'permission' => 'sms.view_log',
'parent' => null,
'order' => 900,
'children' => [
['label_ar' => 'سجل الرسائل', 'label_en' => 'SMS Log', 'route' => '/notifications/log', 'permission' => 'sms.view_log', 'order' => 1],
['label_ar' => 'إرسال رسالة', 'label_en' => 'Send SMS', 'route' => '/notifications/send', 'permission' => 'sms.send_single', 'order' => 2],
['label_ar' => 'القوالب', 'label_en' => 'Templates', 'route' => '/notifications/templates', 'permission' => 'sms.edit_templates', 'order' => 3],
],
]);
PermissionRegistry::register('notifications', [
'sms.send_single' => ['ar' => 'إرسال رسالة فردية', 'en' => 'Send Single SMS'],
'sms.send_bulk' => ['ar' => 'إرسال رسائل جماعية', 'en' => 'Send Bulk SMS'],
'sms.view_log' => ['ar' => 'عرض سجل الرسائل', 'en' => 'View SMS Log'],
'sms.edit_templates' => ['ar' => 'تعديل القوالب', 'en' => 'Edit Templates'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Reports\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Reports\Models\ReportDefinition;
use App\Modules\Reports\Services\ReportEngine;
use App\Modules\Reports\Services\ReportExporter;
class ReportController extends Controller
{
public function index(Request $request): Response
{
$reports = ReportDefinition::getAllActive();
$grouped = [];
foreach ($reports as $r) {
$grouped[$r['category']][] = $r;
}
return $this->view('Reports.Views.index', ['grouped' => $grouped]);
}
public function view(Request $request, string $code): Response
{
$definition = ReportDefinition::findByCode($code);
if (!$definition) return $this->redirect('/reports')->withError('التقرير غير موجود');
$filters = [
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'branch_id' => $request->get('branch_id', ''),
'status' => $request->get('status', ''),
];
$data = ReportEngine::generate($code, $filters);
$db = App::getInstance()->db();
$branches = $db->select("SELECT id, name_ar FROM branches WHERE is_active = 1 ORDER BY name_ar");
return $this->view('Reports.Views.view', [
'definition' => $definition,
'data' => $data,
'filters' => $filters,
'branches' => $branches,
]);
}
public function export(Request $request, string $code): Response
{
$definition = ReportDefinition::findByCode($code);
if (!$definition) return $this->redirect('/reports')->withError('التقرير غير موجود');
$filters = [
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'branch_id' => $request->get('branch_id', ''),
'status' => $request->get('status', ''),
];
$data = ReportEngine::generate($code, $filters);
$csv = ReportExporter::toCsv($data, $definition);
$response = new Response();
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="' . $code . '_' . date('Y-m-d') . '.csv"');
echo "\xEF\xBB\xBF"; // UTF-8 BOM
echo $csv;
exit;
}
public function printReport(Request $request, string $code): Response
{
$definition = ReportDefinition::findByCode($code);
if (!$definition) return $this->redirect('/reports')->withError('التقرير غير موجود');
$filters = [
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'branch_id' => $request->get('branch_id', ''),
'status' => $request->get('status', ''),
];
$data = ReportEngine::generate($code, $filters);
return $this->view('Reports.Views.print', [
'definition' => $definition,
'data' => $data,
'filters' => $filters,
]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Reports\Models;
use App\Core\App;
class ReportDefinition
{
public static function getAllActive(): array
{
$db = App::getInstance()->db();
return $db->select("SELECT * FROM report_definitions WHERE is_active = 1 ORDER BY category, name_ar");
}
public static function findByCode(string $code): ?array
{
$db = App::getInstance()->db();
return $db->selectOne("SELECT * FROM report_definitions WHERE report_code = ? AND is_active = 1", [$code]);
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
return [
['GET', '/reports', 'Reports\Controllers\ReportController@index', ['auth'], 'report.view_membership'],
['GET', '/reports/view/{code}','Reports\Controllers\ReportController@view', ['auth'], 'report.view_membership'],
['GET', '/reports/export/{code}','Reports\Controllers\ReportController@export', ['auth'], 'report.export'],
['GET', '/reports/print/{code}','Reports\Controllers\ReportController@printReport', ['auth'], 'report.view_membership'],
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Reports\Services;
use App\Core\App;
final class ReportEngine
{
public static function generate(string $reportCode, array $filters): array
{
$db = App::getInstance()->db();
switch ($reportCode) {
case 'RPT_MEMBERSHIP_REGISTER':
return self::membershipRegister($db, $filters);
case 'RPT_NEW_MEMBERSHIPS':
return self::newMemberships($db, $filters);
case 'RPT_CHILDREN':
return self::childrenReport($db, $filters);
case 'RPT_CHILDREN_18':
return self::childrenApproaching($db, $filters, 18);
case 'RPT_CHILDREN_25':
return self::childrenApproaching($db, $filters, 25);
case 'RPT_SPOUSES':
return self::spousesReport($db, $filters);
case 'RPT_REVENUE':
return self::revenueReport($db, $filters);
case 'RPT_OUTSTANDING':
return self::outstandingReport($db, $filters);
case 'RPT_INSTALLMENTS':
return self::installmentsReport($db, $filters);
case 'RPT_SUBSCRIPTIONS':
return self::subscriptionsReport($db, $filters);
case 'RPT_OVERDUE_SUBSCRIPTIONS':
return self::overdueSubscriptionsReport($db, $filters);
case 'RPT_FINES':
return self::finesReport($db, $filters);
case 'RPT_DAILY_CASH':
return self::dailyCashReport($db, $filters);
case 'RPT_VOIDED_RECEIPTS':
return self::voidedReceiptsReport($db, $filters);
case 'RPT_EMPLOYEE_ACTIVITY':
return self::employeeActivityReport($db, $filters);
case 'RPT_CARNET_PRINTS':
return self::carnetPrintsReport($db, $filters);
case 'RPT_TRANSFERS':
return self::transfersReport($db, $filters);
case 'RPT_TEMPORARY':
return self::temporaryReport($db, $filters);
default:
return self::genericReport($db, $reportCode, $filters);
}
}
private static function applyDateFilter(string &$where, array &$params, array $filters, string $dateCol): void
{
if (!empty($filters['date_from'])) { $where .= " AND {$dateCol} >= ?"; $params[] = $filters['date_from']; }
if (!empty($filters['date_to'])) { $where .= " AND {$dateCol} <= ?"; $params[] = $filters['date_to'] . ' 23:59:59'; }
}
private static function applyBranchFilter(string &$where, array &$params, array $filters, string $col = 'm.branch_id'): void
{
if (!empty($filters['branch_id'])) { $where .= " AND {$col} = ?"; $params[] = (int) $filters['branch_id']; }
}
private static function membershipRegister($db, array $f): array
{
$w = 'm.is_archived = 0'; $p = [];
self::applyDateFilter($w, $p, $f, 'm.created_at');
self::applyBranchFilter($w, $p, $f);
if (!empty($f['status'])) { $w .= ' AND m.status = ?'; $p[] = $f['status']; }
$rows = $db->select("SELECT m.*, b.name_ar as branch_name FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE {$w} ORDER BY m.membership_number ASC", $p);
return ['columns' => ['membership_number' => 'رقم العضوية', 'full_name_ar' => 'الاسم', 'national_id' => 'الرقم القومي', 'phone_mobile' => 'المحمول', 'status' => 'الحالة', 'branch_name' => 'الفرع', 'membership_type' => 'النوع', 'created_at' => 'تاريخ الإنشاء'], 'rows' => $rows, 'total' => count($rows)];
}
private static function newMemberships($db, array $f): array
{
$w = "m.is_archived = 0"; $p = [];
self::applyDateFilter($w, $p, $f, 'm.created_at');
self::applyBranchFilter($w, $p, $f);
$rows = $db->select("SELECT m.full_name_ar, m.membership_number, m.national_id, m.membership_type, m.membership_value, m.status, m.created_at, b.name_ar as branch_name FROM members m LEFT JOIN branches b ON b.id = m.branch_id WHERE {$w} ORDER BY m.created_at DESC", $p);
return ['columns' => ['full_name_ar' => 'الاسم', 'membership_number' => 'رقم العضوية', 'membership_type' => 'النوع', 'membership_value' => 'القيمة', 'status' => 'الحالة', 'branch_name' => 'الفرع', 'created_at' => 'التاريخ'], 'rows' => $rows, 'total' => count($rows)];
}
private static function childrenReport($db, array $f): array
{
$w = 'c.is_archived = 0'; $p = [];
self::applyDateFilter($w, $p, $f, 'c.created_at');
$rows = $db->select("SELECT c.full_name_ar, c.date_of_birth, c.age_years, c.gender, c.classification, c.status, c.is_frozen, m.full_name_ar as member_name, m.membership_number FROM children c JOIN members m ON m.id = c.member_id WHERE {$w} ORDER BY c.age_years DESC", $p);
return ['columns' => ['full_name_ar' => 'اسم الابن', 'member_name' => 'صاحب العضوية', 'membership_number' => 'رقم العضوية', 'date_of_birth' => 'تاريخ الميلاد', 'age_years' => 'السن', 'gender' => 'النوع', 'classification' => 'التصنيف', 'status' => 'الحالة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function childrenApproaching($db, array $f, int $age): array
{
$monthsAhead = 3;
$targetDate = date('Y-m-d', strtotime("-{$age} years +{$monthsAhead} months"));
$targetDateNow = date('Y-m-d', strtotime("-{$age} years"));
$rows = $db->select("SELECT c.full_name_ar, c.date_of_birth, c.age_years, c.gender, c.classification, m.full_name_ar as member_name, m.membership_number, m.phone_mobile FROM children c JOIN members m ON m.id = c.member_id WHERE c.is_archived = 0 AND c.date_of_birth BETWEEN ? AND ? ORDER BY c.date_of_birth ASC", [$targetDateNow, $targetDate]);
return ['columns' => ['full_name_ar' => 'اسم الابن', 'member_name' => 'صاحب العضوية', 'membership_number' => 'رقم العضوية', 'date_of_birth' => 'تاريخ الميلاد', 'age_years' => 'السن', 'phone_mobile' => 'المحمول'], 'rows' => $rows, 'total' => count($rows)];
}
private static function spousesReport($db, array $f): array
{
$w = 's.is_archived = 0'; $p = [];
$rows = $db->select("SELECT s.full_name_ar, s.spouse_order, s.classification, s.addition_fee, s.status, m.full_name_ar as member_name, m.membership_number FROM spouses s JOIN members m ON m.id = s.member_id WHERE {$w} ORDER BY m.membership_number, s.spouse_order", $p);
return ['columns' => ['member_name' => 'العضو', 'membership_number' => 'رقم العضوية', 'full_name_ar' => 'اسم الزوج/ة', 'spouse_order' => 'الترتيب', 'classification' => 'التصنيف', 'addition_fee' => 'الرسوم', 'status' => 'الحالة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function revenueReport($db, array $f): array
{
$w = 'p.is_voided = 0'; $p = [];
self::applyDateFilter($w, $p, $f, 'p.payment_date');
$rows = $db->select("SELECT p.payment_type, COUNT(*) as count, SUM(p.amount) as total_amount FROM payments p WHERE {$w} GROUP BY p.payment_type ORDER BY total_amount DESC", $p);
return ['columns' => ['payment_type' => 'نوع الدفع', 'count' => 'العدد', 'total_amount' => 'الإجمالي'], 'rows' => $rows, 'total' => count($rows)];
}
private static function outstandingReport($db, array $f): array
{
$rows = $db->select("SELECT m.full_name_ar, m.membership_number, m.phone_mobile, COALESCE((SELECT SUM(s.total_amount - s.paid_amount) FROM subscriptions s WHERE s.member_id = m.id AND s.status IN ('pending','overdue')), 0) as sub_outstanding, COALESCE((SELECT SUM(f.amount - f.paid_amount) FROM fines f WHERE f.member_id = m.id AND f.status IN ('imposed','appeal_upheld')), 0) as fine_outstanding FROM members m WHERE m.is_archived = 0 AND m.status = 'active' HAVING (sub_outstanding + fine_outstanding) > 0 ORDER BY (sub_outstanding + fine_outstanding) DESC");
return ['columns' => ['full_name_ar' => 'الاسم', 'membership_number' => 'رقم العضوية', 'phone_mobile' => 'المحمول', 'sub_outstanding' => 'اشتراكات مستحقة', 'fine_outstanding' => 'غرامات مستحقة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function installmentsReport($db, array $f): array
{
$w = '1=1'; $p = [];
if (!empty($f['status'])) { $w .= ' AND p.status = ?'; $p[] = $f['status']; }
$rows = $db->select("SELECT p.*, m.full_name_ar as member_name, m.membership_number, (SELECT COUNT(*) FROM installment_schedule s WHERE s.installment_plan_id = p.id AND s.status = 'pending' AND s.due_date < CURDATE()) as overdue_count FROM installment_plans p JOIN members m ON m.id = p.member_id WHERE {$w} ORDER BY p.created_at DESC", $p);
return ['columns' => ['member_name' => 'العضو', 'membership_number' => 'رقم العضوية', 'total_amount' => 'الإجمالي', 'down_payment' => 'المقدم', 'monthly_payment' => 'القسط الشهري', 'number_of_months' => 'الأشهر', 'status' => 'الحالة', 'overdue_count' => 'أقساط متأخرة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function subscriptionsReport($db, array $f): array
{
$w = '1=1'; $p = [];
if (!empty($f['status'])) { $w .= ' AND s.status = ?'; $p[] = $f['status']; }
$rows = $db->select("SELECT s.financial_year, s.person_type, s.person_name, s.total_amount, s.paid_amount, s.fine_amount, s.status, m.full_name_ar as member_name, m.membership_number FROM subscriptions s JOIN members m ON m.id = s.member_id WHERE {$w} ORDER BY s.financial_year DESC, m.membership_number", $p);
return ['columns' => ['member_name' => 'العضو', 'membership_number' => 'رقم العضوية', 'financial_year' => 'السنة المالية', 'person_type' => 'النوع', 'total_amount' => 'المبلغ', 'paid_amount' => 'المدفوع', 'fine_amount' => 'الغرامة', 'status' => 'الحالة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function overdueSubscriptionsReport($db, array $f): array
{
$rows = $db->select("SELECT s.financial_year, s.total_amount, s.fine_amount, m.full_name_ar as member_name, m.membership_number, m.phone_mobile FROM subscriptions s JOIN members m ON m.id = s.member_id WHERE s.status IN ('pending','overdue') AND m.is_archived = 0 ORDER BY s.financial_year ASC, m.membership_number");
return ['columns' => ['member_name' => 'العضو', 'membership_number' => 'رقم العضوية', 'phone_mobile' => 'المحمول', 'financial_year' => 'السنة المالية', 'total_amount' => 'المبلغ', 'fine_amount' => 'الغرامة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function finesReport($db, array $f): array
{
$w = '1=1'; $p = [];
self::applyDateFilter($w, $p, $f, 'f.created_at');
$rows = $db->select("SELECT f.*, m.full_name_ar as member_name, m.membership_number FROM fines f JOIN members m ON m.id = f.member_id WHERE {$w} ORDER BY f.created_at DESC", $p);
return ['columns' => ['member_name' => 'العضو', 'membership_number' => 'رقم العضوية', 'penalty_type' => 'نوع العقوبة', 'amount' => 'المبلغ', 'status' => 'الحالة', 'created_at' => 'التاريخ'], 'rows' => $rows, 'total' => count($rows)];
}
private static function dailyCashReport($db, array $f): array
{
$date = $f['date_from'] ?: date('Y-m-d');
$rows = $db->select("SELECT r.receipt_number, r.amount, r.description_ar, r.receipt_type, r.is_voided, r.issued_at, m.full_name_ar as member_name, e.full_name_ar as cashier FROM receipts r JOIN members m ON m.id = r.member_id LEFT JOIN employees e ON e.id = r.issued_by_employee_id WHERE DATE(r.issued_at) = ? ORDER BY r.issued_at ASC", [$date]);
$totalValid = array_sum(array_map(fn($r) => $r['is_voided'] ? 0 : (float) $r['amount'], $rows));
return ['columns' => ['receipt_number' => 'رقم الإيصال', 'member_name' => 'العضو', 'amount' => 'المبلغ', 'description_ar' => 'الوصف', 'cashier' => 'أمين الخزينة', 'is_voided' => 'ملغى', 'issued_at' => 'الوقت'], 'rows' => $rows, 'total' => count($rows), 'summary' => ['total_valid' => $totalValid, 'date' => $date]];
}
private static function voidedReceiptsReport($db, array $f): array
{
$w = 'r.is_voided = 1'; $p = [];
self::applyDateFilter($w, $p, $f, 'r.voided_at');
$rows = $db->select("SELECT r.receipt_number, r.amount, r.void_reason, r.voided_at, m.full_name_ar as member_name, e.full_name_ar as voided_by_name FROM receipts r JOIN members m ON m.id = r.member_id LEFT JOIN employees e ON e.id = r.voided_by WHERE {$w} ORDER BY r.voided_at DESC", $p);
return ['columns' => ['receipt_number' => 'رقم الإيصال', 'member_name' => 'العضو', 'amount' => 'المبلغ', 'void_reason' => 'السبب', 'voided_by_name' => 'بواسطة', 'voided_at' => 'تاريخ الإلغاء'], 'rows' => $rows, 'total' => count($rows)];
}
private static function employeeActivityReport($db, array $f): array
{
$w = '1=1'; $p = [];
self::applyDateFilter($w, $p, $f, 'a.created_at');
$rows = $db->select("SELECT a.employee_name, a.action, COUNT(*) as action_count, MAX(a.created_at) as last_action FROM audit_trail a WHERE {$w} GROUP BY a.employee_id, a.employee_name, a.action ORDER BY action_count DESC", $p);
return ['columns' => ['employee_name' => 'الموظف', 'action' => 'الإجراء', 'action_count' => 'العدد', 'last_action' => 'آخر نشاط'], 'rows' => $rows, 'total' => count($rows)];
}
private static function carnetPrintsReport($db, array $f): array
{
$w = 'c.print_count > 0'; $p = [];
self::applyDateFilter($w, $p, $f, 'c.last_printed_at');
$rows = $db->select("SELECT c.carnet_number, c.print_count, c.last_printed_at, m.full_name_ar as member_name, m.membership_number, e.full_name_ar as printed_by FROM carnets c JOIN members m ON m.id = c.member_id LEFT JOIN employees e ON e.id = c.last_printed_by WHERE {$w} ORDER BY c.last_printed_at DESC", $p);
return ['columns' => ['carnet_number' => 'رقم الكارنيه', 'member_name' => 'العضو', 'membership_number' => 'رقم العضوية', 'print_count' => 'مرات الطباعة', 'printed_by' => 'طُبع بواسطة', 'last_printed_at' => 'آخر طباعة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function transfersReport($db, array $f): array
{
$w = '1=1'; $p = [];
self::applyDateFilter($w, $p, $f, 't.created_at');
$rows = $db->select("SELECT t.transfer_type, t.status, t.total_fee, t.source_membership_number, t.new_membership_number, t.created_at, m.full_name_ar as source_name FROM transfer_requests t JOIN members m ON m.id = t.source_member_id WHERE {$w} ORDER BY t.created_at DESC", $p);
return ['columns' => ['source_name' => 'العضو المصدر', 'source_membership_number' => 'رقم العضوية القديم', 'new_membership_number' => 'رقم العضوية الجديد', 'transfer_type' => 'النوع', 'total_fee' => 'الرسوم', 'status' => 'الحالة', 'created_at' => 'التاريخ'], 'rows' => $rows, 'total' => count($rows)];
}
private static function temporaryReport($db, array $f): array
{
$rows = $db->select("SELECT t.full_name_ar, t.category, t.age_years, t.status, t.addition_fee, m.full_name_ar as member_name, m.membership_number FROM temporary_members t JOIN members m ON m.id = t.member_id WHERE t.is_archived = 0 ORDER BY t.category, m.membership_number");
return ['columns' => ['member_name' => 'العضو', 'membership_number' => 'رقم العضوية', 'full_name_ar' => 'اسم العضو المؤقت', 'category' => 'الفئة', 'age_years' => 'السن', 'addition_fee' => 'الرسوم', 'status' => 'الحالة'], 'rows' => $rows, 'total' => count($rows)];
}
private static function genericReport($db, string $code, array $f): array
{
return ['columns' => [], 'rows' => [], 'total' => 0, 'message' => 'التقرير غير مُعرّف بعد: ' . $code];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace App\Modules\Reports\Services;
final class ReportExporter
{
public static function toCsv(array $data, array $definition): string
{
$columns = $data['columns'] ?? [];
$rows = $data['rows'] ?? [];
$output = '';
if (!empty($columns)) {
$output .= implode(',', array_map(fn($v) => '"' . str_replace('"', '""', $v) . '"', array_values($columns))) . "\n";
}
$colKeys = array_keys($columns);
foreach ($rows as $row) {
$line = [];
foreach ($colKeys as $key) {
$val = $row[$key] ?? '';
$line[] = '"' . str_replace('"', '""', (string) $val) . '"';
}
$output .= implode(',', $line) . "\n";
}
return $output;
}
}
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>منشئ التقارير<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
<h3>منشئ التقارير المخصصة</h3>
<p>هذه الميزة متاحة للمدير العام — قيد التطوير المستقبلي</p>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>التقارير<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$categoryLabels = ['membership' => 'تقارير العضويات', 'financial' => 'تقارير مالية', 'operations' => 'تقارير العمليات', 'audit' => 'تقارير المراجعة', 'general' => 'تقارير عامة'];
?>
<?php foreach ($grouped as $category => $reports): ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;"><?= e($categoryLabels[$category] ?? $category) ?></h3></div>
<div style="padding:15px 20px;display:grid;grid-template-columns:repeat(auto-fill, minmax(250px, 1fr));gap:10px;">
<?php foreach ($reports as $r): ?>
<a href="/reports/view/<?= e($r['report_code']) ?>" class="btn btn-outline" style="text-align:right;padding:12px 15px;display:block;">
<?= e($r['name_ar']) ?>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($grouped)): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">لا توجد تقارير متاحة</div>
<?php endif; ?>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.print'); ?>
<?php $__template->section('title'); ?><?= e($definition['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<h2 style="text-align:center;color:#0D7377;margin-bottom:5px;"><?= e($definition['name_ar']) ?></h2>
<p style="text-align:center;color:#6B7280;margin-bottom:20px;font-size:12px;">تاريخ الطباعة: <?= e(date('Y-m-d H:i')) ?> — إجمالي: <?= (int) ($data['total'] ?? 0) ?> سجل</p>
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead><tr style="background:#0D7377;color:#fff;"><?php foreach (($data['columns'] ?? []) as $label): ?><th style="padding:8px 6px;text-align:right;border:1px solid #ccc;"><?= e($label) ?></th><?php endforeach; ?></tr></thead>
<tbody><?php foreach (($data['rows'] ?? []) as $i => $row): ?><tr style="background:<?= $i % 2 ? '#F9FAFB' : '#fff' ?>;"><?php foreach (array_keys($data['columns'] ?? []) as $key): ?><td style="padding:6px;border:1px solid #E5E7EB;"><?= e((string) ($row[$key] ?? '')) ?></td><?php endforeach; ?></tr><?php endforeach; ?></tbody>
</table>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= e($definition['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/reports/export/<?= e($definition['report_code']) ?>?<?= http_build_query($filters) ?>" class="btn btn-outline">تصدير CSV</a>
<a href="/reports/print/<?= e($definition['report_code']) ?>?<?= http_build_query($filters) ?>" class="btn btn-outline" target="_blank">طباعة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/reports/view/<?= e($definition['report_code']) ?>" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div><label class="form-label" style="font-size:12px;">من</label><input type="date" name="date_from" value="<?= e($filters['date_from'] ?? '') ?>" class="form-input"></div>
<div><label class="form-label" style="font-size:12px;">إلى</label><input type="date" name="date_to" value="<?= e($filters['date_to'] ?? '') ?>" class="form-input"></div>
<div><label class="form-label" style="font-size:12px;">الفرع</label><select name="branch_id" class="form-select"><option value="">الكل</option><?php foreach ($branches as $b): ?><option value="<?= (int) $b['id'] ?>" <?= ($filters['branch_id'] ?? '') == $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option><?php endforeach; ?></select></div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
<div class="card">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;">
<strong><?= e($definition['name_ar']) ?></strong>
<span style="color:#6B7280;">إجمالي: <?= (int) ($data['total'] ?? 0) ?> سجل</span>
</div>
<div class="table-responsive">
<table class="data-table"><thead><tr>
<?php foreach (($data['columns'] ?? []) as $label): ?><th><?= e($label) ?></th><?php endforeach; ?>
</tr></thead><tbody>
<?php foreach (($data['rows'] ?? []) as $row): ?>
<tr><?php foreach (array_keys($data['columns'] ?? []) as $key): ?><td style="font-size:13px;"><?= e((string) ($row[$key] ?? '—')) ?></td><?php endforeach; ?></tr>
<?php endforeach; ?>
<?php if (empty($data['rows'])): ?><tr><td colspan="<?= count($data['columns'] ?? []) ?>" style="text-align:center;padding:40px;color:#6B7280;"><?= e($data['message'] ?? 'لا توجد بيانات') ?></td></tr><?php endif; ?>
</tbody></table>
</div>
</div>
<?php $__template->endSection(); ?>
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Registries\MenuRegistry;
use App\Core\Registries\PermissionRegistry;
MenuRegistry::register('reports', [
'label_ar' => 'التقارير',
'label_en' => 'Reports',
'icon' => 'chart',
'route' => '/reports',
'permission' => 'report.view_membership',
'parent' => null,
'order' => 800,
'children' => [],
]);
PermissionRegistry::register('reports', [
'report.view_membership' => ['ar' => 'تقارير العضويات', 'en' => 'Membership Reports'],
'report.view_financial' => ['ar' => 'تقارير مالية', 'en' => 'Financial Reports'],
'report.view_operations' => ['ar' => 'تقارير العمليات', 'en' => 'Operations Reports'],
'report.view_audit' => ['ar' => 'تقارير المراجعة', 'en' => 'Audit Reports'],
'report.export' => ['ar' => 'تصدير التقارير', 'en' => 'Export Reports'],
]);
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'provider' => env('SMS_PROVIDER', ''),
'api_key' => env('SMS_API_KEY', ''),
'api_url' => env('SMS_API_URL', ''),
'sender_id' => env('SMS_SENDER_ID', 'THECLUB'),
'max_retries' => 3,
'enabled' => env('SMS_ENABLED', false),
];
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\Notifications\Services\SmsService;
class AgeMonitorJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
// Daily at any hour
return true;
}
public function run(): array
{
$processed = 0;
// Children approaching 18 (3 months ahead)
$approaching18 = $this->db->select(
"SELECT c.id, c.full_name_ar, c.date_of_birth, m.id as member_id, m.full_name_ar as member_name, m.phone_mobile
FROM children c JOIN members m ON m.id = c.member_id
WHERE c.is_archived = 0 AND c.status = 'active'
AND c.date_of_birth = DATE_SUB(CURDATE(), INTERVAL 18 YEAR) + INTERVAL 90 DAY"
);
foreach ($approaching18 as $child) {
SmsService::sendFromTemplate('CHILD_AGE_18_ALERT', $child['phone_mobile'], [
'member_name' => $child['member_name'],
'child_name' => $child['full_name_ar'],
'date' => date('Y-m-d', strtotime($child['date_of_birth'] . ' +18 years')),
], (int) $child['member_id']);
$processed++;
}
// Children approaching 25 (3 months ahead)
$approaching25 = $this->db->select(
"SELECT c.id, c.full_name_ar, c.date_of_birth, c.gender, m.id as member_id, m.full_name_ar as member_name, m.phone_mobile
FROM children c JOIN members m ON m.id = c.member_id
WHERE c.is_archived = 0 AND c.status = 'active' AND c.gender = 'male'
AND c.date_of_birth = DATE_SUB(CURDATE(), INTERVAL 25 YEAR) + INTERVAL 90 DAY"
);
foreach ($approaching25 as $child) {
SmsService::sendFromTemplate('CHILD_AGE_25_ALERT', $child['phone_mobile'], [
'member_name' => $child['member_name'],
'child_name' => $child['full_name_ar'],
'date' => date('Y-m-d', strtotime($child['date_of_birth'] . ' +25 years')),
], (int) $child['member_id']);
$processed++;
}
// Auto-freeze males at 25
$toFreeze = $this->db->select(
"SELECT c.id, c.full_name_ar FROM children c
WHERE c.is_archived = 0 AND c.status = 'active' AND c.is_frozen = 0 AND c.gender = 'male'
AND c.date_of_birth <= DATE_SUB(CURDATE(), INTERVAL 25 YEAR)"
);
foreach ($toFreeze as $child) {
$this->db->update('children', [
'is_frozen' => 1,
'frozen_at' => date('Y-m-d H:i:s'),
'frozen_reason' => 'تجميد تلقائي — بلوغ سن 25',
'status' => 'frozen',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $child['id']]);
Logger::info("Auto-frozen child #{$child['id']}: {$child['full_name_ar']}");
$processed++;
}
// Auto-remove temporary members at 25
$tempToRemove = $this->db->select(
"SELECT t.id, t.full_name_ar FROM temporary_members t
WHERE t.is_archived = 0 AND t.status = 'active'
AND t.date_of_birth <= DATE_SUB(CURDATE(), INTERVAL 25 YEAR)"
);
foreach ($tempToRemove as $temp) {
$this->db->update('temporary_members', [
'status' => 'inactive',
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $temp['id']]);
Logger::info("Auto-removed temp member #{$temp['id']}: {$temp['full_name_ar']}");
$processed++;
}
return ['processed' => $processed];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
class FormExpiryJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool { return true; }
public function run(): array
{
$expired = $this->db->select(
"SELECT id, form_number FROM form_submissions WHERE status IN ('submitted','under_review') AND expires_at IS NOT NULL AND expires_at < NOW()"
);
foreach ($expired as $form) {
$this->db->update('form_submissions', ['status' => 'expired', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $form['id']]);
Logger::info("Form expired: #{$form['id']}{$form['form_number']}");
}
return ['processed' => count($expired)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
class HonoraryExpiryJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool { return true; }
public function run(): array
{
$expired = $this->db->select(
"SELECT h.id, h.member_id, m.full_name_ar FROM honorary_members h JOIN members m ON m.id = h.member_id WHERE h.is_archived = 0 AND h.status = 'active' AND h.end_date < CURDATE()"
);
foreach ($expired as $h) {
$this->db->update('honorary_members', ['status' => 'expired', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $h['id']]);
Logger::info("Honorary membership expired: member #{$h['member_id']}{$h['full_name_ar']}");
}
return ['processed' => count($expired)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Modules\Notifications\Services\SmsService;
class InstallmentReminderJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool { return true; }
public function run(): array
{
$reminderDate = date('Y-m-d', strtotime('+7 days'));
$upcoming = $this->db->select(
"SELECT s.installment_number, s.amount, s.due_date, p.member_id,
m.full_name_ar, m.phone_mobile
FROM installment_schedule s
JOIN installment_plans p ON p.id = s.installment_plan_id
JOIN members m ON m.id = p.member_id
WHERE p.status = 'active' AND s.status = 'pending' AND s.due_date = ?
AND m.phone_mobile IS NOT NULL",
[$reminderDate]
);
$processed = 0;
foreach ($upcoming as $item) {
SmsService::sendFromTemplate('INSTALLMENT_REMINDER', $item['phone_mobile'], [
'member_name' => $item['full_name_ar'],
'installment_number' => $item['installment_number'],
'amount' => number_format((float) $item['amount'], 2),
'due_date' => $item['due_date'],
], (int) $item['member_id']);
$processed++;
}
return ['processed' => $processed];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
class OverdueFineJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
// Run after October 1 (month 10+)
return (int) date('n') >= 10;
}
public function run(): array
{
$processed = 0;
// Find unpaid subscriptions that are overdue (past September 30)
$overdue = $this->db->select(
"SELECT s.id, s.member_id, s.total_amount, s.financial_year
FROM subscriptions s
WHERE s.status = 'pending' AND s.paid_amount = 0"
);
foreach ($overdue as $sub) {
// Mark as overdue if not already
$this->db->update('subscriptions', [
'status' => 'overdue',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ? AND `status` = \'pending\'', [(int) $sub['id']]);
$processed++;
}
// Check for 5+ consecutive years of non-payment → drop membership
$members = $this->db->select(
"SELECT s.member_id, COUNT(DISTINCT s.financial_year) as unpaid_years
FROM subscriptions s
WHERE s.status IN ('pending','overdue') AND s.paid_amount = 0
GROUP BY s.member_id
HAVING unpaid_years >= 5"
);
foreach ($members as $m) {
$this->db->update('members', [
'status' => 'dropped',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ? AND `status` = \'active\'', [(int) $m['member_id']]);
if ($this->db->pdo()->rowCount() > 0) {
Logger::warning("Membership dropped due to 5+ years non-payment: member #{$m['member_id']}");
$processed++;
}
}
return ['processed' => $processed];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
class SeasonalExpiryJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool { return true; }
public function run(): array
{
$expired = $this->db->select(
"SELECT s.id, s.member_id, m.full_name_ar FROM seasonal_memberships s JOIN members m ON m.id = s.member_id WHERE s.is_archived = 0 AND s.status = 'active' AND s.end_date < CURDATE()"
);
foreach ($expired as $s) {
$this->db->update('seasonal_memberships', ['status' => 'expired', 'updated_at' => date('Y-m-d H:i:s')], '`id` = ?', [(int) $s['id']]);
Logger::info("Seasonal membership expired: #{$s['id']} — member #{$s['member_id']}");
}
return ['processed' => count($expired)];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
class SubscriptionGeneratorJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
// Run in July only
return (int) date('n') === 7 && (int) date('j') <= 7;
}
public function run(): array
{
$year = (int) date('Y');
$financialYear = $year . '/' . ($year + 1);
$processed = 0;
// Check if already generated
$existing = $this->db->selectOne("SELECT COUNT(*) as c FROM subscriptions WHERE financial_year = ?", [$financialYear]);
if ((int) ($existing['c'] ?? 0) > 0) {
return ['processed' => 0]; // Already generated
}
// Get all active members
$members = $this->db->select("SELECT id, full_name_ar, membership_type FROM members WHERE status = 'active' AND is_archived = 0");
$devFee = '35.00';
$ts = date('Y-m-d H:i:s');
foreach ($members as $m) {
$memberRate = '492.00'; // Default 2025/2026
// Generate for member
$this->db->insert('subscriptions', [
'member_id' => (int) $m['id'],
'financial_year' => $financialYear,
'person_type' => 'member',
'person_id' => (int) $m['id'],
'person_name' => $m['full_name_ar'],
'base_amount' => $memberRate,
'development_fee'=> $devFee,
'total_amount' => bcadd($memberRate, $devFee, 2),
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
]);
$processed++;
// Generate for spouses
$spouses = $this->db->select("SELECT id, full_name_ar FROM spouses WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [(int) $m['id']]);
foreach ($spouses as $sp) {
$this->db->insert('subscriptions', [
'member_id' => (int) $m['id'],
'financial_year' => $financialYear,
'person_type' => 'spouse',
'person_id' => (int) $sp['id'],
'person_name' => $sp['full_name_ar'],
'base_amount' => $memberRate,
'development_fee'=> $devFee,
'total_amount' => bcadd($memberRate, $devFee, 2),
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
]);
$processed++;
}
// Generate for children
$children = $this->db->select("SELECT id, full_name_ar FROM children WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [(int) $m['id']]);
$childRate = '222.00';
foreach ($children as $ch) {
$this->db->insert('subscriptions', [
'member_id' => (int) $m['id'],
'financial_year' => $financialYear,
'person_type' => 'child',
'person_id' => (int) $ch['id'],
'person_name' => $ch['full_name_ar'],
'base_amount' => $childRate,
'development_fee'=> $devFee,
'total_amount' => bcadd($childRate, $devFee, 2),
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
]);
$processed++;
}
// Generate for temporary members
$temps = $this->db->select("SELECT id, full_name_ar FROM temporary_members WHERE member_id = ? AND is_archived = 0 AND status = 'active'", [(int) $m['id']]);
foreach ($temps as $tmp) {
$this->db->insert('subscriptions', [
'member_id' => (int) $m['id'],
'financial_year' => $financialYear,
'person_type' => 'temporary',
'person_id' => (int) $tmp['id'],
'person_name' => $tmp['full_name_ar'],
'base_amount' => $childRate,
'development_fee'=> $devFee,
'total_amount' => bcadd($childRate, $devFee, 2),
'status' => 'pending',
'created_at' => $ts,
'updated_at' => $ts,
]);
$processed++;
}
}
Logger::info("Subscription generation complete for {$financialYear}: {$processed} records");
return ['processed' => $processed];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
class WorkflowTimeoutJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool { return true; }
public function run(): array
{
$processed = 0;
// Find workflow instances with timeout transitions
$instances = $this->db->select(
"SELECT wi.id, wi.workflow_definition_id, wi.current_state, wi.state_entered_at, wi.entity_type, wi.entity_id, wd.definition_json
FROM workflow_instances wi
JOIN workflow_definitions wd ON wd.id = wi.workflow_definition_id
WHERE wi.is_completed = 0"
);
foreach ($instances as $inst) {
$definition = json_decode($inst['definition_json'], true);
if (!$definition) continue;
$transitions = $definition['transitions'] ?? [];
foreach ($transitions as $trans) {
if (($trans['from'] ?? '') !== $inst['current_state']) continue;
if (($trans['trigger'] ?? '') !== 'automatic') continue;
// Check timeout guards
foreach (($trans['guards'] ?? []) as $guard) {
if (($guard['type'] ?? '') !== 'timeout') continue;
$days = $guard['days'] ?? 0;
if ($days <= 0) continue;
$enteredAt = strtotime($inst['state_entered_at']);
$timeoutAt = $enteredAt + ($days * 86400);
if (time() >= $timeoutAt) {
// Execute timeout transition
$newState = $trans['to'];
$this->db->update('workflow_instances', [
'current_state' => $newState,
'state_entered_at' => date('Y-m-d H:i:s'),
'is_completed' => in_array(($definition['states'][$newState]['type'] ?? ''), ['terminal']) ? 1 : 0,
'completed_at' => in_array(($definition['states'][$newState]['type'] ?? ''), ['terminal']) ? date('Y-m-d H:i:s') : null,
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $inst['id']]);
$this->db->insert('workflow_transition_log', [
'workflow_instance_id' => (int) $inst['id'],
'from_state' => $inst['current_state'],
'to_state' => $newState,
'transition_name' => $trans['name'] ?? 'timeout',
'trigger_type' => 'timeout',
'created_at' => date('Y-m-d H:i:s'),
]);
Logger::info("Workflow timeout: instance #{$inst['id']}{$inst['current_state']}{$newState}");
$processed++;
break 2; // Only one transition per instance per run
}
}
}
}
return ['processed' => $processed];
}
}
\ No newline at end of file
<?php
declare(strict_types=1);
/**
* Cron Runner — execute via: php cron/runner.php
* Should be called by system crontab every hour (or as configured).
*/
$basePath = dirname(__DIR__);
require_once $basePath . '/app/Core/Autoloader.php';
\App\Core\Autoloader::register();
$app = \App\Core\App::getInstance();
$app->boot();
$db = $app->db();
$jobFiles = glob($basePath . '/cron/jobs/*.php');
echo "[" . date('Y-m-d H:i:s') . "] Cron runner started. Found " . count($jobFiles) . " jobs.\n";
foreach ($jobFiles as $jobFile) {
$className = pathinfo($jobFile, PATHINFO_FILENAME);
require_once $jobFile;
$fullClass = "CronJobs\\{$className}";
if (!class_exists($fullClass)) {
echo " [SKIP] Class {$fullClass} not found in {$jobFile}\n";
continue;
}
$job = new $fullClass($db);
if (!$job->shouldRun()) {
continue;
}
$startTime = microtime(true);
$logId = $db->insert('cron_job_log', [
'job_name' => $className,
'started_at' => date('Y-m-d H:i:s'),
'status' => 'running',
]);
echo " [RUN] {$className}...";
try {
$result = $job->run();
$elapsed = (int) ((microtime(true) - $startTime) * 1000);
$db->update('cron_job_log', [
'finished_at' => date('Y-m-d H:i:s'),
'status' => 'completed',
'records_processed' => $result['processed'] ?? 0,
'execution_time_ms' => $elapsed,
], '`id` = ?', [$logId]);
echo " OK ({$elapsed}ms, " . ($result['processed'] ?? 0) . " records)\n";
} catch (\Throwable $e) {
$elapsed = (int) ((microtime(true) - $startTime) * 1000);
$db->update('cron_job_log', [
'finished_at' => date('Y-m-d H:i:s'),
'status' => 'failed',
'error_message' => $e->getMessage(),
'execution_time_ms' => $elapsed,
], '`id` = ?', [$logId]);
echo " FAILED: {$e->getMessage()}\n";
\App\Core\Logger::error("Cron job {$className} failed: {$e->getMessage()}");
}
}
// Process notification queue
echo " [RUN] NotificationQueueProcessor...";
$queueResult = \App\Modules\Notifications\Services\SmsService::processQueue(100);
echo " sent={$queueResult['sent']}, failed={$queueResult['failed']}\n";
echo "[" . date('Y-m-d H:i:s') . "] Cron runner finished.\n";
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `sms_templates` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`template_code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`message_template_ar` TEXT NOT NULL,
`message_template_en` TEXT NULL,
`variables_json` JSON NULL,
`trigger_event` VARCHAR(100) NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_sms_templates_code` (`template_code`),
INDEX `idx_sms_templates_trigger` (`trigger_event`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `sms_templates`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `sms_log` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`member_id` BIGINT UNSIGNED NULL,
`phone_number` VARCHAR(20) NOT NULL,
`template_id` BIGINT UNSIGNED NULL,
`message_text` TEXT NOT NULL,
`message_type` VARCHAR(20) NOT NULL DEFAULT 'single',
`status` VARCHAR(30) NOT NULL DEFAULT 'queued',
`provider_message_id` VARCHAR(200) NULL,
`provider_response` TEXT NULL,
`cost` DECIMAL(10,4) NULL,
`retry_count` INT UNSIGNED NOT NULL DEFAULT 0,
`max_retries` INT UNSIGNED NOT NULL DEFAULT 3,
`sent_at` TIMESTAMP NULL DEFAULT NULL,
`delivered_at` TIMESTAMP NULL DEFAULT NULL,
`failed_at` TIMESTAMP NULL DEFAULT NULL,
`failure_reason` TEXT NULL,
`sent_by` BIGINT UNSIGNED NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_sms_log_member` (`member_id`),
INDEX `idx_sms_log_phone` (`phone_number`),
INDEX `idx_sms_log_status` (`status`),
INDEX `idx_sms_log_date` (`created_at`),
INDEX `idx_sms_log_template` (`template_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `sms_log`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `notification_queue` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`type` VARCHAR(20) NOT NULL DEFAULT 'sms',
`recipient_type` VARCHAR(20) NOT NULL DEFAULT 'member',
`recipient_id` BIGINT UNSIGNED NOT NULL,
`template_id` BIGINT UNSIGNED NULL,
`subject` VARCHAR(500) NULL,
`message` TEXT NOT NULL,
`data_json` JSON NULL,
`priority` INT NOT NULL DEFAULT 0,
`status` VARCHAR(30) NOT NULL DEFAULT 'pending',
`attempts` INT UNSIGNED NOT NULL DEFAULT 0,
`max_attempts` INT UNSIGNED NOT NULL DEFAULT 3,
`scheduled_at` TIMESTAMP NULL DEFAULT NULL,
`processed_at` TIMESTAMP NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_notif_queue_status` (`status`),
INDEX `idx_notif_queue_type` (`type`),
INDEX `idx_notif_queue_recipient` (`recipient_type`, `recipient_id`),
INDEX `idx_notif_queue_scheduled` (`scheduled_at`),
INDEX `idx_notif_queue_priority` (`priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `notification_queue`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `report_definitions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`report_code` VARCHAR(50) NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`name_en` VARCHAR(200) NULL,
`description_ar` TEXT NULL,
`category` VARCHAR(50) NOT NULL DEFAULT 'general',
`query_definition_json` JSON NULL,
`column_definitions_json` JSON NULL,
`default_filters_json` JSON NULL,
`required_permission` VARCHAR(100) NULL,
`is_system` TINYINT(1) NOT NULL DEFAULT 1,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
UNIQUE KEY `uq_report_defs_code` (`report_code`),
INDEX `idx_report_defs_category` (`category`),
INDEX `idx_report_defs_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `report_definitions`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS `scheduled_reports` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`report_definition_id` BIGINT UNSIGNED NOT NULL,
`name_ar` VARCHAR(200) NOT NULL,
`schedule_type` VARCHAR(30) NOT NULL DEFAULT 'monthly',
`schedule_config_json` JSON NULL,
`filters_json` JSON NULL,
`format` VARCHAR(20) NOT NULL DEFAULT 'pdf',
`last_generated_at` TIMESTAMP NULL DEFAULT NULL,
`next_run_at` TIMESTAMP NULL DEFAULT NULL,
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_by` BIGINT UNSIGNED NULL,
`updated_by` BIGINT UNSIGNED NULL,
INDEX `idx_sched_reports_def` (`report_definition_id`),
INDEX `idx_sched_reports_next` (`next_run_at`),
INDEX `idx_sched_reports_active` (`is_active`),
CONSTRAINT `fk_sched_reports_def` FOREIGN KEY (`report_definition_id`) REFERENCES `report_definitions`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
",
'down' => "DROP TABLE IF EXISTS `scheduled_reports`",
];
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$ts = date('Y-m-d H:i:s');
$templates = [
[
'template_code' => 'MEMBER_ACCEPTED',
'name_ar' => 'قبول العضوية',
'name_en' => 'Membership Accepted',
'message_template_ar' => 'عزيزي {member_name}، تم قبول عضويتكم في نادي النادي شيراتون. يرجى سداد المستحقات خلال 15 يوم من تاريخ اليوم. رقم الاستمارة: {form_number}',
'variables_json' => '["member_name","form_number"]',
'trigger_event' => 'member.accepted',
],
[
'template_code' => 'MEMBER_REJECTED',
'name_ar' => 'رفض العضوية',
'name_en' => 'Membership Rejected',
'message_template_ar' => 'عزيزي {member_name}، نأسف لعدم قبول طلب عضويتكم في نادي النادي شيراتون. يمكنكم التقدم مرة أخرى.',
'variables_json' => '["member_name"]',
'trigger_event' => 'member.rejected',
],
[
'template_code' => 'PAYMENT_CONFIRMED',
'name_ar' => 'تأكيد الدفع',
'name_en' => 'Payment Confirmed',
'message_template_ar' => 'عزيزي {member_name}، تم تسجيل دفعتكم بمبلغ {amount} ج.م. رقم الإيصال: {receipt_number}. شكراً لكم.',
'variables_json' => '["member_name","amount","receipt_number"]',
'trigger_event' => 'payment.completed',
],
[
'template_code' => 'SUBSCRIPTION_DUE',
'name_ar' => 'موعد الاشتراك السنوي',
'name_en' => 'Subscription Due',
'message_template_ar' => 'عزيزي {member_name}، يُرجى سداد الاشتراك السنوي {financial_year} بمبلغ {amount} ج.م قبل 30 سبتمبر لتجنب الغرامات.',
'variables_json' => '["member_name","financial_year","amount"]',
'trigger_event' => 'subscription.due',
],
[
'template_code' => 'SUBSCRIPTION_OVERDUE',
'name_ar' => 'تأخر الاشتراك السنوي',
'name_en' => 'Subscription Overdue',
'message_template_ar' => 'عزيزي {member_name}، الاشتراك السنوي {financial_year} متأخر. المبلغ المستحق: {amount} ج.م بما في ذلك الغرامات. يرجى السداد فوراً.',
'variables_json' => '["member_name","financial_year","amount"]',
'trigger_event' => 'subscription.overdue',
],
[
'template_code' => 'INSTALLMENT_REMINDER',
'name_ar' => 'تذكير بالقسط',
'name_en' => 'Installment Reminder',
'message_template_ar' => 'عزيزي {member_name}، تذكير بموعد القسط رقم {installment_number} بمبلغ {amount} ج.م المستحق في {due_date}.',
'variables_json' => '["member_name","installment_number","amount","due_date"]',
'trigger_event' => 'installment.reminder',
],
[
'template_code' => 'INSTALLMENT_OVERDUE',
'name_ar' => 'تأخر القسط',
'name_en' => 'Installment Overdue',
'message_template_ar' => 'عزيزي {member_name}، القسط رقم {installment_number} بمبلغ {amount} ج.م تجاوز موعده. يرجى السداد فوراً لتجنب الإيقاف.',
'variables_json' => '["member_name","installment_number","amount"]',
'trigger_event' => 'installment.overdue',
],
[
'template_code' => 'FORM_EXPIRY_WARNING',
'name_ar' => 'تحذير انتهاء الاستمارة',
'name_en' => 'Form Expiry Warning',
'message_template_ar' => 'عزيزي {member_name}، استمارتكم رقم {form_number} ستنتهي خلال {days_remaining} أيام. يرجى إتمام السداد قبل {expiry_date}.',
'variables_json' => '["member_name","form_number","days_remaining","expiry_date"]',
'trigger_event' => 'form.expiry_warning',
],
[
'template_code' => 'INTERVIEW_SCHEDULED',
'name_ar' => 'موعد المقابلة',
'name_en' => 'Interview Scheduled',
'message_template_ar' => 'عزيزي {member_name}، تم تحديد موعد مقابلتكم مع مجلس الأمناء في {date} الساعة {time}. يرجى الحضور في الموعد.',
'variables_json' => '["member_name","date","time"]',
'trigger_event' => 'interview.scheduled',
],
[
'template_code' => 'CHILD_AGE_18_ALERT',
'name_ar' => 'تنبيه بلوغ ابن 18 سنة',
'name_en' => 'Child Age 18 Alert',
'message_template_ar' => 'عزيزي {member_name}، نُعلمكم أن {child_name} سيبلغ 18 عاماً في {date}. قد تترتب رسوم إضافية.',
'variables_json' => '["member_name","child_name","date"]',
'trigger_event' => 'child.approaching_18',
],
[
'template_code' => 'CHILD_AGE_25_ALERT',
'name_ar' => 'تنبيه بلوغ ابن 25 سنة',
'name_en' => 'Child Age 25 Alert',
'message_template_ar' => 'عزيزي {member_name}، نُعلمكم أن {child_name} سيبلغ 25 عاماً في {date}. يجب التحويل لعضوية مستقلة.',
'variables_json' => '["member_name","child_name","date"]',
'trigger_event' => 'child.approaching_25',
],
[
'template_code' => 'PENALTY_IMPOSED',
'name_ar' => 'إخطار بعقوبة',
'name_en' => 'Penalty Imposed',
'message_template_ar' => 'عزيزي {member_name}، تم فرض عقوبة: {penalty_type}. يمكنكم التظلم خلال 15 يوم.',
'variables_json' => '["member_name","penalty_type"]',
'trigger_event' => 'fine.imposed',
],
[
'template_code' => 'HONORARY_EXPIRY_WARNING',
'name_ar' => 'تحذير انتهاء العضوية الشرفية',
'name_en' => 'Honorary Expiry Warning',
'message_template_ar' => 'عزيزي {member_name}، عضويتكم الشرفية ستنتهي في {expiry_date}. يرجى التواصل مع الإدارة للتجديد.',
'variables_json' => '["member_name","expiry_date"]',
'trigger_event' => 'honorary.expiry_warning',
],
[
'template_code' => 'GENERAL_NOTIFICATION',
'name_ar' => 'إخطار عام',
'name_en' => 'General Notification',
'message_template_ar' => '{message}',
'variables_json' => '["message"]',
'trigger_event' => null,
],
];
foreach ($templates as $t) {
$existing = $db->selectOne("SELECT id FROM sms_templates WHERE template_code = ?", [$t['template_code']]);
if ($existing) continue;
$db->insert('sms_templates', [
'template_code' => $t['template_code'],
'name_ar' => $t['name_ar'],
'name_en' => $t['name_en'] ?? null,
'message_template_ar' => $t['message_template_ar'],
'variables_json' => $t['variables_json'],
'trigger_event' => $t['trigger_event'],
'is_active' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
};
\ No newline at end of file
<?php
declare(strict_types=1);
use App\Core\Database;
return function (Database $db): void {
$ts = date('Y-m-d H:i:s');
$reports = [
['code' => 'RPT_MEMBERSHIP_REGISTER', 'name_ar' => 'سجل العضويات', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_NEW_MEMBERSHIPS', 'name_ar' => 'العضويات الجديدة', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_CHILDREN', 'name_ar' => 'تقرير الأبناء', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_CHILDREN_18', 'name_ar' => 'الأبناء المقتربون من 18', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_CHILDREN_25', 'name_ar' => 'الأبناء المقتربون من 25', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_SPOUSES', 'name_ar' => 'تقرير الزوجات', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_TEMPORARY', 'name_ar' => 'الأعضاء المؤقتون', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_SEASONAL', 'name_ar' => 'العضويات الموسمية', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_TRANSFERS', 'name_ar' => 'التحويلات والفصل', 'category' => 'operations', 'permission' => 'report.view_operations'],
['code' => 'RPT_SEPARATIONS', 'name_ar' => 'عمليات الفصل', 'category' => 'operations', 'permission' => 'report.view_operations'],
['code' => 'RPT_WAIVERS', 'name_ar' => 'التنازلات', 'category' => 'operations', 'permission' => 'report.view_operations'],
['code' => 'RPT_DIVORCE', 'name_ar' => 'حالات الطلاق', 'category' => 'operations', 'permission' => 'report.view_operations'],
['code' => 'RPT_DEATH', 'name_ar' => 'حالات الوفاة', 'category' => 'operations', 'permission' => 'report.view_operations'],
['code' => 'RPT_REVENUE', 'name_ar' => 'تقرير الإيرادات', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_OUTSTANDING', 'name_ar' => 'الأرصدة المستحقة', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_INSTALLMENTS', 'name_ar' => 'حالة الأقساط', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_SUBSCRIPTIONS', 'name_ar' => 'الاشتراكات السنوية', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_OVERDUE_SUBSCRIPTIONS', 'name_ar' => 'الاشتراكات المتأخرة', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_FINES', 'name_ar' => 'الغرامات والعقوبات', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_DAILY_CASH', 'name_ar' => 'تقرير الخزينة اليومي', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_VOIDED_RECEIPTS', 'name_ar' => 'الإيصالات الملغاة', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_EMPLOYEE_ACTIVITY', 'name_ar' => 'نشاط الموظفين', 'category' => 'audit', 'permission' => 'report.view_audit'],
['code' => 'RPT_CARNET_PRINTS', 'name_ar' => 'طباعات الكارنيه', 'category' => 'operations', 'permission' => 'report.view_operations'],
['code' => 'RPT_POTENTIAL_REJECTED', 'name_ar' => 'الطلبات المرفوضة', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_EXPIRED_FORMS', 'name_ar' => 'الاستمارات المنتهية', 'category' => 'operations', 'permission' => 'report.view_operations'],
['code' => 'RPT_MEMBER_MOVEMENT', 'name_ar' => 'حركة العضوية', 'category' => 'membership', 'permission' => 'report.view_membership'],
['code' => 'RPT_GROUP_DISCOUNTS', 'name_ar' => 'خصومات المجموعات', 'category' => 'financial', 'permission' => 'report.view_financial'],
['code' => 'RPT_BRANCH_COMPARISON', 'name_ar' => 'مقارنة الفروع', 'category' => 'general', 'permission' => 'report.view_membership'],
['code' => 'RPT_SMS_LOG', 'name_ar' => 'سجل الرسائل النصية', 'category' => 'audit', 'permission' => 'report.view_audit'],
['code' => 'RPT_FOREIGN_MEMBERS', 'name_ar' => 'الأعضاء الأجانب', 'category' => 'membership', 'permission' => 'report.view_membership'],
];
foreach ($reports as $r) {
$existing = $db->selectOne("SELECT id FROM report_definitions WHERE report_code = ?", [$r['code']]);
if ($existing) continue;
$db->insert('report_definitions', [
'report_code' => $r['code'],
'name_ar' => $r['name_ar'],
'category' => $r['category'],
'required_permission' => $r['permission'],
'is_system' => 1,
'is_active' => 1,
'created_at' => $ts,
'updated_at' => $ts,
]);
}
};
\ No newline at end of file
/**
* Dashboard JS — Phase 15
* Minimal chart rendering and dashboard interactions.
*/
(function() {
'use strict';
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh dashboard every 5 minutes
if (window.location.pathname === '/dashboard') {
setTimeout(function() {
window.location.reload();
}, 300000);
}
});
})();
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment