Commit f46e7a77 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(dashboard): role-aware dashboards + super-admin command center

Every user previously saw the same dashboard: DashboardDataService::getData()
returned one fixed payload with no reference to the current employee. A cashier
got membership stats they could not act on; an HR manager got revenue instead of
headcount.

Each role now gets a curated dashboard. Role presets pick the layout, permissions
gate every widget (mirroring MenuRegistry::getVisible), and multi-role users get
the deduped union of their presets. Super admin gets a 5-KPI, 16-widget command
center across six sections.

Wires up WidgetRegistry, which existed fully written but was used by nothing.

144 widgets, all SQL executed and verified against the live schema — 46 were
corrected during verification, including a month-to-date figure compared against
a full prior month (a fake collapse every month), spouse counts missing their
status filter, and receivables that included debt owed by archived deceased
members.

Only the headline plus first six widgets query on load; the rest hydrate through
GET /dashboard/widget/{key}, which re-checks permission server-side and renders
via the same partial as the eager path. Employees with no mapped role fall back
to the previous shared dashboard, preserved verbatim.

Also loads Chart.js, which PlayerAffairs has always called behind a
`typeof Chart !== 'undefined'` guard while the library was loaded nowhere —
those evaluation charts were silently dead and now render.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 7b74997d
...@@ -28,4 +28,33 @@ final class WidgetRegistry ...@@ -28,4 +28,33 @@ final class WidgetRegistry
{ {
return isset(self::$items[$key]); return isset(self::$items[$key]);
} }
/**
* Filter a preset's widget keys down to the ones this user may see.
* Mirrors MenuRegistry::getVisible() — a null permission is public, and the
* wildcard '*' (super admin) passes everything.
*
* Order follows $keys, so the preset controls layout.
*/
public static function getVisible(array $userPermissions, array $keys): array
{
$visible = [];
foreach ($keys as $key) {
$item = self::$items[$key] ?? null;
if ($item === null) {
continue;
}
if (self::allows($item['permission'] ?? null, $userPermissions)) {
$visible[$key] = $item;
}
}
return $visible;
}
public static function allows(?string $permission, array $userPermissions): bool
{
return $permission === null
|| in_array('*', $userPermissions, true)
|| in_array($permission, $userPermissions, true);
}
} }
\ No newline at end of file
<?php
declare(strict_types=1);
/**
* Arabic column headings per widget, keyed by SQL alias / column name.
*
* role: primary = the KPI headline figure | secondary = supporting figure
* label = row caption / chart category | value = plotted or displayed number
* link = href or id used for drill-through (never rendered) | hidden = internal (never rendered)
*/
return [
'executive_active_members' => [
'active_members' => ['ar' => 'الأعضاء النشطون', 'role' => 'primary'],
'new_this_month' => ['ar' => 'جدد هذا الشهر', 'role' => 'secondary'],
'active_children' => ['ar' => 'الأبناء النشطون', 'role' => 'secondary'],
'active_spouses' => ['ar' => 'الأزواج النشطون', 'role' => 'secondary'],
'in_pipeline' => ['ar' => 'تحت الإجراء', 'role' => 'secondary'],
],
'executive_board_approval_queue' => [
'kind' => ['ar' => 'رمز النوع', 'role' => 'hidden'],
'kind_ar' => ['ar' => 'نوع الطلب', 'role' => 'label'],
'ref_id' => ['ar' => 'معرف الطلب', 'role' => 'hidden'],
'subject_ar' => ['ar' => 'اسم العضو', 'role' => 'label'],
'ref_no' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'amount' => ['ar' => 'المبلغ', 'role' => 'value'],
'age_days' => ['ar' => 'أيام الانتظار', 'role' => 'secondary'],
'link' => ['ar' => 'رابط الطلب', 'role' => 'link'],
],
'executive_branch_performance' => [
'members' => ['ar' => 'إجمالي الأعضاء', 'role' => 'value'],
'active_members' => ['ar' => 'الأعضاء النشطون', 'role' => 'value'],
'revenue_90d' => ['ar' => 'إيرادات ٩٠ يوم', 'role' => 'value'],
'payments_90d' => ['ar' => 'عدد المدفوعات', 'role' => 'secondary'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'name_ar' => ['ar' => 'الفرع', 'role' => 'label'],
],
'executive_cash_on_hand' => [
'total_custody' => ['ar' => 'إجمالي العهدة', 'role' => 'primary'],
'treasury_count' => ['ar' => 'عدد الخزائن', 'role' => 'secondary'],
'treasuries_holding_cash' => ['ar' => 'خزائن بها نقدية', 'role' => 'secondary'],
'bal' => ['ar' => 'رصيد العهدة', 'role' => 'hidden'],
],
'executive_control_exceptions' => [
'ord' => ['ar' => 'ترتيب العرض', 'role' => 'hidden'],
'k' => ['ar' => 'رمز المخالفة', 'role' => 'hidden'],
'label_ar' => ['ar' => 'بيان المخالفة', 'role' => 'label'],
'c' => ['ar' => 'عدد الحالات', 'role' => 'value'],
'v' => ['ar' => 'المبلغ', 'role' => 'secondary'],
'link' => ['ar' => 'رابط المتابعة', 'role' => 'link'],
],
'executive_facility_utilization' => [
'bookings' => ['ar' => 'عدد الحجوزات', 'role' => 'secondary'],
'booked_hours' => ['ar' => 'ساعات الإشغال', 'role' => 'value'],
'revenue' => ['ar' => 'إيراد الحجوزات', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'name_ar' => ['ar' => 'المنشأة', 'role' => 'label'],
],
'executive_hr_headcount_cost' => [
'staffing_capacity' => ['ar' => 'العدد المعتمد', 'role' => 'value'],
'headcount' => ['ar' => 'عدد الموظفين', 'role' => 'value'],
'monthly_cost' => ['ar' => 'التكلفة الشهرية', 'role' => 'value'],
'on_probation' => ['ar' => 'تحت التجربة', 'role' => 'secondary'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'name_ar' => ['ar' => 'الإدارة', 'role' => 'label'],
],
'executive_membership_growth' => [
'month' => ['ar' => 'الشهر', 'role' => 'label'],
'joined' => ['ar' => 'المنضمون', 'role' => 'value'],
'became_active' => ['ar' => 'أصبحوا نشطين', 'role' => 'value'],
'exited' => ['ar' => 'منتهية عضويتهم', 'role' => 'value'],
'still_pending' => ['ar' => 'تحت الإجراء', 'role' => 'value'],
],
'executive_open_approvals' => [
'waivers' => ['ar' => 'تنازلات العضوية', 'role' => 'secondary'],
'transfers' => ['ar' => 'طلبات الفصل', 'role' => 'secondary'],
'death_cases' => ['ar' => 'حالات الوفاة', 'role' => 'secondary'],
'interviews' => ['ar' => 'مقابلات العضوية', 'role' => 'secondary'],
'open_workflows' => ['ar' => 'معاملات مفتوحة', 'role' => 'primary'],
],
'executive_open_cashier_sessions' => [
'treasury_ar' => ['ar' => 'الخزنة', 'role' => 'label'],
'cashier_ar' => ['ar' => 'أمين الخزنة', 'role' => 'label'],
'open_days' => ['ar' => 'أيام الفتح', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'session_number' => ['ar' => 'رقم الوردية', 'role' => 'label'],
'opened_at' => ['ar' => 'تاريخ الفتح', 'role' => 'secondary'],
'opening_balance' => ['ar' => 'رصيد الافتتاح', 'role' => 'value'],
'total_collected' => ['ar' => 'إجمالي التحصيل', 'role' => 'value'],
'total_receipts' => ['ar' => 'إجمالي الإيصالات', 'role' => 'secondary'],
],
'executive_payroll_status' => [
'runs_count' => ['ar' => 'عدد المسيرات', 'role' => 'secondary'],
'total_net' => ['ar' => 'صافي الرواتب', 'role' => 'primary'],
'total_gross' => ['ar' => 'إجمالي الأجور', 'role' => 'secondary'],
'employer_insurance' => ['ar' => 'تأمينات صاحب العمل', 'role' => 'secondary'],
'paid_runs' => ['ar' => 'مسيرات مصروفة', 'role' => 'secondary'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'period_code' => ['ar' => 'كود الفترة', 'role' => 'label'],
'status' => ['ar' => 'الحالة', 'role' => 'secondary'],
'start_date' => ['ar' => 'بداية الفترة', 'role' => 'secondary'],
'end_date' => ['ar' => 'نهاية الفترة', 'role' => 'secondary'],
'is_locked' => ['ar' => 'حالة الإقفال', 'role' => 'secondary'],
],
'executive_receivables_aging' => [
'bucket_ord' => ['ar' => 'ترتيب الفئة', 'role' => 'hidden'],
'label_ar' => ['ar' => 'نوع المستحق', 'role' => 'label'],
'c' => ['ar' => 'عدد الحالات', 'role' => 'secondary'],
'amt' => ['ar' => 'قيمة المستحق', 'role' => 'value'],
'link' => ['ar' => 'رابط التفاصيل', 'role' => 'link'],
],
'executive_receivables_total' => [
'total_due' => ['ar' => 'إجمالي المستحقات', 'role' => 'primary'],
'members_in_arrears' => ['ar' => 'أعضاء متأخرون', 'role' => 'secondary'],
'awaiting_collection' => ['ar' => 'بانتظار التحصيل', 'role' => 'secondary'],
],
'executive_revenue_by_stream_trend' => [
'month' => ['ar' => 'الشهر', 'role' => 'label'],
'stream_ar' => ['ar' => 'مصدر الإيراد', 'role' => 'label'],
'total' => ['ar' => 'إجمالي الإيراد', 'role' => 'value'],
'cnt' => ['ar' => 'عدد المدفوعات', 'role' => 'secondary'],
],
'executive_revenue_mtd' => [
'total' => ['ar' => 'إيرادات الشهر', 'role' => 'primary'],
'payments_count' => ['ar' => 'عدد المدفوعات', 'role' => 'secondary'],
'prev_month_same_period' => ['ar' => 'نفس الفترة سابقاً', 'role' => 'secondary'],
'prev_month_total' => ['ar' => 'إجمالي الشهر الماضي', 'role' => 'secondary'],
],
'executive_sports_enrollment' => [
'discipline_ar' => ['ar' => 'النشاط', 'role' => 'label'],
'groups_count' => ['ar' => 'عدد المجموعات', 'role' => 'secondary'],
'enrolled' => ['ar' => 'عدد المقيدين', 'role' => 'value'],
'capacity' => ['ar' => 'الطاقة الاستيعابية', 'role' => 'value'],
'fill_pct' => ['ar' => 'نسبة الإشغال', 'role' => 'secondary'],
'full_groups' => ['ar' => 'مجموعات مكتملة', 'role' => 'secondary'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
],
'executive_subscription_collection' => [
'financial_year' => ['ar' => 'السنة المالية', 'role' => 'label'],
'total_subs' => ['ar' => 'إجمالي الاشتراكات', 'role' => 'secondary'],
'paid_subs' => ['ar' => 'اشتراكات مسددة', 'role' => 'secondary'],
'overdue_subs' => ['ar' => 'اشتراكات متأخرة', 'role' => 'secondary'],
'pending_subs' => ['ar' => 'اشتراكات معلقة', 'role' => 'secondary'],
'billed' => ['ar' => 'قيمة المطالبات', 'role' => 'value'],
'collected' => ['ar' => 'المبلغ المحصل', 'role' => 'value'],
'fines_applied' => ['ar' => 'غرامات مطبقة', 'role' => 'secondary'],
],
'executive_support_backlog' => [
'open_total' => ['ar' => 'إجمالي المفتوحة', 'role' => 'value'],
'status_open' => ['ar' => 'بلاغات جديدة', 'role' => 'value'],
'status_in_progress' => ['ar' => 'قيد المعالجة', 'role' => 'value'],
'status_waiting' => ['ar' => 'في الانتظار', 'role' => 'value'],
'urgent' => ['ar' => 'عاجلة', 'role' => 'secondary'],
'high' => ['ar' => 'أهمية عالية', 'role' => 'secondary'],
'unassigned' => ['ar' => 'غير مسندة', 'role' => 'secondary'],
'oldest_days' => ['ar' => 'أقدم بلاغ', 'role' => 'secondary'],
'resolved_this_month' => ['ar' => 'مغلقة هذا الشهر', 'role' => 'secondary'],
],
'executive_system_health' => [
'live_sessions' => ['ar' => 'جلسات نشطة الآن', 'role' => 'value'],
'open_sessions_total' => ['ar' => 'إجمالي الجلسات', 'role' => 'value'],
'failed_logins_24h' => ['ar' => 'محاولات دخول فاشلة', 'role' => 'value'],
'failed_login_ips' => ['ar' => 'عناوين مشبوهة', 'role' => 'secondary'],
'audit_events_24h' => ['ar' => 'أحداث المراجعة', 'role' => 'value'],
'locked_accounts' => ['ar' => 'حسابات مقفلة', 'role' => 'value'],
'never_logged_in' => ['ar' => 'لم يسجلوا دخولاً', 'role' => 'secondary'],
'active_employees' => ['ar' => 'الموظفون النشطون', 'role' => 'value'],
],
'executive_top_debtors' => [
'branch_ar' => ['ar' => 'الفرع', 'role' => 'label'],
'unpaid_years' => ['ar' => 'سنوات غير مسددة', 'role' => 'value'],
'unpaid_lines' => ['ar' => 'بنود غير مسددة', 'role' => 'value'],
'total_due' => ['ar' => 'إجمالي المديونية', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم العضو', 'role' => 'label'],
'phone_mobile' => ['ar' => 'رقم المحمول', 'role' => 'secondary'],
],
'executive_treasury_positions' => [
'balance' => ['ar' => 'رصيد العهدة', 'role' => 'value'],
'open_sessions' => ['ar' => 'ورديات مفتوحة', 'role' => 'value'],
'oldest_open_days' => ['ar' => 'أقدم وردية', 'role' => 'secondary'],
'today_collected' => ['ar' => 'تحصيل اليوم', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'name_ar' => ['ar' => 'الخزنة', 'role' => 'label'],
'type' => ['ar' => 'نوع الخزنة', 'role' => 'secondary'],
],
'finance_audit_activity_today' => [
'events_today' => ['ar' => 'أحداث اليوم', 'role' => 'primary'],
'events_7d' => ['ar' => 'أحداث الأسبوع', 'role' => 'secondary'],
'actors_today' => ['ar' => 'المستخدمون اليوم', 'role' => 'secondary'],
'deletes_30d' => ['ar' => 'عمليات حذف', 'role' => 'secondary'],
],
'finance_audit_volume_14d' => [
'd' => ['ar' => 'التاريخ', 'role' => 'label'],
'total' => ['ar' => 'إجمالي الأحداث', 'role' => 'value'],
'creates' => ['ar' => 'إضافة', 'role' => 'value'],
'updates' => ['ar' => 'تعديل', 'role' => 'value'],
'deletes' => ['ar' => 'حذف', 'role' => 'value'],
],
'finance_collections_channel_30d' => [
'treasury_name' => ['ar' => 'الخزنة', 'role' => 'label'],
'cnt' => ['ar' => 'عدد العمليات', 'role' => 'secondary'],
'total' => ['ar' => 'إجمالي التحصيل', 'role' => 'value'],
],
'finance_custody_by_treasury' => [
'treasury_name' => ['ar' => 'الخزنة', 'role' => 'label'],
'cashier_name' => ['ar' => 'أمين الخزنة', 'role' => 'label'],
'last_movement_at' => ['ar' => 'آخر حركة', 'role' => 'secondary'],
'days_idle' => ['ar' => 'أيام بدون حركة', 'role' => 'value'],
'max_id' => ['ar' => 'معرّف الحركة', 'role' => 'hidden'],
'balance_after' => ['ar' => 'رصيد العهدة', 'role' => 'value'],
],
'finance_daily_cash_14d' => [
'dt' => ['ar' => 'التاريخ', 'role' => 'hidden'],
'd' => ['ar' => 'اليوم', 'role' => 'label'],
'cnt' => ['ar' => 'عدد العمليات', 'role' => 'secondary'],
'total' => ['ar' => 'إجمالي النقدية', 'role' => 'value'],
],
'finance_fiscal_period_status' => [
'days_remaining' => ['ar' => 'أيام متبقية', 'role' => 'primary'],
'flagged_current_count' => ['ar' => 'سنوات معلّمة حالية', 'role' => 'secondary'],
'entry_count' => ['ar' => 'عدد القيود', 'role' => 'secondary'],
'id' => ['ar' => 'معرّف السنة', 'role' => 'hidden'],
'name_ar' => ['ar' => 'السنة المالية', 'role' => 'label'],
'start_date' => ['ar' => 'بداية السنة', 'role' => 'secondary'],
'end_date' => ['ar' => 'نهاية السنة', 'role' => 'secondary'],
'status' => ['ar' => 'الحالة', 'role' => 'secondary'],
],
'finance_journal_volume_6m' => [
'month' => ['ar' => 'الشهر', 'role' => 'label'],
'entry_count' => ['ar' => 'عدد القيود', 'role' => 'value'],
'total_debit' => ['ar' => 'إجمالي المدين', 'role' => 'value'],
'draft_count' => ['ar' => 'قيود مسودة', 'role' => 'secondary'],
],
'finance_membership_queue' => [
'pending_count' => ['ar' => 'طلبات منتظرة', 'role' => 'primary'],
'pending_amount' => ['ar' => 'قيمة الطلبات', 'role' => 'secondary'],
'aged_count' => ['ar' => 'طلبات متأخرة', 'role' => 'secondary'],
'oldest_hours' => ['ar' => 'ساعات أقدم طلب', 'role' => 'secondary'],
],
'finance_my_collections_today' => [
'cnt' => ['ar' => 'عدد العمليات', 'role' => 'secondary'],
'total' => ['ar' => 'إجمالي التحصيل', 'role' => 'primary'],
'cash_total' => ['ar' => 'تحصيل نقدي', 'role' => 'secondary'],
'non_cash_total' => ['ar' => 'تحصيل غير نقدي', 'role' => 'secondary'],
],
'finance_my_custody_balance' => [
'custody_total' => ['ar' => 'رصيد العهدة', 'role' => 'primary'],
'treasury_count' => ['ar' => 'عدد الخزائن', 'role' => 'secondary'],
'last_movement_at' => ['ar' => 'آخر حركة', 'role' => 'secondary'],
'max_days_idle' => ['ar' => 'أيام بدون حركة', 'role' => 'secondary'],
'max_id' => ['ar' => 'معرّف الحركة', 'role' => 'hidden'],
],
'finance_my_custody_log' => [
'treasury_name' => ['ar' => 'الخزنة', 'role' => 'label'],
'id' => ['ar' => 'معرّف الحركة', 'role' => 'hidden'],
'action' => ['ar' => 'نوع الحركة', 'role' => 'secondary'],
'amount' => ['ar' => 'المبلغ', 'role' => 'value'],
'balance_after' => ['ar' => 'الرصيد بعد الحركة', 'role' => 'secondary'],
'description_ar' => ['ar' => 'البيان', 'role' => 'label'],
'created_at' => ['ar' => 'وقت الحركة', 'role' => 'secondary'],
],
'finance_my_open_session' => [
'treasury_name' => ['ar' => 'الخزنة', 'role' => 'label'],
'hours_open' => ['ar' => 'ساعات الفتح', 'role' => 'primary'],
'open_session_count' => ['ar' => 'ورديات مفتوحة', 'role' => 'secondary'],
'id' => ['ar' => 'معرّف الوردية', 'role' => 'hidden'],
'session_number' => ['ar' => 'رقم الوردية', 'role' => 'label'],
'opened_at' => ['ar' => 'وقت الفتح', 'role' => 'secondary'],
'opening_balance' => ['ar' => 'رصيد الافتتاح', 'role' => 'secondary'],
'total_collected' => ['ar' => 'إجمالي المحصّل', 'role' => 'secondary'],
'total_receipts' => ['ar' => 'عدد الإيصالات', 'role' => 'secondary'],
],
'finance_my_recent_receipts' => [
'member_name' => ['ar' => 'اسم العضو', 'role' => 'label'],
'id' => ['ar' => 'معرّف الإيصال', 'role' => 'hidden'],
'receipt_number' => ['ar' => 'رقم الإيصال', 'role' => 'label'],
'amount' => ['ar' => 'المبلغ', 'role' => 'value'],
'receipt_type' => ['ar' => 'نوع الإيصال', 'role' => 'secondary'],
'issued_at' => ['ar' => 'تاريخ الإصدار', 'role' => 'secondary'],
],
'finance_open_sessions_all' => [
'treasury_name' => ['ar' => 'الخزنة', 'role' => 'label'],
'cashier_name' => ['ar' => 'أمين الخزنة', 'role' => 'label'],
'days_open' => ['ar' => 'أيام الفتح', 'role' => 'value'],
'id' => ['ar' => 'معرّف الوردية', 'role' => 'hidden'],
'session_number' => ['ar' => 'رقم الوردية', 'role' => 'label'],
'opened_at' => ['ar' => 'وقت الفتح', 'role' => 'secondary'],
'total_collected' => ['ar' => 'إجمالي المحصّل', 'role' => 'value'],
'total_receipts' => ['ar' => 'عدد الإيصالات', 'role' => 'secondary'],
],
'finance_open_sessions_count' => [
'open_sessions' => ['ar' => 'ورديات مفتوحة', 'role' => 'primary'],
'cashier_count' => ['ar' => 'أمناء الخزنة', 'role' => 'secondary'],
'uncleared_amount' => ['ar' => 'مبالغ لم تُسلَّم', 'role' => 'secondary'],
'max_days_open' => ['ar' => 'أيام أقدم وردية', 'role' => 'secondary'],
],
'finance_overdue_installments' => [
'overdue_count' => ['ar' => 'أقساط متأخرة', 'role' => 'primary'],
'member_count' => ['ar' => 'عدد الأعضاء', 'role' => 'secondary'],
'overdue_amount' => ['ar' => 'المبلغ المتأخر', 'role' => 'secondary'],
'max_days_late' => ['ar' => 'أطول تأخير', 'role' => 'secondary'],
],
'finance_payment_integrity' => [
'voided_payments_30d' => ['ar' => 'مدفوعات ملغاة', 'role' => 'primary'],
'voided_amount_30d' => ['ar' => 'قيمة الملغى', 'role' => 'secondary'],
'voided_receipts_30d' => ['ar' => 'إيصالات ملغاة', 'role' => 'secondary'],
'non_positive_payments' => ['ar' => 'مدفوعات غير موجبة', 'role' => 'secondary'],
'cash_without_session_90d' => ['ar' => 'نقدية بدون وردية', 'role' => 'secondary'],
],
'finance_payments_missing_journal' => [
'missing_count' => ['ar' => 'مدفوعات بدون قيد', 'role' => 'primary'],
'missing_amount' => ['ar' => 'المبلغ بدون قيد', 'role' => 'secondary'],
'oldest_date' => ['ar' => 'أقدم تاريخ', 'role' => 'secondary'],
],
'finance_permission_changes' => [
'entity_label' => ['ar' => 'العنصر المتأثر', 'role' => 'label'],
'id' => ['ar' => 'معرّف السجل', 'role' => 'hidden'],
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'action' => ['ar' => 'الإجراء', 'role' => 'secondary'],
'entity_type' => ['ar' => 'نوع العنصر', 'role' => 'secondary'],
'ip_address' => ['ar' => 'عنوان الشبكة', 'role' => 'secondary'],
'created_at' => ['ar' => 'وقت التغيير', 'role' => 'secondary'],
],
'finance_settlements_inbox' => [
'from_treasury' => ['ar' => 'الخزنة المرسلة', 'role' => 'label'],
'settled_by_name' => ['ar' => 'منفذ التسوية', 'role' => 'label'],
'hours_waiting' => ['ar' => 'ساعات الانتظار', 'role' => 'value'],
],
'finance_settlements_pending' => [
'pending_count' => ['ar' => 'تسويات منتظرة', 'role' => 'primary'],
'pending_amount' => ['ar' => 'قيمة التسويات', 'role' => 'secondary'],
'receipt_count' => ['ar' => 'عدد الإيصالات', 'role' => 'secondary'],
'oldest_hours' => ['ar' => 'ساعات أقدم تسوية', 'role' => 'secondary'],
],
'finance_sports_queue' => [
'pending_count' => ['ar' => 'طلبات منتظرة', 'role' => 'primary'],
'pending_amount' => ['ar' => 'قيمة الطلبات', 'role' => 'secondary'],
'aged_count' => ['ar' => 'طلبات متأخرة', 'role' => 'secondary'],
'oldest_hours' => ['ar' => 'ساعات أقدم طلب', 'role' => 'secondary'],
],
'finance_top_accounts_month' => [
'debit_total' => ['ar' => 'إجمالي المدين', 'role' => 'value'],
'credit_total' => ['ar' => 'إجمالي الدائن', 'role' => 'value'],
'line_count' => ['ar' => 'عدد الحركات', 'role' => 'secondary'],
'account_code' => ['ar' => 'رمز الحساب', 'role' => 'secondary'],
'name_ar' => ['ar' => 'اسم الحساب', 'role' => 'label'],
'account_type' => ['ar' => 'نوع الحساب', 'role' => 'secondary'],
],
'finance_trial_balance_health' => [
'total_debit' => ['ar' => 'إجمالي المدين', 'role' => 'secondary'],
'total_credit' => ['ar' => 'إجمالي الدائن', 'role' => 'secondary'],
'difference' => ['ar' => 'الفرق', 'role' => 'primary'],
'entry_count' => ['ar' => 'عدد القيود', 'role' => 'secondary'],
],
'finance_unclosed_periods' => [
'fiscal_year_id' => ['ar' => 'السنة المالية', 'role' => 'link'],
'months_elapsed' => ['ar' => 'أشهر مضت', 'role' => 'secondary'],
'months_closed' => ['ar' => 'أشهر مقفلة', 'role' => 'secondary'],
'last_closed_period' => ['ar' => 'آخر فترة مقفلة', 'role' => 'secondary'],
'open_entries' => ['ar' => 'قيود غير مقفلة', 'role' => 'primary'],
'name_ar' => ['ar' => 'اسم السنة المالية', 'role' => 'label'],
],
'finance_undeposited_cash' => [
'max_id' => ['ar' => 'معرّف الحركة', 'role' => 'hidden'],
'cash_on_hand' => ['ar' => 'النقدية بالعهدة', 'role' => 'primary'],
'deposits_awaiting' => ['ar' => 'إيداعات منتظرة', 'role' => 'secondary'],
'deposits_amount' => ['ar' => 'قيمة الإيداعات', 'role' => 'secondary'],
'last_confirmed_deposit' => ['ar' => 'آخر إيداع مؤكد', 'role' => 'secondary'],
],
'hr_approvals_inbox' => [
'kind' => ['ar' => 'تصنيف الطلب', 'role' => 'hidden'],
'kind_ar' => ['ar' => 'نوع الطلب', 'role' => 'label'],
'ref_id' => ['ar' => 'رقم الطلب', 'role' => 'link'],
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'department_name' => ['ar' => 'الإدارة', 'role' => 'secondary'],
'detail' => ['ar' => 'تفاصيل الطلب', 'role' => 'value'],
'requested_at' => ['ar' => 'تاريخ الطلب', 'role' => 'value'],
'url' => ['ar' => 'رابط الطلب', 'role' => 'link'],
],
'hr_attendance_exceptions_today' => [
'profile_id' => ['ar' => 'رقم الموظف', 'role' => 'link'],
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'department_name' => ['ar' => 'الإدارة', 'role' => 'secondary'],
],
'hr_attendance_today' => [
'roster' => ['ar' => 'إجمالي الموظفين', 'role' => 'secondary'],
'present' => ['ar' => 'الحاضرون', 'role' => 'primary'],
'absent' => ['ar' => 'الغياب', 'role' => 'secondary'],
'late' => ['ar' => 'متأخرون', 'role' => 'secondary'],
'still_in' => ['ar' => 'بالداخل الآن', 'role' => 'secondary'],
'unrecorded_scheduled' => ['ar' => 'مجدول بلا تسجيل', 'role' => 'secondary'],
'unrecorded_no_schedule' => ['ar' => 'غير مجدولين', 'role' => 'secondary'],
'on_leave' => ['ar' => 'في إجازة', 'role' => 'secondary'],
'awaiting_approval' => ['ar' => 'بانتظار الاعتماد', 'role' => 'secondary'],
],
'hr_attendance_unrecorded_list' => [
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'department_name' => ['ar' => 'الإدارة', 'role' => 'secondary'],
'shift_start' => ['ar' => 'بداية الوردية', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'employee_number' => ['ar' => 'الرقم الوظيفي', 'role' => 'secondary'],
],
'hr_contract_coverage' => [
'without_contract' => ['ar' => 'بدون عقد', 'role' => 'primary'],
'expiring_60d' => ['ar' => 'تنتهي خلال شهرين', 'role' => 'secondary'],
'lapsed' => ['ar' => 'عقود منتهية', 'role' => 'secondary'],
'unsigned_contracts' => ['ar' => 'عقود غير موقعة', 'role' => 'secondary'],
],
'hr_dept_approvals_inbox' => [
'kind' => ['ar' => 'تصنيف الطلب', 'role' => 'hidden'],
'kind_ar' => ['ar' => 'نوع الطلب', 'role' => 'label'],
'ref_id' => ['ar' => 'رقم الطلب', 'role' => 'link'],
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'detail' => ['ar' => 'تفاصيل الطلب', 'role' => 'value'],
'requested_at' => ['ar' => 'تاريخ الطلب', 'role' => 'value'],
'url' => ['ar' => 'رابط الطلب', 'role' => 'link'],
],
'hr_dept_review_progress' => [
'days_to_deadline' => ['ar' => 'أيام حتى الموعد', 'role' => 'secondary'],
'reviews_total' => ['ar' => 'إجمالي التقييمات', 'role' => 'value'],
'reviews_done' => ['ar' => 'تقييمات مكتملة', 'role' => 'value'],
'reviews_pending' => ['ar' => 'تقييمات معلقة', 'role' => 'secondary'],
],
'hr_dept_team_roster' => [
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'job_title' => ['ar' => 'الوظيفة', 'role' => 'secondary'],
'shift_start' => ['ar' => 'بداية الوردية', 'role' => 'value'],
'state' => ['ar' => 'الحالة', 'role' => 'value'],
'on_leave' => ['ar' => 'في إجازة', 'role' => 'hidden'],
],
'hr_dept_team_today' => [
'team_size' => ['ar' => 'عدد الفريق', 'role' => 'secondary'],
'present' => ['ar' => 'الحاضرون', 'role' => 'primary'],
'late' => ['ar' => 'متأخرون', 'role' => 'secondary'],
'absent' => ['ar' => 'الغياب', 'role' => 'secondary'],
'on_leave' => ['ar' => 'في إجازة', 'role' => 'secondary'],
'unrecorded' => ['ar' => 'بلا تسجيل', 'role' => 'secondary'],
'awaiting_approval' => ['ar' => 'بانتظار الاعتماد', 'role' => 'secondary'],
],
'hr_employee_file_gaps' => [
'active_employees' => ['ar' => 'الموظفون النشطون', 'role' => 'primary'],
'missing_department' => ['ar' => 'بدون إدارة', 'role' => 'secondary'],
'missing_job_title' => ['ar' => 'بدون وظيفة', 'role' => 'secondary'],
'missing_national_id' => ['ar' => 'بدون رقم قومي', 'role' => 'secondary'],
'missing_bank_account' => ['ar' => 'بدون حساب بنكي', 'role' => 'secondary'],
'missing_phone' => ['ar' => 'بدون هاتف', 'role' => 'secondary'],
'missing_schedule' => ['ar' => 'بدون جدول', 'role' => 'secondary'],
'missing_documents' => ['ar' => 'بدون مستندات', 'role' => 'secondary'],
],
'hr_headcount_by_dept' => [
'headcount' => ['ar' => 'عدد الموظفين', 'role' => 'value'],
'on_probation' => ['ar' => 'تحت الاختبار', 'role' => 'value'],
'hired_90d' => ['ar' => 'تعيينات حديثة', 'role' => 'value'],
'leaving' => ['ar' => 'مغادرون', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'name_ar' => ['ar' => 'الإدارة', 'role' => 'label'],
'staffing_capacity' => ['ar' => 'السعة الوظيفية', 'role' => 'value'],
],
'hr_insurance_gaps' => [
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'department_name' => ['ar' => 'الإدارة', 'role' => 'secondary'],
'days_since_hire' => ['ar' => 'أيام منذ التعيين', 'role' => 'value'],
'gap_type' => ['ar' => 'نوع النقص', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'employee_number' => ['ar' => 'الرقم الوظيفي', 'role' => 'secondary'],
'hire_date' => ['ar' => 'تاريخ التعيين', 'role' => 'secondary'],
'insurable_salary' => ['ar' => 'الأجر التأميني', 'role' => 'secondary'],
],
'hr_new_hires_onboarding' => [
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'department_name' => ['ar' => 'الإدارة', 'role' => 'secondary'],
'days_since_hire' => ['ar' => 'أيام منذ التعيين', 'role' => 'value'],
'no_contract' => ['ar' => 'بدون عقد', 'role' => 'value'],
'no_schedule' => ['ar' => 'بدون جدول', 'role' => 'value'],
'no_insurance' => ['ar' => 'بدون تأمين', 'role' => 'value'],
'no_documents' => ['ar' => 'بدون مستندات', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'employee_number' => ['ar' => 'الرقم الوظيفي', 'role' => 'secondary'],
'hire_date' => ['ar' => 'تاريخ التعيين', 'role' => 'secondary'],
],
'hr_open_cases' => [
'kind' => ['ar' => 'تصنيف الملف', 'role' => 'hidden'],
'kind_ar' => ['ar' => 'نوع الملف', 'role' => 'label'],
'ref_id' => ['ar' => 'رقم الملف', 'role' => 'link'],
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'state' => ['ar' => 'الحالة', 'role' => 'value'],
'deadline' => ['ar' => 'الموعد النهائي', 'role' => 'value'],
'days_left' => ['ar' => 'الأيام المتبقية', 'role' => 'value'],
'url' => ['ar' => 'رابط الملف', 'role' => 'link'],
],
'hr_payroll_period_status' => [
'days_since_period_end' => ['ar' => 'منذ نهاية الفترة', 'role' => 'secondary'],
'runs_total' => ['ar' => 'إجمالي الكشوف', 'role' => 'value'],
'runs_paid' => ['ar' => 'كشوف مصروفة', 'role' => 'value'],
'active_employees' => ['ar' => 'الموظفون النشطون', 'role' => 'secondary'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'period_code' => ['ar' => 'رمز الفترة', 'role' => 'label'],
'year' => ['ar' => 'السنة', 'role' => 'secondary'],
'month' => ['ar' => 'الشهر', 'role' => 'secondary'],
'status' => ['ar' => 'الحالة', 'role' => 'secondary'],
'is_locked' => ['ar' => 'مقفلة', 'role' => 'secondary'],
'total_gross' => ['ar' => 'إجمالي الرواتب', 'role' => 'value'],
'total_net' => ['ar' => 'صافي الرواتب', 'role' => 'value'],
'paid_date' => ['ar' => 'تاريخ الصرف', 'role' => 'secondary'],
],
'hr_performance_cycle_status' => [
'days_to_deadline' => ['ar' => 'أيام حتى الموعد', 'role' => 'secondary'],
'reviews_total' => ['ar' => 'إجمالي التقييمات', 'role' => 'value'],
'reviews_done' => ['ar' => 'تقييمات مكتملة', 'role' => 'value'],
'reviews_pending' => ['ar' => 'تقييمات معلقة', 'role' => 'secondary'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'cycle_code' => ['ar' => 'رمز الدورة', 'role' => 'secondary'],
'name_ar' => ['ar' => 'الدورة', 'role' => 'label'],
'status' => ['ar' => 'الحالة', 'role' => 'secondary'],
'review_deadline' => ['ar' => 'الموعد النهائي', 'role' => 'secondary'],
],
'hr_probation_overdue' => [
'employee_name' => ['ar' => 'الموظف', 'role' => 'label'],
'department_name' => ['ar' => 'الإدارة', 'role' => 'secondary'],
'probation_ends' => ['ar' => 'نهاية فترة الاختبار', 'role' => 'value'],
'days_left' => ['ar' => 'الأيام المتبقية', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'employee_number' => ['ar' => 'الرقم الوظيفي', 'role' => 'secondary'],
'hire_date' => ['ar' => 'تاريخ التعيين', 'role' => 'secondary'],
],
'membership_active_headcount' => [
'total_active' => ['ar' => 'الأعضاء النشطون', 'role' => 'primary'],
'new_this_month' => ['ar' => 'جدد هذا الشهر', 'role' => 'secondary'],
'new_last_month' => ['ar' => 'جدد الشهر الماضي', 'role' => 'secondary'],
],
'membership_by_branch_category' => [
'branch' => ['ar' => 'الفرع', 'role' => 'label'],
'working' => ['ar' => 'أعضاء عاملون', 'role' => 'value'],
'other' => ['ar' => 'فئات أخرى', 'role' => 'value'],
'total' => ['ar' => 'إجمالي الأعضاء', 'role' => 'secondary'],
],
'membership_carnet_print_queue' => [
'members_without_carnet' => ['ar' => 'أعضاء بلا كارنيه', 'role' => 'primary'],
'activated_this_month' => ['ar' => 'مفعّلون هذا الشهر', 'role' => 'secondary'],
],
'membership_category_mix' => [
'c' => ['ar' => 'عدد الأعضاء', 'role' => 'value'],
'member_category' => ['ar' => 'فئة العضوية', 'role' => 'label'],
],
'membership_children_age_limit' => [
'total' => ['ar' => 'إجمالي الأبناء', 'role' => 'primary'],
'already_over_25' => ['ar' => 'تجاوزوا 25 سنة', 'role' => 'secondary'],
'within_12_months' => ['ar' => 'خلال 12 شهرًا', 'role' => 'secondary'],
],
'membership_children_age_limit_queue' => [
'child_id' => ['ar' => 'رقم الابن', 'role' => 'link'],
'child_name' => ['ar' => 'اسم الابن', 'role' => 'label'],
'member_id' => ['ar' => 'رقم العضو', 'role' => 'link'],
'member_name' => ['ar' => 'اسم العضو', 'role' => 'label'],
'turns_25_on' => ['ar' => 'تاريخ بلوغ 25', 'role' => 'secondary'],
'days_left' => ['ar' => 'الأيام المتبقية', 'role' => 'value'],
'url' => ['ar' => 'رابط الملف', 'role' => 'link'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'date_of_birth' => ['ar' => 'تاريخ الميلاد', 'role' => 'secondary'],
],
'membership_collection_by_branch' => [
'branch' => ['ar' => 'الفرع', 'role' => 'label'],
'rows_billed' => ['ar' => 'عدد الاشتراكات', 'role' => 'secondary'],
'billed' => ['ar' => 'إجمالي المستحق', 'role' => 'value'],
'collected' => ['ar' => 'المُحصَّل', 'role' => 'value'],
'outstanding' => ['ar' => 'المتبقي', 'role' => 'value'],
],
'membership_dependents_activation_queue' => [
'kind' => ['ar' => 'نوع التابع', 'role' => 'label'],
'dependent_id' => ['ar' => 'رقم التابع', 'role' => 'link'],
'person' => ['ar' => 'اسم التابع', 'role' => 'label'],
'member_id' => ['ar' => 'رقم العضو', 'role' => 'link'],
'member_name' => ['ar' => 'اسم العضو', 'role' => 'label'],
'fee' => ['ar' => 'رسوم الإضافة', 'role' => 'value'],
'days_waiting' => ['ar' => 'أيام الانتظار', 'role' => 'value'],
'url' => ['ar' => 'رابط العضو', 'role' => 'link'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'phone_mobile' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
],
'membership_dependents_awaiting_activation' => [
'total' => ['ar' => 'إجمالي التابعين', 'role' => 'primary'],
'spouses' => ['ar' => 'الأزواج', 'role' => 'secondary'],
'children' => ['ar' => 'الأبناء', 'role' => 'secondary'],
'temps' => ['ar' => 'أعضاء مؤقتون', 'role' => 'secondary'],
'blocked_fees' => ['ar' => 'رسوم معلقة', 'role' => 'secondary'],
'k' => ['ar' => 'نوع التابع', 'role' => 'hidden'],
'c' => ['ar' => 'العدد', 'role' => 'hidden'],
'v' => ['ar' => 'قيمة الرسوم', 'role' => 'hidden'],
],
'membership_growth_trend' => [
'd' => ['ar' => 'تاريخ الشهر', 'role' => 'hidden'],
'ym' => ['ar' => 'الشهر', 'role' => 'label'],
'joined' => ['ar' => 'أعضاء جدد', 'role' => 'value'],
],
'membership_interview_queue' => [
'reason' => ['ar' => 'سبب الانتظار', 'role' => 'label'],
'member_id' => ['ar' => 'رقم العضو', 'role' => 'link'],
'ref_date' => ['ar' => 'التاريخ', 'role' => 'secondary'],
'days_waiting' => ['ar' => 'أيام الانتظار', 'role' => 'value'],
'url' => ['ar' => 'رابط الإجراء', 'role' => 'link'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم العضو', 'role' => 'label'],
'phone_mobile' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
],
'membership_new_members_missing_docs' => [
'recent_without_docs' => ['ar' => 'بلا مستندات', 'role' => 'primary'],
'joined_this_month' => ['ar' => 'انضموا هذا الشهر', 'role' => 'secondary'],
],
'membership_pipeline_stalled' => [
'in_pipeline' => ['ar' => 'ملفات قيد الإجراء', 'role' => 'primary'],
'stalled_14d' => ['ar' => 'معلق 14 يومًا', 'role' => 'secondary'],
'awaiting_payment' => ['ar' => 'بانتظار السداد', 'role' => 'secondary'],
'under_review' => ['ar' => 'تحت المراجعة', 'role' => 'secondary'],
'potential' => ['ar' => 'أعضاء محتملون', 'role' => 'secondary'],
],
'membership_profile_gap_queue' => [
'gaps' => ['ar' => 'عدد النواقص', 'role' => 'value'],
'no_photo' => ['ar' => 'بلا صورة', 'role' => 'secondary'],
'no_national_id' => ['ar' => 'بلا رقم قومي', 'role' => 'secondary'],
'no_qualification' => ['ar' => 'بلا مؤهل', 'role' => 'secondary'],
'no_form_number' => ['ar' => 'بلا رقم استمارة', 'role' => 'secondary'],
'url' => ['ar' => 'رابط التعديل', 'role' => 'link'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم العضو', 'role' => 'label'],
'phone_mobile' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
],
'membership_profile_gaps' => [
'members_with_gaps' => ['ar' => 'ملفات ناقصة', 'role' => 'primary'],
'no_photo' => ['ar' => 'بلا صورة', 'role' => 'secondary'],
'no_national_id' => ['ar' => 'بلا رقم قومي', 'role' => 'secondary'],
'no_qualification' => ['ar' => 'بلا مؤهل', 'role' => 'secondary'],
'no_form_number' => ['ar' => 'بلا رقم استمارة', 'role' => 'secondary'],
'no_id' => ['ar' => 'بدون رقم قومي', 'role' => 'hidden'],
'no_qual' => ['ar' => 'بدون مؤهل', 'role' => 'hidden'],
'no_form' => ['ar' => 'بدون رقم استمارة', 'role' => 'hidden'],
],
'membership_sales_funnel' => [
'potential' => ['ar' => 'أعضاء محتملون', 'role' => 'value'],
'under_review' => ['ar' => 'تحت المراجعة', 'role' => 'value'],
'payment_pending' => ['ar' => 'بانتظار السداد', 'role' => 'value'],
'pending_cheques' => ['ar' => 'شيكات معلقة', 'role' => 'value'],
'activated_this_month' => ['ar' => 'مفعّلون هذا الشهر', 'role' => 'value'],
],
'membership_sales_own_signups' => [
'mine_this_month' => ['ar' => 'تسجيلاتي هذا الشهر', 'role' => 'primary'],
'mine_last_month' => ['ar' => 'الشهر الماضي', 'role' => 'secondary'],
'mine_value_this_month' => ['ar' => 'قيمة تسجيلاتي', 'role' => 'secondary'],
],
'membership_sales_pipeline_aging' => [
'branch' => ['ar' => 'الفرع', 'role' => 'label'],
'days_in_stage' => ['ar' => 'أيام في المرحلة', 'role' => 'value'],
'url' => ['ar' => 'رابط العضو', 'role' => 'link'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم المرشح', 'role' => 'label'],
'phone_mobile' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
'status' => ['ar' => 'الحالة', 'role' => 'secondary'],
'membership_value' => ['ar' => 'قيمة العضوية', 'role' => 'secondary'],
],
'membership_sales_pipeline_value' => [
'deals' => ['ar' => 'عدد الصفقات', 'role' => 'secondary'],
'pipeline_value' => ['ar' => 'قيمة الصفقات', 'role' => 'primary'],
'stale_30d' => ['ar' => 'راكد 30 يومًا', 'role' => 'secondary'],
],
'membership_sales_seasonal_pending' => [
'pending_count' => ['ar' => 'عضويات غير مسددة', 'role' => 'primary'],
'pending_value' => ['ar' => 'القيمة المستحقة', 'role' => 'secondary'],
'already_started' => ['ar' => 'بدأت بالفعل', 'role' => 'secondary'],
],
'membership_sales_seasonal_queue' => [
'member_id' => ['ar' => 'رقم العضو', 'role' => 'link'],
'member_name' => ['ar' => 'اسم العضو', 'role' => 'label'],
'days_waiting' => ['ar' => 'أيام الانتظار', 'role' => 'value'],
'url' => ['ar' => 'رابط العضو', 'role' => 'link'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'person_name' => ['ar' => 'اسم المشترك', 'role' => 'label'],
'person_type' => ['ar' => 'نوع المشترك', 'role' => 'label'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'phone_mobile' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
'duration_months' => ['ar' => 'مدة الاشتراك', 'role' => 'secondary'],
'start_date' => ['ar' => 'تاريخ البدء', 'role' => 'secondary'],
'end_date' => ['ar' => 'تاريخ الانتهاء', 'role' => 'secondary'],
'total_amount' => ['ar' => 'إجمالي المبلغ', 'role' => 'value'],
],
'membership_separation_queue' => [
'case_type' => ['ar' => 'نوع الحالة', 'role' => 'label'],
'case_id' => ['ar' => 'رقم الحالة', 'role' => 'link'],
'member_id' => ['ar' => 'رقم العضو', 'role' => 'link'],
'days_open' => ['ar' => 'مدة الفتح', 'role' => 'value'],
'url' => ['ar' => 'رابط الحالة', 'role' => 'link'],
'status' => ['ar' => 'حالة الملف', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم العضو', 'role' => 'label'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
],
'membership_separations_backlog' => [
'open_cases' => ['ar' => 'حالات مفتوحة', 'role' => 'primary'],
'aged_30d' => ['ar' => 'متأخرة 30 يومًا', 'role' => 'secondary'],
'waivers' => ['ar' => 'تنازلات', 'role' => 'secondary'],
'deaths' => ['ar' => 'وفيات', 'role' => 'secondary'],
'divorces' => ['ar' => 'حالات طلاق', 'role' => 'secondary'],
'transfers' => ['ar' => 'نقل عضوية', 'role' => 'secondary'],
'case_type' => ['ar' => 'نوع الحالة', 'role' => 'hidden'],
'days_open' => ['ar' => 'مدة الفتح', 'role' => 'hidden'],
],
'membership_subscription_collection' => [
'billed_rows' => ['ar' => 'عدد الاشتراكات', 'role' => 'secondary'],
'billed' => ['ar' => 'إجمالي المستحق', 'role' => 'value'],
'collected' => ['ar' => 'المُحصَّل', 'role' => 'value'],
'outstanding' => ['ar' => 'المتبقي', 'role' => 'value'],
'overdue_rows' => ['ar' => 'اشتراكات متأخرة', 'role' => 'secondary'],
'pending_rows' => ['ar' => 'اشتراكات معلقة', 'role' => 'secondary'],
],
'operations_carnet_register' => [
'signed' => ['ar' => 'قيمة داخلية', 'role' => 'hidden'],
'invitations_left' => ['ar' => 'دعوات متبقية', 'role' => 'value'],
'member_name' => ['ar' => 'اسم العضو', 'role' => 'label'],
'member_status' => ['ar' => 'حالة العضوية', 'role' => 'secondary'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'carnet_number' => ['ar' => 'رقم الكارنيه', 'role' => 'label'],
'carnet_type' => ['ar' => 'نوع الكارنيه', 'role' => 'secondary'],
'card_variant' => ['ar' => 'فئة البطاقة', 'role' => 'secondary'],
'is_active' => ['ar' => 'حالة الكارنيه', 'role' => 'secondary'],
'dependent_type' => ['ar' => 'صفة التابع', 'role' => 'secondary'],
'total_invitations' => ['ar' => 'إجمالي الدعوات', 'role' => 'secondary'],
'used_invitations' => ['ar' => 'دعوات مستخدمة', 'role' => 'secondary'],
'deactivated_reason' => ['ar' => 'سبب الإيقاف', 'role' => 'secondary'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
],
'operations_carnets_blocked' => [
'blocked_carnets' => ['ar' => 'كارنيهات موقوفة', 'role' => 'primary'],
'live_carnets' => ['ar' => 'كارنيهات سارية', 'role' => 'secondary'],
'signed' => ['ar' => 'قيمة داخلية', 'role' => 'hidden'],
'invitations_remaining' => ['ar' => 'دعوات متبقية', 'role' => 'secondary'],
],
'operations_guest_cash_today' => [
'entries_today' => ['ar' => 'تسجيلات اليوم', 'role' => 'secondary'],
'heads_today' => ['ar' => 'عدد الضيوف', 'role' => 'secondary'],
'cash_today' => ['ar' => 'تحصيل اليوم', 'role' => 'primary'],
'still_inside' => ['ar' => 'بالداخل الآن', 'role' => 'secondary'],
],
'operations_guest_register_today' => [
'time_in' => ['ar' => 'وقت الدخول', 'role' => 'value'],
'time_out' => ['ar' => 'وقت الخروج', 'role' => 'secondary'],
'host_member' => ['ar' => 'العضو المضيف', 'role' => 'label'],
'facility_name' => ['ar' => 'المنشأة', 'role' => 'secondary'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'guest_name' => ['ar' => 'اسم الضيف', 'role' => 'label'],
'guest_count' => ['ar' => 'عدد الضيوف', 'role' => 'secondary'],
'guest_phone' => ['ar' => 'هاتف الضيف', 'role' => 'secondary'],
'entry_date' => ['ar' => 'تاريخ الدخول', 'role' => 'secondary'],
'activity_type' => ['ar' => 'نوع النشاط', 'role' => 'secondary'],
'amount_paid' => ['ar' => 'المبلغ المدفوع', 'role' => 'secondary'],
'status' => ['ar' => 'حالة الزيارة', 'role' => 'secondary'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
],
'operations_guests_inside_now' => [
'entries_inside' => ['ar' => 'تسجيلات مفتوحة', 'role' => 'secondary'],
'heads_inside' => ['ar' => 'ضيوف بالداخل', 'role' => 'primary'],
'oldest_entry_date' => ['ar' => 'أقدم دخول', 'role' => 'secondary'],
'stale_entries' => ['ar' => 'تسجيلات متأخرة', 'role' => 'secondary'],
],
'operations_it_account_health' => [
'never_logged_in' => ['ar' => 'لم يسجل دخول', 'role' => 'primary'],
'locked_now' => ['ar' => 'حسابات مقفلة', 'role' => 'secondary'],
'disabled' => ['ar' => 'حسابات موقوفة', 'role' => 'secondary'],
'without_role' => ['ar' => 'بدون دور', 'role' => 'secondary'],
'must_change_password' => ['ar' => 'تغيير كلمة المرور', 'role' => 'secondary'],
],
'operations_it_alert_rules_silent' => [
'fire_count' => ['ar' => 'مرات التفعيل', 'role' => 'value'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'code' => ['ar' => 'كود القاعدة', 'role' => 'secondary'],
'name_ar' => ['ar' => 'اسم القاعدة', 'role' => 'label'],
'category' => ['ar' => 'التصنيف', 'role' => 'secondary'],
'trigger_type' => ['ar' => 'نوع التفعيل', 'role' => 'secondary'],
'notification_channels' => ['ar' => 'قنوات التنبيه', 'role' => 'secondary'],
'last_triggered_at' => ['ar' => 'آخر تفعيل', 'role' => 'secondary'],
],
'operations_it_audit_today' => [
'events_today' => ['ar' => 'أحداث اليوم', 'role' => 'primary'],
'actors_today' => ['ar' => 'موظفون نشطون', 'role' => 'secondary'],
'deletes_today' => ['ar' => 'عمليات حذف', 'role' => 'secondary'],
'creates_today' => ['ar' => 'عمليات إضافة', 'role' => 'secondary'],
'updates_today' => ['ar' => 'عمليات تعديل', 'role' => 'secondary'],
],
'operations_it_audit_volume' => [
'day' => ['ar' => 'اليوم', 'role' => 'label'],
'events' => ['ar' => 'عدد الأحداث', 'role' => 'value'],
'deletes' => ['ar' => 'عمليات الحذف', 'role' => 'value'],
],
'operations_it_destructive_actions' => [
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'employee_name' => ['ar' => 'اسم الموظف', 'role' => 'label'],
'entity_type' => ['ar' => 'نوع السجل', 'role' => 'secondary'],
'entity_label' => ['ar' => 'السجل المحذوف', 'role' => 'secondary'],
'route' => ['ar' => 'مسار العملية', 'role' => 'secondary'],
'ip_address' => ['ar' => 'عنوان الشبكة', 'role' => 'secondary'],
'created_at' => ['ar' => 'وقت العملية', 'role' => 'secondary'],
],
'operations_it_login_failures' => [
'failure_reason' => ['ar' => 'سبب الفشل', 'role' => 'label'],
'attempts' => ['ar' => 'عدد المحاولات', 'role' => 'value'],
'accounts' => ['ar' => 'عدد الحسابات', 'role' => 'secondary'],
'source_ips' => ['ar' => 'عناوين المصدر', 'role' => 'secondary'],
'last_attempt' => ['ar' => 'آخر محاولة', 'role' => 'secondary'],
],
'operations_it_never_logged_in' => [
'days_since_created' => ['ar' => 'أيام منذ الإنشاء', 'role' => 'value'],
'roles' => ['ar' => 'الأدوار', 'role' => 'secondary'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'username' => ['ar' => 'اسم المستخدم', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم الموظف', 'role' => 'label'],
],
'operations_it_roles_unassigned' => [
'assigned_employees' => ['ar' => 'عدد الموظفين', 'role' => 'value'],
'permission_count' => ['ar' => 'عدد الصلاحيات', 'role' => 'secondary'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'role_code' => ['ar' => 'كود الدور', 'role' => 'secondary'],
'name_ar' => ['ar' => 'اسم الدور', 'role' => 'label'],
],
'operations_it_sessions_live' => [
'open_sessions' => ['ar' => 'جلسات مفتوحة', 'role' => 'primary'],
'distinct_employees' => ['ar' => 'عدد الموظفين', 'role' => 'secondary'],
'active_now' => ['ar' => 'نشط الآن', 'role' => 'secondary'],
'stale_sessions' => ['ar' => 'جلسات راكدة', 'role' => 'secondary'],
'oldest_session_start' => ['ar' => 'أقدم جلسة', 'role' => 'secondary'],
],
'operations_member_lookup' => [
'registered_on' => ['ar' => 'تاريخ التسجيل', 'role' => 'value'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم العضو', 'role' => 'label'],
'status' => ['ar' => 'حالة العضوية', 'role' => 'secondary'],
'phone_mobile' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
],
'operations_members_flagged_count' => [
'flagged_total' => ['ar' => 'عضويات غير سارية', 'role' => 'primary'],
'payment_pending' => ['ar' => 'بانتظار السداد', 'role' => 'secondary'],
'pending_cheques' => ['ar' => 'شيكات معلقة', 'role' => 'secondary'],
'under_review' => ['ar' => 'تحت المراجعة', 'role' => 'secondary'],
'potential' => ['ar' => 'عضوية محتملة', 'role' => 'secondary'],
],
'operations_members_not_in_good_standing' => [
'last_change' => ['ar' => 'آخر تحديث', 'role' => 'value'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'membership_number' => ['ar' => 'رقم العضوية', 'role' => 'secondary'],
'full_name_ar' => ['ar' => 'اسم العضو', 'role' => 'label'],
'status' => ['ar' => 'حالة العضوية', 'role' => 'secondary'],
'phone_mobile' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
],
'operations_report_audit_headline' => [
'events_this_month' => ['ar' => 'أحداث الشهر', 'role' => 'primary'],
'active_employees' => ['ar' => 'موظفون نشطون', 'role' => 'secondary'],
'deletes_this_month' => ['ar' => 'عمليات حذف', 'role' => 'secondary'],
'active_days' => ['ar' => 'أيام النشاط', 'role' => 'secondary'],
],
'operations_report_catalog' => [
'report_count' => ['ar' => 'عدد التقارير', 'role' => 'value'],
'report_codes' => ['ar' => 'أكواد التقارير', 'role' => 'link'],
'report_names' => ['ar' => 'أسماء التقارير', 'role' => 'label'],
'category' => ['ar' => 'تصنيف التقرير', 'role' => 'label'],
'required_permission' => ['ar' => 'الصلاحية المطلوبة', 'role' => 'secondary'],
],
'operations_report_financial_headline' => [
'revenue_this_month' => ['ar' => 'إيراد الشهر', 'role' => 'primary'],
'revenue_last_month' => ['ar' => 'إيراد الشهر الماضي', 'role' => 'secondary'],
'receipts_this_month' => ['ar' => 'عدد الإيصالات', 'role' => 'secondary'],
],
'operations_report_membership_headline' => [
'active_members' => ['ar' => 'أعضاء ساريون', 'role' => 'primary'],
'new_this_month' => ['ar' => 'أعضاء جدد', 'role' => 'secondary'],
'pipeline_members' => ['ar' => 'عضويات غير سارية', 'role' => 'secondary'],
'branches_covered' => ['ar' => 'عدد الفروع', 'role' => 'secondary'],
],
'operations_report_operations_headline' => [
'reservations_this_month' => ['ar' => 'حجوزات الشهر', 'role' => 'primary'],
'guest_heads_this_month' => ['ar' => 'ضيوف الشهر', 'role' => 'secondary'],
'carnets_issued_this_month' => ['ar' => 'كارنيهات صادرة', 'role' => 'secondary'],
'carnets_printed_this_month' => ['ar' => 'كارنيهات مطبوعة', 'role' => 'secondary'],
],
'operations_reservations_action_queue' => [
'start_time' => ['ar' => 'بداية الحجز', 'role' => 'value'],
'end_time' => ['ar' => 'نهاية الحجز', 'role' => 'secondary'],
'facility_name' => ['ar' => 'المنشأة', 'role' => 'secondary'],
'booker_name' => ['ar' => 'اسم الحاجز', 'role' => 'label'],
'unpaid' => ['ar' => 'غير مدفوع', 'role' => 'secondary'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'reservation_number' => ['ar' => 'رقم الحجز', 'role' => 'label'],
'reservation_date' => ['ar' => 'تاريخ الحجز', 'role' => 'secondary'],
'booker_type' => ['ar' => 'نوع الحاجز', 'role' => 'secondary'],
'booker_phone' => ['ar' => 'هاتف الحاجز', 'role' => 'secondary'],
'status' => ['ar' => 'حالة الحجز', 'role' => 'secondary'],
'total_amount' => ['ar' => 'إجمالي المبلغ', 'role' => 'secondary'],
],
'operations_reservations_pending_count' => [
'pending_total' => ['ar' => 'حجوزات معلقة', 'role' => 'primary'],
'pending_overdue' => ['ar' => 'حجوزات متأخرة', 'role' => 'secondary'],
'pending_today' => ['ar' => 'حجوزات اليوم', 'role' => 'secondary'],
'unpaid_value' => ['ar' => 'قيمة غير محصلة', 'role' => 'secondary'],
],
'operations_support_open' => [
'age_days' => ['ar' => 'عمر التذكرة', 'role' => 'value'],
'assignee' => ['ar' => 'الموظف المسؤول', 'role' => 'label'],
'id' => ['ar' => 'معرف داخلي', 'role' => 'hidden'],
'ticket_number' => ['ar' => 'رقم التذكرة', 'role' => 'label'],
'subject' => ['ar' => 'موضوع التذكرة', 'role' => 'secondary'],
'priority' => ['ar' => 'الأولوية', 'role' => 'secondary'],
'status' => ['ar' => 'حالة التذكرة', 'role' => 'secondary'],
'category' => ['ar' => 'تصنيف التذكرة', 'role' => 'secondary'],
],
'operations_support_open_count' => [
'open_total' => ['ar' => 'تذاكر مفتوحة', 'role' => 'primary'],
'awaiting_close' => ['ar' => 'بانتظار الإغلاق', 'role' => 'secondary'],
'oldest_days' => ['ar' => 'أقدم تذكرة', 'role' => 'secondary'],
'high_priority' => ['ar' => 'أولوية عالية', 'role' => 'secondary'],
],
'sports_academy_portfolio' => [
'academy' => ['ar' => 'الأكاديمية', 'role' => 'label'],
'groups_count' => ['ar' => 'عدد المجموعات', 'role' => 'value'],
'enrolled' => ['ar' => 'المسجلون', 'role' => 'value'],
'capacity' => ['ar' => 'السعة', 'role' => 'value'],
'outstanding' => ['ar' => 'المستحقات', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'academy_type' => ['ar' => 'نوع الأكاديمية', 'role' => 'secondary'],
],
'sports_arrears_by_academy' => [
'academy_id' => ['ar' => 'رقم الأكاديمية', 'role' => 'link'],
'label' => ['ar' => 'الأكاديمية', 'role' => 'label'],
'outstanding' => ['ar' => 'المتأخرات', 'role' => 'value'],
'collected' => ['ar' => 'المحصّل', 'role' => 'value'],
],
'sports_attendance_rate' => [
'marked_total' => ['ar' => 'الحصص المرصودة', 'role' => 'secondary'],
'attended' => ['ar' => 'عدد الحاضرين', 'role' => 'secondary'],
'rate_pct' => ['ar' => 'نسبة الحضور', 'role' => 'value'],
],
'sports_booking_conflicts' => [
'facility' => ['ar' => 'المرفق', 'role' => 'label'],
'unit' => ['ar' => 'الوحدة', 'role' => 'label'],
'slot_start' => ['ar' => 'بداية الفترة', 'role' => 'label'],
'slot_end' => ['ar' => 'نهاية الفترة', 'role' => 'label'],
'booking_id' => ['ar' => 'رقم الحجز', 'role' => 'link'],
'spots_sold' => ['ar' => 'الأماكن المحجوزة', 'role' => 'value'],
'overlapping_bookings' => ['ar' => 'حجوزات متداخلة', 'role' => 'value'],
],
'sports_coach_utilization' => [
'coach' => ['ar' => 'المدرب', 'role' => 'label'],
'assigned_groups' => ['ar' => 'المجموعات المسندة', 'role' => 'value'],
'weekly_hours' => ['ar' => 'ساعات أسبوعية', 'role' => 'value'],
'load_pct' => ['ar' => 'نسبة التحميل', 'role' => 'value'],
'hrs' => ['ar' => 'الساعات', 'role' => 'hidden'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'max_groups' => ['ar' => 'الحد الأقصى للمجموعات', 'role' => 'secondary'],
],
'sports_conflicts_count' => [
'c' => ['ar' => 'تعارضات الحجز', 'role' => 'primary'],
],
'sports_contracts_expiring' => [
'academy' => ['ar' => 'الأكاديمية', 'role' => 'label'],
'days_lapsed' => ['ar' => 'أيام التأخر', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'contract_number' => ['ar' => 'رقم العقد', 'role' => 'secondary'],
'contract_type' => ['ar' => 'نوع العقد', 'role' => 'secondary'],
'end_date' => ['ar' => 'تاريخ الانتهاء', 'role' => 'secondary'],
'club_commission_pct' => ['ar' => 'نسبة العمولة', 'role' => 'secondary'],
'fixed_monthly_rent' => ['ar' => 'الإيجار الشهري', 'role' => 'secondary'],
'deposit_status' => ['ar' => 'حالة التأمين', 'role' => 'secondary'],
],
'sports_enrollment_by_discipline' => [
'discipline_id' => ['ar' => 'رقم اللعبة', 'role' => 'link'],
'label' => ['ar' => 'اللعبة', 'role' => 'label'],
'enrolled' => ['ar' => 'المسجلون', 'role' => 'value'],
'capacity' => ['ar' => 'السعة', 'role' => 'value'],
'groups_count' => ['ar' => 'عدد المجموعات', 'role' => 'value'],
'cnt' => ['ar' => 'عدد اللاعبين', 'role' => 'hidden'],
],
'sports_facility_load_today' => [
'facility_id' => ['ar' => 'رقم المرفق', 'role' => 'link'],
'label' => ['ar' => 'المرفق', 'role' => 'label'],
'booked_hours' => ['ar' => 'ساعات محجوزة', 'role' => 'value'],
'slots' => ['ar' => 'عدد الفترات', 'role' => 'value'],
'uid' => ['ar' => 'رقم الوحدة', 'role' => 'hidden'],
'hrs' => ['ar' => 'الساعات', 'role' => 'hidden'],
],
'sports_groups_below_min' => [
'group_name' => ['ar' => 'المجموعة', 'role' => 'label'],
'discipline' => ['ar' => 'اللعبة', 'role' => 'label'],
'enrolled' => ['ar' => 'المسجلون', 'role' => 'value'],
'short_by' => ['ar' => 'النقص', 'role' => 'value'],
'coach' => ['ar' => 'المدرب', 'role' => 'label'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'min_capacity' => ['ar' => 'الحد الأدنى', 'role' => 'secondary'],
'max_capacity' => ['ar' => 'الحد الأقصى', 'role' => 'secondary'],
],
'sports_groups_below_min_count' => [
'c' => ['ar' => 'مجموعات تحت الحد', 'role' => 'primary'],
],
'sports_groups_without_coach' => [
'c' => ['ar' => 'مجموعات بلا مدرب', 'role' => 'primary'],
],
'sports_idle_facility_units' => [
'unit' => ['ar' => 'الوحدة', 'role' => 'label'],
'facility' => ['ar' => 'المرفق', 'role' => 'label'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'unit_type' => ['ar' => 'نوع الوحدة', 'role' => 'secondary'],
'max_capacity' => ['ar' => 'السعة القصوى', 'role' => 'secondary'],
],
'sports_idle_units_count' => [
'c' => ['ar' => 'وحدات غير مستخدمة', 'role' => 'primary'],
],
'sports_locker_rentals_action' => [
'player' => ['ar' => 'اللاعب', 'role' => 'label'],
'locker' => ['ar' => 'كود اللوكر', 'role' => 'label'],
'locker_name' => ['ar' => 'اسم اللوكر', 'role' => 'label'],
'days_overdue' => ['ar' => 'أيام التأخر', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'rental_number' => ['ar' => 'رقم الإيجار', 'role' => 'secondary'],
'end_date' => ['ar' => 'تاريخ الانتهاء', 'role' => 'secondary'],
'payment_status' => ['ar' => 'حالة السداد', 'role' => 'secondary'],
'amount' => ['ar' => 'المبلغ', 'role' => 'value'],
],
'sports_medical_blocked_count' => [
'c' => ['ar' => 'لاعبون بشهادة منتهية', 'role' => 'primary'],
],
'sports_medical_blocked_players' => [
'days_overdue' => ['ar' => 'أيام التأخر', 'role' => 'value'],
'active_groups' => ['ar' => 'مجموعات نشطة', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'full_name_ar' => ['ar' => 'اللاعب', 'role' => 'label'],
'medical_status' => ['ar' => 'الحالة الطبية', 'role' => 'secondary'],
'medical_expiry_date' => ['ar' => 'انتهاء الفحص الطبي', 'role' => 'secondary'],
],
'sports_medical_docs_pending' => [
'pending_docs' => ['ar' => 'مستندات معلقة', 'role' => 'primary'],
'pending_records' => ['ar' => 'سجلات طبية معلقة', 'role' => 'secondary'],
],
'sports_medical_expiring_soon' => [
'c' => ['ar' => 'شهادات تنتهي قريباً', 'role' => 'primary'],
],
'sports_my_groups_roster' => [
'group_name' => ['ar' => 'المجموعة', 'role' => 'label'],
'discipline' => ['ar' => 'اللعبة', 'role' => 'label'],
'roster' => ['ar' => 'عدد اللاعبين', 'role' => 'value'],
'medical_blocked' => ['ar' => 'موقوفون طبياً', 'role' => 'value'],
'weekly_sessions' => ['ar' => 'حصص أسبوعية', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'min_capacity' => ['ar' => 'الحد الأدنى', 'role' => 'secondary'],
'max_capacity' => ['ar' => 'الحد الأقصى', 'role' => 'secondary'],
],
'sports_my_sessions_today' => [
'group_id' => ['ar' => 'رقم المجموعة', 'role' => 'link'],
'group_name' => ['ar' => 'المجموعة', 'role' => 'label'],
'start_time' => ['ar' => 'بداية الحصة', 'role' => 'label'],
'end_time' => ['ar' => 'نهاية الحصة', 'role' => 'label'],
'facility' => ['ar' => 'المرفق', 'role' => 'label'],
'unit' => ['ar' => 'الوحدة', 'role' => 'label'],
'roster' => ['ar' => 'عدد اللاعبين', 'role' => 'value'],
'marked' => ['ar' => 'حضور مسجَّل', 'role' => 'value'],
],
'sports_pending_payment_enrollments' => [
'player_id' => ['ar' => 'رقم اللاعب', 'role' => 'link'],
'player' => ['ar' => 'اللاعب', 'role' => 'label'],
'group_id' => ['ar' => 'رقم المجموعة', 'role' => 'link'],
'group_name' => ['ar' => 'المجموعة', 'role' => 'label'],
'discipline' => ['ar' => 'اللعبة', 'role' => 'label'],
'days_waiting' => ['ar' => 'أيام الانتظار', 'role' => 'value'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'player_type' => ['ar' => 'نوع اللاعب', 'role' => 'secondary'],
'enrolled_at' => ['ar' => 'تاريخ التسجيل', 'role' => 'secondary'],
'monthly_fee_member' => ['ar' => 'رسوم الأعضاء', 'role' => 'secondary'],
'monthly_fee_nonmember' => ['ar' => 'رسوم غير الأعضاء', 'role' => 'secondary'],
],
'sports_pending_reservations' => [
'days_stale' => ['ar' => 'أيام الانتظار', 'role' => 'value'],
'start_time' => ['ar' => 'بداية الحجز', 'role' => 'label'],
'end_time' => ['ar' => 'نهاية الحجز', 'role' => 'label'],
'facility' => ['ar' => 'المرفق', 'role' => 'label'],
'booker' => ['ar' => 'صاحب الحجز', 'role' => 'label'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'reservation_number' => ['ar' => 'رقم الحجز', 'role' => 'secondary'],
'reservation_date' => ['ar' => 'تاريخ الحجز', 'role' => 'secondary'],
'booker_phone' => ['ar' => 'رقم الهاتف', 'role' => 'secondary'],
'total_amount' => ['ar' => 'الإجمالي', 'role' => 'value'],
],
'sports_pending_reservations_count' => [
'c' => ['ar' => 'حجوزات معلقة', 'role' => 'primary'],
'amount' => ['ar' => 'قيمة الحجوزات', 'role' => 'secondary'],
],
'sports_sessions_today' => [
'group_id' => ['ar' => 'رقم المجموعة', 'role' => 'link'],
'group_name' => ['ar' => 'المجموعة', 'role' => 'label'],
'discipline' => ['ar' => 'اللعبة', 'role' => 'label'],
'start_time' => ['ar' => 'بداية الحصة', 'role' => 'label'],
'end_time' => ['ar' => 'نهاية الحصة', 'role' => 'label'],
'facility' => ['ar' => 'المرفق', 'role' => 'label'],
'unit' => ['ar' => 'الوحدة', 'role' => 'label'],
'coach' => ['ar' => 'المدرب', 'role' => 'label'],
'roster' => ['ar' => 'عدد اللاعبين', 'role' => 'value'],
'marked' => ['ar' => 'حضور مسجَّل', 'role' => 'value'],
],
'sports_subscription_arrears' => [
'overdue_count' => ['ar' => 'اشتراكات متأخرة', 'role' => 'primary'],
'overdue_amount' => ['ar' => 'قيمة المتأخرات', 'role' => 'secondary'],
'players_affected' => ['ar' => 'لاعبون متأثرون', 'role' => 'secondary'],
],
'sports_today_facility_slots' => [
'slots' => ['ar' => 'عدد الفترات', 'role' => 'primary'],
'hours' => ['ar' => 'الساعات', 'role' => 'secondary'],
'units_in_use' => ['ar' => 'وحدات مستخدمة', 'role' => 'secondary'],
'uid' => ['ar' => 'رقم الوحدة', 'role' => 'hidden'],
'hrs' => ['ar' => 'الساعات', 'role' => 'hidden'],
],
'sports_unmarked_sessions_today' => [
'total_sessions' => ['ar' => 'إجمالي الحصص', 'role' => 'secondary'],
'unmarked' => ['ar' => 'حصص بلا تسجيل', 'role' => 'primary'],
'marked' => ['ar' => 'حضور مسجَّل', 'role' => 'hidden'],
],
'sports_unpaid_bookings' => [
'unpaid_count' => ['ar' => 'حجوزات غير مسددة', 'role' => 'primary'],
'unpaid_amount' => ['ar' => 'المبلغ غير المسدد', 'role' => 'secondary'],
],
'sports_upcoming_bookings' => [
'start_time' => ['ar' => 'بداية الحجز', 'role' => 'label'],
'end_time' => ['ar' => 'نهاية الحجز', 'role' => 'label'],
'facility' => ['ar' => 'المرفق', 'role' => 'label'],
'unit' => ['ar' => 'الوحدة', 'role' => 'label'],
'booker' => ['ar' => 'صاحب الحجز', 'role' => 'label'],
'id' => ['ar' => 'المعرف', 'role' => 'hidden'],
'booking_number' => ['ar' => 'رقم الحجز', 'role' => 'secondary'],
'booking_date' => ['ar' => 'تاريخ الحجز', 'role' => 'secondary'],
'booking_type' => ['ar' => 'نوع الحجز', 'role' => 'secondary'],
'participant_count' => ['ar' => 'عدد المشاركين', 'role' => 'value'],
'payment_status' => ['ar' => 'حالة السداد', 'role' => 'secondary'],
'total_amount' => ['ar' => 'الإجمالي', 'role' => 'value'],
],
];
<?php
declare(strict_types=1);
/**
* Role -> dashboard layout presets.
* 'headline' renders as the top KPI strip; 'widgets' renders below, grouped by section.
* Every key is still permission-gated at render time, so a preset can never leak data.
*/
return [
'sports_coordinator' => [
'headline' => ['sports_unmarked_sessions_today', 'sports_subscription_arrears', 'sports_medical_blocked_count', 'sports_groups_without_coach', 'sports_conflicts_count'],
'widgets' => [
'sports_sessions_today',
'sports_medical_blocked_players',
'sports_medical_docs_pending',
'sports_medical_expiring_soon',
'sports_groups_below_min',
'sports_enrollment_by_discipline',
'sports_coach_utilization',
'sports_academy_portfolio',
'sports_arrears_by_academy',
'sports_contracts_expiring',
'sports_pending_payment_enrollments',
'sports_booking_conflicts',
'sports_pending_reservations',
'sports_pending_reservations_count',
'sports_facility_load_today',
'sports_unpaid_bookings',
'sports_attendance_rate',
],
],
'super_admin' => [
'headline' => ['executive_revenue_mtd', 'executive_cash_on_hand', 'executive_active_members', 'executive_receivables_total', 'executive_open_approvals'],
'widgets' => [
'executive_control_exceptions',
'executive_board_approval_queue',
'executive_revenue_by_stream_trend',
'executive_membership_growth',
'executive_treasury_positions',
'executive_open_cashier_sessions',
'executive_receivables_aging',
'executive_top_debtors',
'executive_subscription_collection',
'executive_hr_headcount_cost',
'executive_payroll_status',
'executive_sports_enrollment',
'executive_facility_utilization',
'executive_branch_performance',
'executive_support_backlog',
'executive_system_health',
],
],
'sports_director' => [
'headline' => ['sports_subscription_arrears', 'sports_unpaid_bookings', 'sports_medical_blocked_count', 'sports_groups_below_min_count', 'sports_unmarked_sessions_today'],
'widgets' => [
'sports_enrollment_by_discipline',
'sports_arrears_by_academy',
'sports_academy_portfolio',
'sports_groups_below_min',
'sports_coach_utilization',
'sports_groups_without_coach',
'sports_contracts_expiring',
'sports_facility_load_today',
'sports_idle_facility_units',
'sports_idle_units_count',
'sports_medical_blocked_players',
'sports_sessions_today',
'sports_locker_rentals_action',
],
],
'sports_officer' => [
'headline' => ['sports_unmarked_sessions_today', 'sports_medical_blocked_count', 'sports_subscription_arrears', 'sports_unpaid_bookings', 'sports_groups_below_min_count'],
'widgets' => [
'sports_sessions_today',
'sports_medical_blocked_players',
'sports_medical_expiring_soon',
'sports_medical_docs_pending',
'sports_pending_payment_enrollments',
'sports_groups_below_min',
'sports_groups_without_coach',
'sports_locker_rentals_action',
'sports_upcoming_bookings',
'sports_booking_conflicts',
'sports_conflicts_count',
'sports_attendance_rate',
],
],
'academy_manager' => [
'headline' => ['sports_unmarked_sessions_today', 'sports_subscription_arrears', 'sports_medical_blocked_count', 'sports_groups_below_min_count'],
'widgets' => [
'sports_sessions_today',
'sports_academy_portfolio',
'sports_arrears_by_academy',
'sports_groups_below_min',
'sports_groups_without_coach',
'sports_enrollment_by_discipline',
'sports_coach_utilization',
'sports_medical_blocked_players',
'sports_medical_expiring_soon',
'sports_medical_docs_pending',
'sports_pending_payment_enrollments',
'sports_attendance_rate',
],
],
'general_manager' => [
'headline' => ['executive_revenue_mtd', 'executive_active_members', 'executive_receivables_total', 'executive_open_approvals'],
'widgets' => [
'executive_control_exceptions',
'executive_revenue_by_stream_trend',
'executive_membership_growth',
'executive_receivables_aging',
'executive_top_debtors',
'executive_subscription_collection',
'executive_hr_headcount_cost',
'executive_payroll_status',
'executive_sports_enrollment',
'executive_facility_utilization',
'executive_branch_performance',
'executive_support_backlog',
],
],
'membership_director' => [
'headline' => ['membership_active_headcount', 'membership_pipeline_stalled', 'membership_separations_backlog', 'membership_subscription_collection', 'membership_dependents_awaiting_activation'],
'widgets' => [
'membership_separation_queue',
'membership_interview_queue',
'membership_collection_by_branch',
'membership_by_branch_category',
'membership_growth_trend',
'membership_category_mix',
'membership_carnet_print_queue',
'membership_children_age_limit',
'membership_profile_gaps',
],
],
'facilities_manager' => [
'headline' => ['sports_today_facility_slots', 'sports_unpaid_bookings', 'sports_conflicts_count', 'sports_pending_reservations_count', 'sports_idle_units_count'],
'widgets' => [
'sports_facility_load_today',
'sports_sessions_today',
'sports_upcoming_bookings',
'sports_booking_conflicts',
'sports_pending_reservations',
'sports_idle_facility_units',
],
],
'receptionist' => [
'headline' => ['operations_guests_inside_now', 'operations_guest_cash_today', 'operations_reservations_pending_count', 'operations_support_open_count'],
'widgets' => [
'operations_guest_register_today',
'operations_reservations_action_queue',
'operations_support_open',
'operations_members_not_in_good_standing',
'operations_member_lookup',
'operations_carnet_register',
'operations_carnets_blocked',
],
],
'board_member' => [
'headline' => ['executive_revenue_mtd', 'executive_active_members', 'executive_receivables_total', 'executive_open_approvals'],
'widgets' => [
'executive_board_approval_queue',
'executive_revenue_by_stream_trend',
'executive_membership_growth',
'executive_subscription_collection',
'executive_receivables_aging',
'executive_branch_performance',
],
],
'hr_manager' => [
'headline' => ['hr_attendance_today', 'hr_contract_coverage', 'hr_payroll_period_status', 'hr_performance_cycle_status'],
'widgets' => [
'hr_approvals_inbox',
'hr_insurance_gaps',
'hr_probation_overdue',
'hr_open_cases',
'hr_new_hires_onboarding',
'hr_headcount_by_dept',
],
],
'accountant' => [
'headline' => ['finance_trial_balance_health', 'finance_payments_missing_journal', 'finance_unclosed_periods', 'finance_overdue_installments', 'finance_fiscal_period_status'],
'widgets' => [
'finance_journal_volume_6m',
'finance_top_accounts_month',
'finance_daily_cash_14d',
'finance_payment_integrity',
],
],
'auditor' => [
'headline' => ['finance_payment_integrity', 'finance_audit_activity_today', 'finance_open_sessions_count', 'finance_overdue_installments'],
'widgets' => [
'finance_permission_changes',
'finance_audit_volume_14d',
'finance_open_sessions_all',
'finance_custody_by_treasury',
'finance_daily_cash_14d',
],
],
'it_admin' => [
'headline' => ['operations_it_sessions_live', 'operations_it_account_health', 'operations_it_audit_today'],
'widgets' => [
'operations_it_login_failures',
'operations_it_audit_volume',
'operations_it_never_logged_in',
'operations_it_destructive_actions',
'operations_it_roles_unassigned',
'operations_it_alert_rules_silent',
],
],
'treasury_manager' => [
'headline' => ['finance_undeposited_cash', 'finance_open_sessions_count', 'finance_settlements_pending', 'finance_overdue_installments'],
'widgets' => [
'finance_open_sessions_all',
'finance_custody_by_treasury',
'finance_settlements_inbox',
'finance_daily_cash_14d',
'finance_collections_channel_30d',
],
],
'gate_guard' => [
'headline' => ['operations_guests_inside_now', 'operations_carnets_blocked', 'operations_guest_cash_today', 'operations_members_flagged_count'],
'widgets' => [
'operations_guest_register_today',
'operations_carnet_register',
'operations_members_not_in_good_standing',
'operations_member_lookup',
],
],
'hr_officer' => [
'headline' => ['hr_attendance_today', 'hr_employee_file_gaps', 'hr_contract_coverage'],
'widgets' => [
'hr_attendance_unrecorded_list',
'hr_attendance_exceptions_today',
'hr_insurance_gaps',
'hr_new_hires_onboarding',
'hr_probation_overdue',
],
],
'membership_officer' => [
'headline' => ['membership_profile_gaps', 'membership_dependents_awaiting_activation', 'membership_children_age_limit', 'membership_new_members_missing_docs'],
'widgets' => [
'membership_profile_gap_queue',
'membership_dependents_activation_queue',
'membership_children_age_limit_queue',
],
],
'sports_coach' => [
'headline' => ['sports_unmarked_sessions_today', 'sports_medical_blocked_count', 'sports_attendance_rate'],
'widgets' => [
'sports_my_sessions_today',
'sports_my_groups_roster',
'sports_medical_blocked_players',
'sports_medical_expiring_soon',
],
],
'sales_agent' => [
'headline' => ['membership_sales_pipeline_value', 'membership_sales_seasonal_pending', 'membership_sales_own_signups'],
'widgets' => [
'membership_sales_pipeline_aging',
'membership_sales_funnel',
'membership_sales_seasonal_queue',
],
],
'security_officer' => [
'headline' => ['operations_guests_inside_now', 'operations_carnets_blocked', 'operations_members_flagged_count'],
'widgets' => [
'operations_carnet_register',
'operations_members_not_in_good_standing',
'operations_member_lookup',
],
],
'treasury_officer' => [
'headline' => ['finance_my_open_session', 'finance_my_custody_balance', 'finance_my_collections_today', 'finance_membership_queue'],
'widgets' => [
'finance_my_custody_log',
'finance_overdue_installments',
],
],
'cashier_operator' => [
'headline' => ['finance_membership_queue', 'finance_my_collections_today', 'finance_overdue_installments'],
'widgets' => [
'finance_my_recent_receipts',
'finance_daily_cash_14d',
],
],
'report_viewer' => [
'headline' => ['operations_report_membership_headline', 'operations_report_financial_headline', 'operations_report_operations_headline', 'operations_report_audit_headline'],
'widgets' => [
'operations_report_catalog',
],
],
'sports_cashier' => [
'headline' => ['finance_my_open_session', 'finance_my_custody_balance', 'finance_sports_queue'],
'widgets' => [
'finance_my_custody_log',
'finance_open_sessions_all',
],
],
'department_head' => [
'headline' => ['hr_dept_team_today', 'hr_dept_approvals_inbox', 'hr_dept_review_progress'],
'widgets' => [
'hr_dept_team_roster',
],
],
'main_cashier' => [
'headline' => ['finance_undeposited_cash', 'finance_settlements_pending', 'finance_membership_queue'],
'widgets' => [
'finance_settlements_inbox',
],
],
'membership_cashier' => [
'headline' => ['finance_my_open_session', 'finance_my_custody_balance', 'finance_membership_queue'],
'widgets' => [
'finance_my_custody_log',
],
],
'_default' => [
'headline' => [],
'widgets' => [],
],
];
<?php
declare(strict_types=1);
/** Section key -> Arabic heading, in display order. */
return [
'general' => 'نظرة عامة',
'revenue' => 'الإيرادات والتحصيل',
'membership' => 'العضوية',
'treasury' => 'الخزينة',
'workforce' => 'الموارد البشرية',
'sports' => 'النشاط الرياضي والمنشآت',
'governance' => 'الحوكمة والنظام',
];
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -4,6 +4,7 @@ declare(strict_types=1); ...@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Modules\Dashboard\Controllers; namespace App\Modules\Dashboard\Controllers;
use App\Core\Controller; use App\Core\Controller;
use App\Core\Registries\WidgetRegistry;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Modules\Dashboard\Services\DashboardDataService; use App\Modules\Dashboard\Services\DashboardDataService;
...@@ -12,7 +13,73 @@ class DashboardController extends Controller ...@@ -12,7 +13,73 @@ class DashboardController extends Controller
{ {
public function index(Request $request): Response public function index(Request $request): Response
{ {
$data = DashboardDataService::getData(); $employee = $this->currentEmployee();
return $this->view('Dashboard.Views.index', $data); $dashboard = DashboardDataService::getForEmployee($employee);
if ($dashboard['legacy']) {
return $this->view('Dashboard.Views.index', $dashboard['data'] + ['__legacy' => true]);
}
return $this->view('Dashboard.Views.index', [
'__legacy' => false,
'headline' => $dashboard['headline'],
'sections' => $dashboard['sections'],
]);
}
/**
* Hydrate one lazily-loaded widget.
*
* The key arrives from the client, so permission is re-checked here — a preset
* is a layout hint, never an authorisation decision.
*/
public function widget(Request $request, string $key): Response
{
$employee = $this->currentEmployee();
if ($employee === null) {
return $this->json(['error' => 'unauthenticated'], 401);
}
$def = WidgetRegistry::get($key);
if ($def === null) {
return $this->json(['error' => 'unknown_widget'], 404);
}
$permissions = method_exists($employee, 'getAllPermissions')
? $employee->getAllPermissions()
: [];
if (!WidgetRegistry::allows($def['permission'] ?? null, $permissions)) {
return $this->json(['error' => 'forbidden'], 403);
}
$rows = DashboardDataService::runWidget($def, ['employee_id' => (int) ($employee->id ?? 0)]);
// Render server-side through the same partial the eager path uses, so a lazily
// loaded chart is built identically to an eager one and the client never needs
// the column-label metadata.
$renderer = match ($def['type']) {
'bar_chart', 'line_chart', 'donut' => 'chart',
'progress' => 'progress',
'kpi' => 'kpi',
default => 'rows',
};
$html = (new \App\Core\Template())->render(
'Dashboard.Views._partials.widget_' . $renderer,
[
'key' => $key,
'w' => $def + ['data' => $rows, 'loaded' => true],
'rows' => $rows,
'labels' => DashboardDataService::labels()[$key] ?? [],
'loaded' => true,
]
);
return $this->json([
'key' => $key,
'type' => $def['type'],
'html' => $html,
]);
} }
} }
...@@ -3,4 +3,5 @@ declare(strict_types=1); ...@@ -3,4 +3,5 @@ declare(strict_types=1);
return [ return [
['GET', '/dashboard', 'Dashboard\Controllers\DashboardController@index', ['auth'], null], ['GET', '/dashboard', 'Dashboard\Controllers\DashboardController@index', ['auth'], null],
['GET', '/dashboard/widget/{key:[a-z0-9_]+}', 'Dashboard\Controllers\DashboardController@widget', ['auth'], null],
]; ];
\ No newline at end of file
...@@ -4,9 +4,145 @@ declare(strict_types=1); ...@@ -4,9 +4,145 @@ declare(strict_types=1);
namespace App\Modules\Dashboard\Services; namespace App\Modules\Dashboard\Services;
use App\Core\App; use App\Core\App;
use App\Core\Registries\WidgetRegistry;
final class DashboardDataService final class DashboardDataService
{ {
/** Widgets rendered server-side on first paint; the rest hydrate over XHR. */
private const EAGER_WIDGETS = 6;
/**
* Resolve the dashboard for one employee: role presets pick the layout,
* permissions decide what actually renders.
*
* Returns ['headline' => [...], 'sections' => [...], 'legacy' => bool].
* Falls back to the pre-existing shared dashboard when the employee has no
* mapped role, so nobody ever lands on a blank page.
*/
public static function getForEmployee(?object $employee): array
{
if ($employee === null) {
return ['legacy' => true, 'data' => self::getData()];
}
$presets = self::presets();
$roleCodes = method_exists($employee, 'getRoleCodes') ? $employee->getRoleCodes() : [];
$headlineKeys = [];
$widgetKeys = [];
foreach ($roleCodes as $code) {
if (!isset($presets[$code])) {
continue;
}
$headlineKeys = array_merge($headlineKeys, $presets[$code]['headline'] ?? []);
$widgetKeys = array_merge($widgetKeys, $presets[$code]['widgets'] ?? []);
}
// Union across multiple roles, deduped, headline never repeated below.
$headlineKeys = array_values(array_unique($headlineKeys));
$widgetKeys = array_values(array_diff(array_unique($widgetKeys), $headlineKeys));
if ($headlineKeys === [] && $widgetKeys === []) {
return ['legacy' => true, 'data' => self::getData()];
}
$permissions = method_exists($employee, 'getAllPermissions')
? $employee->getAllPermissions()
: [];
$headline = WidgetRegistry::getVisible($permissions, $headlineKeys);
$body = WidgetRegistry::getVisible($permissions, $widgetKeys);
$ctx = ['employee_id' => (int) ($employee->id ?? 0)];
foreach ($headline as $key => $def) {
$headline[$key]['data'] = self::runWidget($def, $ctx);
$headline[$key]['loaded'] = true;
}
// Only the first slice runs now; the rest are hydrated by /dashboard/widget/{key}
// so one slow query can never hold up first paint.
$i = 0;
foreach ($body as $key => $def) {
if ($i++ < self::EAGER_WIDGETS) {
$body[$key]['data'] = self::runWidget($def, $ctx);
$body[$key]['loaded'] = true;
} else {
$body[$key]['data'] = null;
$body[$key]['loaded'] = false;
}
}
return [
'legacy' => false,
'headline' => $headline,
'sections' => self::groupBySection($body),
'roles' => $roleCodes,
];
}
/** Execute one widget's query. A failure yields an empty widget, never a 500. */
public static function runWidget(array $def, array $ctx): array
{
try {
$db = App::getInstance()->db();
if ($db === null) {
return [];
}
$params = [];
if (isset($def['params']) && $def['params'] instanceof \Closure) {
$params = ($def['params'])($ctx);
}
return $db->select($def['sql'], $params) ?: [];
} catch (\Throwable $e) {
return [];
}
}
/** Group widgets under their section heading, preserving section display order. */
private static function groupBySection(array $widgets): array
{
$labels = self::sections();
$out = [];
foreach ($labels as $key => $label) {
$out[$key] = ['label' => $label, 'widgets' => []];
}
foreach ($widgets as $key => $def) {
$sec = $def['section'] ?? 'general';
if (!isset($out[$sec])) {
$out[$sec] = ['label' => $labels[$sec] ?? $sec, 'widgets' => []];
}
$out[$sec]['widgets'][$key] = $def;
}
return array_filter($out, static fn($s) => $s['widgets'] !== []);
}
public static function presets(): array
{
static $p = null;
return $p ??= require dirname(__DIR__) . '/Config/role_presets.php';
}
public static function sections(): array
{
static $s = null;
return $s ??= require dirname(__DIR__) . '/Config/sections.php';
}
/**
* Arabic column headings, keyed by widget then SQL alias.
* Optional — the row/chart partials fall back to convention when absent.
*/
public static function labels(): array
{
static $l = null;
if ($l !== null) {
return $l;
}
$file = dirname(__DIR__) . '/Config/labels.php';
return $l = is_file($file) ? (require $file) : [];
}
public static function getData(): array public static function getData(): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
<?php
// Legacy shared dashboard — the fallback for employees with no mapped role.
// Preserved verbatim so nobody lands on a blank page.
?>
<!-- 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
/**
* Widget card shell + renderer dispatch.
*
* Expects: $key, $w (widget definition incl. 'data' and 'loaded').
* A widget that failed its query renders an empty state — never an error.
*/
$type = $w['type'] ?? 'kpi';
$rows = $w['data'] ?? [];
$loaded = $w['loaded'] ?? false;
$labels = $__dashLabels[$key] ?? [];
$renderer = match ($type) {
'kpi' => 'kpi',
'bar_chart', 'line_chart', 'donut' => 'chart',
'progress' => 'progress',
default => 'rows',
};
$isKpi = $renderer === 'kpi';
$span = match ($type) {
'kpi', 'progress' => 1,
'donut' => 1,
'table', 'feed' => 2,
default => 2,
};
?>
<div class="dash-widget dash-widget--<?= e($type) ?> dash-span-<?= $span ?><?= $isKpi ? ' stats-card stats-card-' . e($w['color'] ?? 'primary') : ' card' ?>"
data-widget="<?= e($key) ?>"
<?= $loaded ? '' : 'data-lazy="1"' ?>>
<?php if ($isKpi): ?>
<?php $__template->include('Dashboard.Views._partials.widget_kpi', compact('key', 'w', 'rows', 'labels', 'loaded')); ?>
<?php else: ?>
<div class="card-header dash-widget-head">
<h3><i data-lucide="<?= e($w['icon'] ?? 'activity') ?>"></i><?= e($w['title_ar']) ?></h3>
<?php if (!empty($w['drill_link'])): ?>
<a href="<?= e($w['drill_link']) ?>" class="dash-widget-more">عرض الكل</a>
<?php endif; ?>
</div>
<div class="card-body dash-widget-body">
<?php if (!$loaded): ?>
<div class="dash-skeleton"><span></span><span></span><span></span></div>
<?php else: ?>
<?php $__template->include('Dashboard.Views._partials.widget_' . $renderer, compact('key', 'w', 'rows', 'labels')); ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php
/**
* Chart widget — emits a canvas plus its data as attributes.
* dashboard.js builds the Chart.js instance, so there is no inline JS and Arabic
* category names are encoded exactly once.
*
* Three data shapes are supported:
* - pivot : one row of counts, each column becomes a category (funnels, status splits)
* - grouped : two label columns, the second becomes one series per distinct value
* - plain : one label column plus one or more value columns
*/
$labelCols = [];
$valueCols = [];
foreach ($labels as $alias => $meta) {
$role = $meta['role'] ?? '';
if ($role === 'label') { $labelCols[$alias] = $meta['ar']; }
elseif ($role === 'value') { $valueCols[$alias] = $meta['ar']; }
}
// Convention fallback when a widget has no labelling data.
if (!$labelCols && !$valueCols) {
$first = $rows[0] ?? [];
foreach ($first as $c => $v) {
if (!$labelCols && !is_numeric($v)) { $labelCols[$c] = $c; }
elseif (is_numeric($v)) { $valueCols[$c] = $c; }
}
}
$categories = [];
$series = [];
if ($rows) {
$pivot = count($rows) === 1 && !$labelCols && count($valueCols) > 1;
if ($pivot) {
// One row of figures — each column is a slice/bar.
$data = [];
foreach ($valueCols as $col => $ar) {
$categories[] = $ar;
$data[] = (float) ($rows[0][$col] ?? 0);
}
$series[] = ['label' => $w['title_ar'], 'data' => $data];
} elseif (count($labelCols) >= 2 && count($valueCols) >= 1) {
// Grouped: first label is the axis, second splits into series.
$cols = array_keys($labelCols);
$axisCol = $cols[0];
$seriesCol = $cols[1];
$valueCol = array_key_first($valueCols);
$catIndex = [];
$buckets = [];
foreach ($rows as $r) {
$cat = (string) ($r[$axisCol] ?? '');
$grp = (string) ($r[$seriesCol] ?? '');
if (!isset($catIndex[$cat])) { $catIndex[$cat] = count($categories); $categories[] = $cat; }
$buckets[$grp][$catIndex[$cat]] = (float) ($r[$valueCol] ?? 0);
}
foreach ($buckets as $grp => $points) {
$data = array_fill(0, count($categories), 0.0);
foreach ($points as $i => $v) { $data[$i] = $v; }
$series[] = ['label' => $grp, 'data' => $data];
}
} else {
// Plain: one caption column, one or more value columns.
$axisCol = array_key_first($labelCols) ?? array_key_first($rows[0]);
foreach ($valueCols as $col => $ar) { $series[$col] = ['label' => $ar, 'data' => []]; }
foreach ($rows as $r) {
$categories[] = (string) ($r[$axisCol] ?? '');
foreach ($valueCols as $col => $_) { $series[$col]['data'][] = (float) ($r[$col] ?? 0); }
}
$series = array_values($series);
}
}
$enc = static fn($v) => e(json_encode($v, JSON_UNESCAPED_UNICODE));
?>
<?php if (!$rows || !$categories || !$series): ?>
<div class="dash-empty"><i data-lucide="inbox"></i><span>لا توجد بيانات</span></div>
<?php else: ?>
<div class="dash-chart">
<canvas data-chart="<?= e($w['type']) ?>"
data-labels="<?= $enc($categories) ?>"
data-series="<?= $enc($series) ?>"></canvas>
</div>
<?php endif; ?>
<?php
/**
* KPI tile — one headline figure plus supporting values.
* Renders inside .stats-card, so it uses that component's element classes.
*/
$row = $rows[0] ?? [];
// The labelling pass marks exactly one column 'primary'; fall back to the first.
$primaryCol = null;
foreach ($labels as $alias => $meta) {
if (($meta['role'] ?? '') === 'primary') { $primaryCol = $alias; break; }
}
if ($primaryCol === null || !array_key_exists($primaryCol, $row)) {
$primaryCol = array_key_first($row) ?: null;
}
$value = $primaryCol !== null ? ($row[$primaryCol] ?? 0) : 0;
$isMoney = (bool) preg_match('/amount|revenue|total_due|cash|custody|salary|cost|balance|outstanding|collected|billed|due/i', (string) $primaryCol);
$secondary = [];
foreach ($row as $col => $val) {
if ($col === $primaryCol) continue;
$meta = $labels[$col] ?? null;
if ($meta === null) continue;
if (!in_array($meta['role'] ?? '', ['secondary', 'value'], true)) continue;
$secondary[] = ['ar' => $meta['ar'], 'val' => $val];
}
?>
<div class="stats-card-icon"><i data-lucide="<?= e($w['icon'] ?? 'activity') ?>"></i></div>
<div class="stats-card-content">
<div class="stats-card-title"><?= e($w['title_ar']) ?></div>
<div class="stats-card-value">
<?php if (!$loaded): ?>
<span class="dash-skeleton-text"></span>
<?php elseif ($isMoney): ?>
<?= money($value ?? 0) ?>
<?php else: ?>
<?= number_format((float) ($value ?? 0)) ?>
<?php endif; ?>
</div>
<?php if ($secondary): ?>
<div class="dash-kpi-sub">
<?php foreach (array_slice($secondary, 0, 3) as $s): ?>
<span class="dash-kpi-chip">
<span class="dash-kpi-chip-label"><?= e($s['ar']) ?></span>
<strong><?= is_numeric($s['val']) ? number_format((float) $s['val']) : e((string) $s['val']) ?></strong>
</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php if (!empty($w['drill_link'])): ?>
<a href="<?= e($w['drill_link']) ?>" class="stats-card-link">عرض التفاصيل</a>
<?php endif; ?>
<?php
/**
* Progress widget — a completion ratio, e.g. subscription collection against target.
* Finds a numerator/denominator pair by convention, then falls back to the first
* two numeric columns.
*/
$row = $rows[0] ?? [];
$num = null; $den = null;
foreach ($row as $col => $val) {
if (!is_numeric($val)) continue;
if ($num === null && preg_match('/collected|paid|done|completed|achieved|actual|current/i', $col)) { $num = $col; }
if ($den === null && preg_match('/target|expected|total|billed|due|capacity|planned/i', $col)) { $den = $col; }
}
if ($num === null || $den === null) {
$numeric = array_keys(array_filter($row, 'is_numeric'));
$num ??= $numeric[0] ?? null;
$den ??= $numeric[1] ?? null;
}
$numVal = (float) ($row[$num] ?? 0);
$denVal = (float) ($row[$den] ?? 0);
$pct = $denVal > 0 ? min(100, ($numVal / $denVal) * 100) : 0;
$tone = $pct >= 75 ? 'success' : ($pct >= 40 ? 'warning' : 'danger');
$isMoney = (bool) preg_match('/amount|revenue|collected|billed|due|total/i', (string) $num);
?>
<?php if (!$row): ?>
<div class="dash-empty"><i data-lucide="inbox"></i><span>لا توجد بيانات</span></div>
<?php else: ?>
<div class="dash-progress">
<div class="dash-progress-figures">
<span class="dash-progress-num"><?= $isMoney ? money($numVal) : number_format($numVal) ?></span>
<span class="dash-progress-den">من <?= $isMoney ? money($denVal) : number_format($denVal) ?></span>
</div>
<div class="dash-progress-track">
<div class="dash-progress-fill dash-progress-fill--<?= $tone ?>" style="width:<?= round($pct, 1) ?>%"></div>
</div>
<div class="dash-progress-pct dash-progress-pct--<?= $tone ?>"><?= number_format($pct, 1) ?>%</div>
</div>
<?php endif; ?>
<?php
/**
* Row-based widget — table, list, and feed.
* Columns marked 'link' or 'hidden' by the labelling pass never render; a 'link'
* column named url/link becomes the row's href instead.
*/
$visible = [];
$linkCol = null;
foreach ($labels as $alias => $meta) {
$role = $meta['role'] ?? '';
if ($role === 'hidden') continue;
if ($role === 'link') {
if ($linkCol === null && in_array($alias, ['url', 'link'], true)) { $linkCol = $alias; }
continue;
}
$visible[$alias] = $meta['ar'];
}
// No labelling data: show every column except obvious plumbing.
if (!$visible) {
foreach (($rows[0] ?? []) as $c => $_) {
if (preg_match('/^(url|link|.*_id|max_id|id)$/i', $c)) { if ($c === 'url' || $c === 'link') $linkCol = $c; continue; }
$visible[$c] = $c;
}
}
$isMoneyCol = static fn(string $c): bool =>
(bool) preg_match('/amount|revenue|total|cash|custody|salary|cost|balance|outstanding|collected|billed|due|paid/i', $c);
$asTable = ($w['type'] ?? '') === 'table';
$limit = $asTable ? 8 : 6;
?>
<?php if (!$rows): ?>
<div class="dash-empty"><i data-lucide="inbox"></i><span>لا توجد بيانات</span></div>
<?php elseif ($asTable): ?>
<div class="table-responsive">
<table class="dash-table">
<thead>
<tr><?php foreach ($visible as $ar): ?><th><?= e($ar) ?></th><?php endforeach; ?></tr>
</thead>
<tbody>
<?php foreach (array_slice($rows, 0, $limit) as $r): ?>
<?php $href = $linkCol !== null ? ($r[$linkCol] ?? null) : null; ?>
<tr<?= $href ? ' class="dash-row-link" data-href="' . e((string) $href) . '"' : '' ?>>
<?php foreach ($visible as $col => $ar): ?>
<?php $v = $r[$col] ?? null; ?>
<td>
<?php if ($v === null || $v === ''): ?>
<?php elseif (is_numeric($v) && $isMoneyCol($col)): ?><?= money($v) ?>
<?php elseif (is_numeric($v)): ?><?= number_format((float) $v) ?>
<?php else: ?><?= e((string) $v) ?><?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<ul class="dash-list">
<?php
$cols = array_keys($visible);
$captionCol = $cols[0] ?? null;
$valueCol = $cols[count($cols) - 1] ?? null;
?>
<?php foreach (array_slice($rows, 0, $limit) as $r): ?>
<?php $href = $linkCol !== null ? ($r[$linkCol] ?? null) : null; ?>
<li class="dash-list-item">
<?php if ($href): ?><a href="<?= e((string) $href) ?>"><?php endif; ?>
<div class="dash-list-main">
<span class="dash-list-caption"><?= e((string) ($r[$captionCol] ?? '—')) ?></span>
<?php if (count($cols) > 2): ?>
<span class="dash-list-meta">
<?php
$mid = array_slice($cols, 1, -1);
$parts = [];
foreach ($mid as $c) {
$v = $r[$c] ?? null;
if ($v === null || $v === '') continue;
$parts[] = $visible[$c] . ': ' . (is_numeric($v) ? number_format((float) $v) : $v);
}
echo e(implode(' · ', array_slice($parts, 0, 2)));
?>
</span>
<?php endif; ?>
</div>
<?php if ($valueCol !== null && $valueCol !== $captionCol): ?>
<?php $v = $r[$valueCol] ?? null; ?>
<span class="dash-list-value">
<?php if ($v === null || $v === ''): ?>
<?php elseif (is_numeric($v) && $isMoneyCol($valueCol)): ?><?= money($v) ?>
<?php elseif (is_numeric($v)): ?><?= number_format((float) $v) ?>
<?php else: ?><?= e((string) $v) ?><?php endif; ?>
</span>
<?php endif; ?>
<?php if ($href): ?></a><?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if (count($rows) > $limit): ?>
<div class="dash-more-count">و <?= number_format(count($rows) - $limit) ?> أخرى</div>
<?php endif; ?>
...@@ -2,96 +2,47 @@ ...@@ -2,96 +2,47 @@
<?php $__template->section('title'); ?>لوحة التحكم<?php $__template->endSection(); ?> <?php $__template->section('title'); ?>لوحة التحكم<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<!-- Summary Cards --> <?php if (!empty($__legacy)): ?>
<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;"> <?php $__template->include('Dashboard.Views._partials.legacy', get_defined_vars()); ?>
<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 --> <?php else: ?>
<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> <?php $__dashLabels = \App\Modules\Dashboard\Services\DashboardDataService::labels(); ?>
<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> <?php if (!empty($headline)): ?>
<!-- Alerts --> <div class="stats-grid dash-headline">
<?php if (!empty($alerts)): ?> <?php foreach ($headline as $key => $w): ?>
<div class="card" style="margin-bottom:20px;"> <?php $__template->include('Dashboard.Views._partials.widget', compact('key', 'w', '__dashLabels')); ?>
<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; ?> <?php endforeach; ?>
</div> </div>
</div>
<?php endif; ?> <?php endif; ?>
<!-- Recent Activity --> <?php foreach (($sections ?? []) as $sectionKey => $section): ?>
<div class="card"> <section class="dash-section" data-section="<?= e($sectionKey) ?>">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;"><h3 style="margin:0;color:#0D7377;">النشاط الأخير</h3></div> <h2 class="dash-section-title"><?= e($section['label']) ?></h2>
<div style="padding:10px 15px;max-height:400px;overflow-y:auto;"> <div class="dash-grid">
<?php foreach (($recent_activity ?? []) as $act): ?> <?php foreach ($section['widgets'] as $key => $w): ?>
<div style="padding:8px 0;border-bottom:1px solid #F3F4F6;font-size:12px;"> <?php $__template->include('Dashboard.Views._partials.widget', compact('key', 'w', '__dashLabels')); ?>
<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 endforeach; ?>
<?php if (empty($recent_activity)): ?><div style="padding:20px;text-align:center;color:#9CA3AF;">لا يوجد نشاط</div><?php endif; ?>
</div>
</div> </div>
</section>
<?php endforeach; ?>
<?php if (empty($headline) && empty($sections)): ?>
<div class="card" style="padding:48px;text-align:center;">
<i data-lucide="layout-dashboard" style="width:40px;height:40px;color:var(--text-muted);"></i>
<h3 style="margin:16px 0 4px;color:var(--text-primary);">لا توجد عناصر لعرضها</h3>
<p style="color:var(--text-muted);margin:0;">لم يتم منح حسابك صلاحيات لعرض أي من مؤشرات لوحة التحكم.</p>
</div> </div>
</div> <?php endif; ?>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('scripts'); ?>
<?php if (empty($__legacy)): ?>
<script src="<?= url('assets/js/dashboard.js') ?>?v=<?= @filemtime(dirname(__DIR__, 4) . '/public/assets/js/dashboard.js') ?: time() ?>"></script>
<?php endif; ?>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -2,6 +2,12 @@ ...@@ -2,6 +2,12 @@
declare(strict_types=1); declare(strict_types=1);
use App\Core\Registries\MenuRegistry; use App\Core\Registries\MenuRegistry;
use App\Core\Registries\WidgetRegistry;
// Widget catalogue — every SQL statement here was verified against the live schema.
foreach (require __DIR__ . '/Config/widgets.php' as $key => $definition) {
WidgetRegistry::register($key, $definition);
}
MenuRegistry::register('dashboard', [ MenuRegistry::register('dashboard', [
'label_ar' => 'لوحة التحكم', 'label_ar' => 'لوحة التحكم',
......
...@@ -30,6 +30,10 @@ $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); ...@@ -30,6 +30,10 @@ $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
<!-- Lucide Icons --> <!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script> <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<!-- Chart.js — dashboard charts, and the evaluation radar/progression charts
in PlayerAffairs which have always been guarded by `typeof Chart !== 'undefined'` -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<!-- Main Stylesheet --> <!-- Main Stylesheet -->
<link rel="stylesheet" href="<?= url('assets/css/main.css') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/css/main.css') ?: time() ?>"> <link rel="stylesheet" href="<?= url('assets/css/main.css') ?>?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/assets/css/main.css') ?: time() ?>">
<style><?= \App\Modules\Settings\Controllers\AppearanceController::getCssOverrides() ?></style> <style><?= \App\Modules\Settings\Controllers\AppearanceController::getCssOverrides() ?></style>
......
...@@ -400,3 +400,56 @@ Super admin check: `employee_roles JOIN roles WHERE role_code = 'super_admin'` ...@@ -400,3 +400,56 @@ Super admin check: `employee_roles JOIN roles WHERE role_code = 'super_admin'`
- `hr.biometric.*` — Biometric devices - `hr.biometric.*` — Biometric devices
- `hr.report.*` — Reports - `hr.report.*` — Reports
- `hr.payslip.view_own` — Self-service payslip - `hr.payslip.view_own` — Self-service payslip
---
## Dashboard Module
The Dashboard is a **pure read consumer**. It writes nothing, dispatches no events, and owns
no tables. It is therefore downstream of essentially every module, and upstream of none.
See `docs/architecture-maps/Dashboard.md` for the full module map.
### Direction of Dependency
```
Dashboard ──reads──> 60+ tables across Members, Subscriptions, Payments, Installments,
Treasury, Cashier, Accounting, HR, SportsActivity, Facilities,
Reservations, Carnets, Support, Alerts, Audit, Users/Roles
Dashboard <──────── nothing depends on Dashboard
```
### What Breaks the Dashboard
Because it reads widely and writes nothing, the failure mode is always the same: a widget
returns `[]` and renders an empty card. It cannot corrupt data, but it CAN silently show a
wrong number.
| Upstream change | Effect on Dashboard | Where to fix |
|---|---|---|
| Column renamed or dropped | That widget's query throws, caught, renders empty | `Config/widgets.php` — re-run the SQL against live |
| Table renamed | Same | `Config/widgets.php` |
| A status enum gains/loses a value | Widget silently under- or over-counts — **no error** | Audit the `WHERE status IN (...)` predicate |
| `is_archived` added to a table | Widget starts counting archived rows | Add the filter |
| Permission key renamed in a module's `bootstrap.php` | Widget vanishes for every role | `Config/widgets.php` `permission` field |
| Permission removed from a role | Widget vanishes for that role only (intended) | `role_permissions`, or the preset |
| New role added to `roles` | That role gets the **legacy fallback** until a preset is added | `Config/role_presets.php` |
| A new widget added | Renders, but table headers show English aliases until labelled | `Config/labels.php` |
### Permission Dependencies
The Dashboard consumes 61 permission keys, all registered by other modules' `bootstrap.php`
files — it registers **none of its own**. The route `/dashboard` intentionally carries a
`null` permission; authorisation happens per widget via `WidgetRegistry::allows()`, which
mirrors `MenuRegistry::getVisible()`.
`GET /dashboard/widget/{key}` re-checks the widget's permission server-side. The widget key
arrives from the client and is never trusted as an authorisation decision.
### Reporting Overlap
`executive_receivables_total` computes member arrears from `subscriptions` + `installment_schedule`
+ `sa_subscriptions`, scoped to non-archived members to reconcile with
`ReportEngine::outstandingReport()` (`RPT_OUTSTANDING`). The two still differ in one respect:
the report includes outstanding `fines`, the widget does not. Immaterial today (no fines due),
but they will diverge once fines are used — keep them in sync if that changes.
# Dashboard Module — Architecture Map
**Last updated:** 2026-08-29
**Widgets:** 144 (all SQL verified against the live DB)
**Roles with a preset:** 28 of 29 (`gate guard`, the malformed duplicate with a space, is unmapped)
---
## Module Purpose
Renders the landing page at `/dashboard`. Each employee sees a dashboard chosen by their
role and filtered by their permissions, rather than one shared payload.
Before this module was reworked, `DashboardDataService::getData()` returned a single fixed
result set to every user — a cashier saw membership statistics they could not act on, an HR
manager saw revenue instead of headcount. That method still exists and is now the fallback
for employees with no mapped role.
---
## File Structure
```
app/Modules/Dashboard/
├── bootstrap.php — registers 144 widgets into WidgetRegistry, menu entry, separators
├── Routes.php — /dashboard, /dashboard/widget/{key}
├── Controllers/
│ └── DashboardController.php — index() + widget() JSON endpoint
├── Services/
│ └── DashboardDataService.php — preset resolution, permission filter, query execution
├── Config/
│ ├── widgets.php — 144 widget definitions (title, type, permission, SQL, params)
│ ├── role_presets.php — role_code → headline KPIs + ordered widget list
│ ├── labels.php — Arabic heading per widget column (838 entries)
│ └── sections.php — 7 section keys → Arabic headings
└── Views/
├── index.php — headline strip + sections, or legacy fallback
└── _partials/
├── widget.php — card shell + renderer dispatch
├── widget_kpi.php — stats-card tile
├── widget_chart.php — canvas + data attributes (bar/line/donut)
├── widget_rows.php — table / list / feed
├── widget_progress.php — completion ratio bar
├── legacy.php — the pre-existing shared dashboard, kept as fallback
└── widgets.php — original placeholder (now superseded)
```
Also touched: `app/Core/Registries/WidgetRegistry.php` (added `getVisible()`, `allows()`),
`app/Shared/Layout/main.php` (loads Chart.js), `public/assets/js/dashboard.js` (new),
`public/assets/css/main.css` (appended the `.dash-*` block).
---
## Entry Points
| Route | Handler | Permission | Notes |
|---|---|---|---|
| `GET /dashboard` | `DashboardController@index` | `null` | Open to any authenticated employee — access is decided per widget, not per route |
| `GET /dashboard/widget/{key}` | `DashboardController@widget` | `null` | Hydrates one lazy widget; **re-checks the widget's own permission** |
---
## Resolution Flow
```
currentEmployee()
└─ getRoleCodes() employee_roles ⋈ roles (active, unexpired)
└─ union of role_presets[role] headline[] + widgets[], deduped
└─ headline removed from body so nothing renders twice
└─ WidgetRegistry::getVisible(getAllPermissions(), keys)
permission === null OR '*' in perms OR perm in perms
└─ headline + first 6 body widgets execute now
remaining emit skeletons, hydrated over XHR
```
If the union is empty — no roles, or only unmapped roles — the service returns
`['legacy' => true, 'data' => getData()]` and the view renders `_partials/legacy.php`.
Nobody reaches a blank page.
`getAllPermissions()` (Users module) resolves role permissions, inherited parent-role
permissions, direct grants, and direct denials. Super admin carries the literal `*`.
---
## Widget Definition Contract
```php
'executive_revenue_mtd' => [
'title_ar' => 'إيرادات الشهر الجاري',
'type' => 'kpi', // kpi|bar_chart|line_chart|donut|table|list|feed|progress
'permission' => 'payment.view', // must be a key registered via PermissionRegistry
'section' => 'revenue', // must exist in sections.php
'icon' => 'trending-up', // lucide icon name
'color' => 'success', // primary|success|danger|warning (stats-card variant)
'drill_link' => '/payments', // or null
'sql' => 'SELECT ...',
'params' => static fn(array $ctx): array => [date('Y-m-01'), $ctx['employee_id']],
],
```
`params` is a **closure**, not an array. It is evaluated per request so `date()` is never
frozen at load time, and `$ctx['employee_id']` supplies the current employee to the 22
employee-scoped params without the config file calling framework code.
---
## Column Labels
`labels.php` maps each widget's SQL columns to an Arabic heading plus a render role:
| role | meaning |
|---|---|
| `primary` | the single headline number of a KPI (exactly one per KPI widget) |
| `secondary` | supporting figure, rendered as a small chip |
| `label` | row caption / chart x-axis category |
| `value` | number plotted in a chart or shown as a row's main figure |
| `link` | URL or id used to build a drill-through — **never displayed** |
| `hidden` | internal plumbing (sort keys, raw ids) — **never displayed** |
This exists because the UI is Arabic-first while SQL aliases are English. Columns that are
not `AS`-aliased (e.g. `d.name_ar`) are included — the column list was harvested by executing
every query against the live database, not by parsing the SQL.
---
## Chart Data Shapes
`widget_chart.php` handles three shapes and picks automatically:
- **pivot** — one row of counts, each column becomes a category. Used by funnels and status
splits (`membership_sales_funnel`, `executive_support_backlog`).
- **grouped** — two `label` columns; the second splits the data into one series per distinct
value (`executive_revenue_by_stream_trend`: month × stream).
- **plain** — one `label` column plus one or more `value` columns.
---
## Performance
Super admin's preset is the largest at 5 KPIs + 16 widgets. Only the headline plus the first
6 body widgets (`EAGER_WIDGETS`) query on page load — 11 queries — and the remaining 10
hydrate lazily through `/dashboard/widget/{key}` as they scroll into view (IntersectionObserver,
200px margin). Every query is individually wrapped in try/catch returning `[]`, so a missing
table renders one empty card rather than a 500.
`dashboard_snapshots` is **not** a cache for this module. It is an empty, enum-scoped
(`club|sport|facility`) SportsDashboard artifact — do not repurpose it.
---
## Risk Areas
- **SQL is generated, not hand-written.** 46 of the 144 queries were corrected during
verification (wrong period comparisons, missing `is_archived` filters, arrears owed by
deceased members). Do not edit SQL in `widgets.php` without re-running it against the live
database.
- **Permission drift.** A widget whose permission is later removed from a role silently
disappears for that role. That is the intended failure mode, but it means the preset and
`role_permissions` should be re-checked when roles change.
- **Label coverage.** A new widget with no `labels.php` entry still renders — the partials
fall back to convention (first column is the caption, numerics are values) — but table
headers will show English aliases.
---
## Dead Code
`Views/_partials/widgets.php` is the original 3-line placeholder. It is no longer included by
anything and can be deleted once nothing references it.
...@@ -2322,3 +2322,225 @@ code { ...@@ -2322,3 +2322,225 @@ code {
opacity: 1; opacity: 1;
transform: translateX(50%) translateY(0); transform: translateX(50%) translateY(0);
} }
/* ══════════════════════════════════════════════════
ROLE DASHBOARD
Widget grid, sections, charts, lists and skeletons.
══════════════════════════════════════════════════ */
.dash-headline { margin-bottom: 32px; }
.dash-section { margin-bottom: 34px; }
.dash-section-title {
font-size: 15px;
font-weight: 700;
color: var(--text-secondary);
margin: 0 0 14px;
padding-right: 12px;
border-right: 3px solid var(--brand-primary);
line-height: 1.4;
}
.dash-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 20px;
align-items: start;
}
.dash-span-1 { grid-column: span 1; }
.dash-span-2 { grid-column: span 2; }
@media (max-width: 1400px) {
.dash-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dash-span-2 { grid-column: span 2; }
}
@media (max-width: 760px) {
.dash-grid { grid-template-columns: minmax(0, 1fr); }
.dash-span-1, .dash-span-2 { grid-column: span 1; }
}
.dash-widget {
min-width: 0;
transition: box-shadow var(--duration-normal) var(--ease-out),
transform var(--duration-normal) var(--ease-out);
}
.dash-widget.card:hover { box-shadow: var(--shadow-lg); transform: translateY(-2px); }
.dash-widget-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dash-widget-head h3 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.dash-widget-head h3 svg { width: 16px; height: 16px; color: var(--brand-primary); flex-shrink: 0; }
.dash-widget-more {
font-size: 12px;
font-weight: 600;
color: var(--brand-primary);
text-decoration: none;
white-space: nowrap;
opacity: 0;
transition: opacity var(--duration-fast) ease;
}
.dash-widget:hover .dash-widget-more { opacity: 1; }
.dash-widget-body { min-height: 90px; }
/* ── KPI supporting figures ── */
.dash-kpi-sub { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.dash-kpi-chip {
display: inline-flex;
align-items: baseline;
gap: 5px;
font-size: 11px;
padding: 3px 9px;
border-radius: var(--radius-full);
background: var(--surface-bg);
border: 1px solid var(--border-light);
color: var(--text-secondary);
}
.dash-kpi-chip-label { color: var(--text-muted); }
.dash-kpi-chip strong { color: var(--text-primary); font-weight: 700; }
/* ── Charts ── */
.dash-chart { position: relative; height: 240px; }
.dash-widget--donut .dash-chart { height: 260px; }
/* ── Tables ── */
.dash-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.dash-table th {
text-align: right;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 0 10px 8px;
border-bottom: 1px solid var(--border-light);
white-space: nowrap;
}
.dash-table td {
padding: 9px 10px;
border-bottom: 1px solid var(--surface-bg);
color: var(--text-primary);
white-space: nowrap;
}
.dash-table tbody tr:last-child td { border-bottom: 0; }
.dash-table tbody tr { transition: background var(--duration-fast) ease; }
.dash-table tbody tr:hover { background: var(--surface-bg); }
.dash-row-link { cursor: pointer; }
/* ── Lists ── */
.dash-list { list-style: none; margin: 0; padding: 0; }
.dash-list-item { border-bottom: 1px solid var(--surface-bg); }
.dash-list-item:last-child { border-bottom: 0; }
.dash-list-item > a,
.dash-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 2px;
text-decoration: none;
color: inherit;
}
.dash-list-item > a { width: 100%; padding: 0; }
.dash-list-item > a:hover .dash-list-caption { color: var(--brand-primary); }
.dash-list-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.dash-list-caption {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color var(--duration-fast) ease;
}
.dash-list-meta { font-size: 11px; color: var(--text-muted); }
.dash-list-value {
font-size: 13px;
font-weight: 700;
color: var(--brand-primary);
white-space: nowrap;
}
.dash-more-count {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--border-light);
font-size: 11px;
color: var(--text-muted);
text-align: center;
}
/* ── Progress ── */
.dash-progress { padding: 6px 0; }
.dash-progress-figures { display: flex; align-items: baseline; gap: 8px; margin-bottom: 12px; }
.dash-progress-num { font-size: 24px; font-weight: 800; color: var(--text-primary); letter-spacing: -0.02em; }
.dash-progress-den { font-size: 12px; color: var(--text-muted); }
.dash-progress-track {
height: 10px;
border-radius: var(--radius-full);
background: var(--surface-bg);
overflow: hidden;
}
.dash-progress-fill {
height: 100%;
border-radius: var(--radius-full);
transition: width var(--duration-slow) var(--ease-out);
}
.dash-progress-fill--success { background: linear-gradient(90deg, var(--success), #34d399); }
.dash-progress-fill--warning { background: linear-gradient(90deg, var(--warning), #fbbf24); }
.dash-progress-fill--danger { background: linear-gradient(90deg, var(--danger), #f87171); }
.dash-progress-pct { margin-top: 8px; font-size: 12px; font-weight: 700; text-align: left; }
.dash-progress-pct--success { color: var(--success); }
.dash-progress-pct--warning { color: var(--warning); }
.dash-progress-pct--danger { color: var(--danger); }
/* ── Empty + loading ── */
.dash-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 34px 0;
color: var(--text-muted);
font-size: 12px;
}
.dash-empty svg { width: 26px; height: 26px; opacity: 0.5; }
.dash-skeleton { display: flex; flex-direction: column; gap: 10px; padding: 8px 0; }
.dash-skeleton span,
.dash-skeleton-text {
display: block;
height: 12px;
border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--surface-bg) 25%, var(--border-light) 37%, var(--surface-bg) 63%);
background-size: 400% 100%;
animation: dash-shimmer 1.4s ease-in-out infinite;
}
.dash-skeleton span:nth-child(2) { width: 78%; }
.dash-skeleton span:nth-child(3) { width: 55%; }
.dash-skeleton-text { height: 26px; width: 110px; }
@keyframes dash-shimmer {
0% { background-position: 100% 50%; }
100% { background-position: 0 50%; }
}
@media (prefers-reduced-motion: reduce) {
.dash-skeleton span,
.dash-skeleton-text { animation: none; }
.dash-widget, .dash-progress-fill { transition: none; }
}
/** /**
* Dashboard JS — Phase 15 * Dashboard — chart rendering and lazy widget hydration.
* Minimal chart rendering and dashboard interactions. *
* Widgets past the eager slice render as skeletons and fetch their own data, so a
* slow query delays one card instead of the whole page.
*/ */
(function() { (function () {
'use strict'; 'use strict';
document.addEventListener('DOMContentLoaded', function() { var CSS = getComputedStyle(document.documentElement);
// Auto-refresh dashboard every 5 minutes var token = function (name, fallback) {
if (window.location.pathname === '/dashboard') { var v = CSS.getPropertyValue(name);
setTimeout(function() { return (v && v.trim()) || fallback;
window.location.reload(); };
}, 300000);
var PALETTE = [
token('--brand-primary', '#0D7377'),
token('--brand-accent', '#6366f1'),
token('--success', '#059669'),
token('--warning', '#d97706'),
token('--info', '#0284c7'),
token('--danger', '#dc2626'),
token('--brand-primary-light', '#14b8a6')
];
var TEXT_MUTED = token('--text-muted', '#94a3b8');
var BORDER = token('--border-light', '#e2e8f0');
function hexToRgba(hex, alpha) {
var h = String(hex).trim().replace('#', '');
if (h.length === 3) { h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]; }
var n = parseInt(h, 16);
if (isNaN(n)) { return 'rgba(13,115,119,' + alpha + ')'; }
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
}
function parseAttr(el, name, fallback) {
var raw = el.getAttribute(name);
if (!raw) { return fallback; }
try { return JSON.parse(raw); } catch (e) { return fallback; }
}
var BASE_OPTS = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 600, easing: 'easeOutQuart' },
plugins: {
legend: {
display: false,
labels: { font: { family: 'Cairo' }, color: TEXT_MUTED }
},
tooltip: {
rtl: true,
textDirection: 'rtl',
backgroundColor: 'rgba(15,15,26,0.92)',
titleFont: { family: 'Cairo', size: 12 },
bodyFont: { family: 'Cairo', size: 12 },
padding: 10,
cornerRadius: 8,
displayColors: false
}
}
};
function axisScales(stacked) {
return {
x: {
stacked: !!stacked,
grid: { display: false },
ticks: { font: { family: 'Cairo', size: 11 }, color: TEXT_MUTED, maxRotation: 0, autoSkip: true }
},
y: {
stacked: !!stacked,
position: 'right',
beginAtZero: true,
border: { display: false },
grid: { color: BORDER, drawTicks: false },
ticks: {
font: { family: 'Cairo', size: 11 },
color: TEXT_MUTED,
padding: 8,
callback: function (v) {
if (Math.abs(v) >= 1000000) { return (v / 1000000).toFixed(1) + 'M'; }
if (Math.abs(v) >= 1000) { return (v / 1000).toFixed(0) + 'K'; }
return v;
}
}
}
};
}
function buildChart(canvas) {
if (typeof Chart === 'undefined' || canvas.dataset.rendered) { return; }
var kind = canvas.getAttribute('data-chart') || 'bar_chart';
var labels = parseAttr(canvas, 'data-labels', []);
var series = parseAttr(canvas, 'data-series', []);
if (!labels.length || !series.length) { return; }
var cfg;
if (kind === 'donut') {
cfg = {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: series[0].data,
backgroundColor: labels.map(function (_, i) { return PALETTE[i % PALETTE.length]; }),
borderWidth: 0,
hoverOffset: 6
}]
},
options: Object.assign({}, BASE_OPTS, {
cutout: '62%',
plugins: Object.assign({}, BASE_OPTS.plugins, {
legend: {
display: true,
position: 'bottom',
labels: {
font: { family: 'Cairo', size: 11 },
color: TEXT_MUTED,
boxWidth: 10,
boxHeight: 10,
usePointStyle: true,
pointStyle: 'circle',
padding: 12
}
}
})
})
};
} else if (kind === 'line_chart') {
cfg = {
type: 'line',
data: {
labels: labels,
datasets: series.map(function (s, i) {
var c = PALETTE[i % PALETTE.length];
return {
label: s.label,
data: s.data,
borderColor: c,
backgroundColor: hexToRgba(c, 0.12),
borderWidth: 2.5,
pointRadius: 0,
pointHoverRadius: 5,
pointHoverBackgroundColor: c,
pointHoverBorderColor: '#fff',
pointHoverBorderWidth: 2,
tension: 0.38,
fill: true
};
})
},
options: Object.assign({}, BASE_OPTS, {
interaction: { mode: 'index', intersect: false },
scales: axisScales(false),
plugins: Object.assign({}, BASE_OPTS.plugins, {
legend: { display: series.length > 1, position: 'bottom',
labels: { font: { family: 'Cairo', size: 11 }, color: TEXT_MUTED,
boxWidth: 10, usePointStyle: true, pointStyle: 'circle', padding: 12 } }
})
})
};
} else {
var stacked = series.length > 1;
cfg = {
type: 'bar',
data: {
labels: labels,
datasets: series.map(function (s, i) {
return {
label: s.label,
data: s.data,
backgroundColor: PALETTE[i % PALETTE.length],
borderRadius: 6,
borderSkipped: false,
maxBarThickness: 34
};
})
},
options: Object.assign({}, BASE_OPTS, {
scales: axisScales(stacked),
plugins: Object.assign({}, BASE_OPTS.plugins, {
legend: { display: stacked, position: 'bottom',
labels: { font: { family: 'Cairo', size: 11 }, color: TEXT_MUTED,
boxWidth: 10, usePointStyle: true, pointStyle: 'circle', padding: 12 } }
})
})
};
}
new Chart(canvas, cfg);
canvas.dataset.rendered = '1';
}
function renderAllCharts(root) {
(root || document).querySelectorAll('canvas[data-chart]').forEach(buildChart);
}
/* ── Lazy hydration ── */
function hydrate(el) {
var key = el.getAttribute('data-widget');
if (!key || el.dataset.hydrating) { return; }
el.dataset.hydrating = '1';
fetch('/dashboard/widget/' + encodeURIComponent(key), {
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin'
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
.then(function (payload) { paint(el, payload); })
.catch(function () { fail(el); })
.finally(function () { el.removeAttribute('data-lazy'); });
}
/**
* The server renders through the same partial the eager path uses, so a lazily
* loaded chart is built exactly like an eager one and the client needs no
* knowledge of column labels or widget types.
*/
function paint(el, payload) {
var body = el.querySelector('.dash-widget-body');
if (!body || typeof payload.html !== 'string') { return; }
body.innerHTML = payload.html;
renderAllCharts(body);
if (window.lucide) { lucide.createIcons(); }
}
function fail(el) {
var body = el.querySelector('.dash-widget-body');
if (body) {
body.innerHTML = '<div class="dash-empty"><i data-lucide="wifi-off"></i><span>تعذر تحميل البيانات</span></div>';
if (window.lucide) { lucide.createIcons(); }
}
}
function init() {
renderAllCharts();
var lazy = document.querySelectorAll('[data-lazy="1"]');
if (!lazy.length) { return; }
if ('IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
io.unobserve(entry.target);
hydrate(entry.target);
} }
}); });
}, { rootMargin: '200px 0px' });
lazy.forEach(function (el) { io.observe(el); });
} else {
lazy.forEach(hydrate);
}
}
// Row-level drill-through for table rows carrying a link.
document.addEventListener('click', function (e) {
var row = e.target.closest('.dash-row-link');
if (row && row.dataset.href && !e.target.closest('a')) {
window.location.href = row.dataset.href;
}
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})(); })();
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