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
{
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
This diff is collapsed.
This diff is collapsed.
<?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);
namespace App\Modules\Dashboard\Controllers;
use App\Core\Controller;
use App\Core\Registries\WidgetRegistry;
use App\Core\Request;
use App\Core\Response;
use App\Modules\Dashboard\Services\DashboardDataService;
......@@ -12,7 +13,73 @@ class DashboardController extends Controller
{
public function index(Request $request): Response
{
$data = DashboardDataService::getData();
return $this->view('Dashboard.Views.index', $data);
$employee = $this->currentEmployee();
$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,
]);
}
}
\ No newline at end of file
}
......@@ -3,4 +3,5 @@ declare(strict_types=1);
return [
['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);
namespace App\Modules\Dashboard\Services;
use App\Core\App;
use App\Core\Registries\WidgetRegistry;
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
{
$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 @@
<?php $__template->section('title'); ?>لوحة التحكم<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Summary Cards -->
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:15px;margin-bottom:25px;">
<div class="card" style="padding:20px;border-right:4px solid #0D7377;">
<div style="font-size:28px;font-weight:700;color:#0D7377;"><?= number_format($total_active ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">أعضاء نشطون</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #059669;">
<div style="font-size:28px;font-weight:700;color:#059669;"><?= number_format($new_this_month ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">جدد هذا الشهر</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #0284C7;">
<div style="font-size:28px;font-weight:700;color:#0284C7;"><?= money($total_revenue_month ?? '0') ?></div>
<div style="color:#6B7280;font-size:13px;">إيرادات الشهر</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #D97706;">
<div style="font-size:28px;font-weight:700;color:#D97706;"><?= (int) ($pending_interviews ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">مقابلات معلقة</div>
</div>
<div class="card" style="padding:20px;border-right:4px solid #DC2626;">
<div style="font-size:28px;font-weight:700;color:#DC2626;"><?= (int) ($overdue_installments ?? 0) ?></div>
<div style="color:#6B7280;font-size:13px;">أقساط متأخرة</div>
</div>
</div>
<?php if (!empty($__legacy)): ?>
<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>
<?php $__template->include('Dashboard.Views._partials.legacy', get_defined_vars()); ?>
<!-- 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>
<?php else: ?>
<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>
<?php $__dashLabels = \App\Modules\Dashboard\Services\DashboardDataService::labels(); ?>
<?php if (!empty($headline)): ?>
<div class="stats-grid dash-headline">
<?php foreach ($headline as $key => $w): ?>
<?php $__template->include('Dashboard.Views._partials.widget', compact('key', 'w', '__dashLabels')); ?>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?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 foreach (($sections ?? []) as $sectionKey => $section): ?>
<section class="dash-section" data-section="<?= e($sectionKey) ?>">
<h2 class="dash-section-title"><?= e($section['label']) ?></h2>
<div class="dash-grid">
<?php foreach ($section['widgets'] as $key => $w): ?>
<?php $__template->include('Dashboard.Views._partials.widget', compact('key', 'w', '__dashLabels')); ?>
<?php endforeach; ?>
<?php if (empty($recent_activity)): ?><div style="padding:20px;text-align:center;color:#9CA3AF;">لا يوجد نشاط</div><?php endif; ?>
</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 $__template->endSection(); ?>
\ No newline at end of file
<?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(); ?>
......@@ -2,6 +2,12 @@
declare(strict_types=1);
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', [
'label_ar' => 'لوحة التحكم',
......
......@@ -30,6 +30,10 @@ $currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
<!-- Lucide Icons -->
<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 -->
<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>
......
......@@ -400,3 +400,56 @@ Super admin check: `employee_roles JOIN roles WHERE role_code = 'super_admin'`
- `hr.biometric.*` — Biometric devices
- `hr.report.*` — Reports
- `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 {
opacity: 1;
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; }
}
This diff is collapsed.
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