Skip to content

22 — Dashboard

Table of Contents

  1. Dashboard Architecture
  2. DashboardController::index()
  3. Stat Cards Reference
  4. Online Now Metric
  5. View As Feature
  6. Dashboard.jsx Component Structure
  7. Recent Payments Table
  8. Expiry Alerts Section
  9. Admin vs Dealer View Differences
  10. Revenue Calculation
  11. Outstanding Dues Calculation
  12. Dashboard Refresh Strategy

Dashboard Architecture

The PyroRadius dashboard is built on the standard Inertia.js pattern: the DashboardController::index() method runs all necessary queries server-side and returns a single Inertia response with all stat card values, recent data, and alerts as props. The React component receives these props and renders them without additional API calls on page load.

Browser → GET /admin/dashboard
        DashboardController::index()
              ├─ Determine context (admin global or dealer-scoped via session)
              ├─ Run stat queries (customers, revenue, outstanding, online)
              ├─ Fetch recent_payments (last 10)
              ├─ Fetch expiry_alerts (expiring in 7 days)
              └─ return Inertia::render('Dashboard', [...props])
                   Dashboard.jsx
                   renders stat cards, tables, alerts

The dashboard does not use a separate JSON API. All data is embedded in the initial page load as Inertia shared props. Refreshing the page re-runs all queries.


DashboardController::index()

Class: App\Http\Controllers\DashboardController
Route: GET /admin/dashboard
Middleware: auth, verified

public function index(): Response
{
    $context   = $this->resolveContext(); // admin-wide or dealer-scoped
    $dealerId  = $context['dealer_id'];   // null = admin global view

    return Inertia::render('Dashboard', [
        // Stat cards
        'stats' => [
            'total_customers'   => $this->countCustomers($dealerId),
            'active_customers'  => $this->countCustomers($dealerId, 'active'),
            'expired_customers' => $this->countCustomers($dealerId, 'expired'),
            'suspended'         => $this->countCustomers($dealerId, 'suspended'),
            'online_now'        => $this->getOnlineNow($dealerId),
            'monthly_revenue'   => $this->getMonthlyRevenue($dealerId),
            'outstanding_dues'  => $this->getOutstandingDues($dealerId),
        ],

        // Tables / lists
        'recent_payments'   => $this->getRecentPayments($dealerId),
        'expiry_alerts'     => $this->getExpiryAlerts($dealerId),

        // View As context
        'view_as'           => $context,
        'dealers'           => $this->getDealersForViewAs(), // null for dealer role
    ]);
}

resolveContext()

Determines whose data to show based on the authenticated user's role and any active "View As" session:

private function resolveContext(): array
{
    $user = auth()->user();

    // Dealer role — always scoped to own dealer
    if ($user->isDealer()) {
        return ['dealer_id' => $user->dealer_id, 'dealer_name' => $user->dealer->name, 'is_view_as' => false];
    }

    // Admin with View As active
    $viewAsId = session('view_as_dealer_id');
    if ($viewAsId) {
        $dealer = Dealer::findOrFail($viewAsId);
        return ['dealer_id' => $dealer->id, 'dealer_name' => $dealer->name, 'is_view_as' => true];
    }

    // Admin global view
    return ['dealer_id' => null, 'dealer_name' => null, 'is_view_as' => false];
}

Stat Cards Reference

Stat Card Label Source Scope
total_customers Total Customers COUNT(*) on customers Dealer-scoped or all
active_customers Active COUNT(*) WHERE status='active' Dealer-scoped or all
expired_customers Expired COUNT(*) WHERE status='expired' Dealer-scoped or all
suspended Suspended COUNT(*) WHERE status='suspended' Dealer-scoped or all
online_now Online Now Redis cache key (see below) Dealer-scoped or all
monthly_revenue Monthly Revenue SUM(payments.amount) this calendar month Dealer-scoped or all
outstanding_dues Outstanding SUM(invoices.amount) WHERE status=unpaid Dealer-scoped or all

countCustomers() Helper

private function countCustomers(?int $dealerId, ?string $status = null): int
{
    return Customer::query()
        ->when($dealerId, fn($q, $v) => $q->where('dealer_id', $v))
        ->when($status,   fn($q, $v) => $q->where('status', $v))
        ->count();
}

Online Now Metric

The Online Now counter shows the number of PPPoE sessions currently active across all NAS devices associated with the admin (or dealer). This is the most frequently referenced real-time metric in PyroRadius.

Data Source

Online Now is served from a Redis cache key, not from a live RouterOS API call on page load. Live API calls would make the dashboard slow and could overload NAS devices under heavy traffic.

Cache Population

The PollTraffic artisan command (scheduled every 1 minute) calls RouterOsService::totalActivePPPoE() for each NAS, then stores the result:

// Inside PollTraffic command handle()
$totalOnline = 0;
foreach (Nas::where('enabled', true)->get() as $nas) {
    $count = $this->routerOs->totalActivePPPoE($nas);
    $totalOnline += $count;

    // Per-NAS cache
    Cache::put("online_now_nas_{$nas->id}", $count, now()->addMinutes(5));
}

// System-wide total
Cache::put('online_now_total', $totalOnline, now()->addMinutes(5));

// Per-dealer totals (for dealer-scoped dashboard)
foreach (Dealer::all() as $dealer) {
    $dealerOnline = $this->calculateDealerOnline($dealer);
    Cache::put("online_now_dealer_{$dealer->id}", $dealerOnline, now()->addMinutes(5));
}

RouterOsService::totalActivePPPoE()

Connects to the NAS via the RouterOS API (using the routeros-api package or direct socket) and runs:

/ppp/active/print

Counts the returned active sessions and returns the integer count.

Reading Online Now in the Dashboard

private function getOnlineNow(?int $dealerId): int
{
    if ($dealerId) {
        return (int) Cache::get("online_now_dealer_{$dealerId}", 0);
    }
    return (int) Cache::get('online_now_total', 0);
}

If the cache key has expired (e.g., the PollTraffic command has not run in over 5 minutes), the value falls back to 0 rather than triggering a live API call. The UI renders "Online Now: 0" and a stale-data indicator if the last poll was more than 2 minutes ago.

Stale Data Indicator

The dashboard passes the last poll timestamp:

'online_now_updated_at' => Cache::get('online_now_updated_at'),

Dashboard.jsx renders a small "as of X minutes ago" note next to the Online Now card. If online_now_updated_at is more than 3 minutes old, the card border turns amber.


View As Feature

Purpose: Allows super admins to view the dashboard as if they were a specific dealer, seeing only that dealer's customers, revenue, and stats. Used for troubleshooting dealer-reported discrepancies.

How It Works

  1. Admin clicks the View As dropdown in the dashboard header.
  2. Dropdown lists all dealers (fetched from getDealersForViewAs()).
  3. Selecting a dealer calls viewAs($dealerId).
  4. The controller validates the target is a real dealer, stores dealer_id in the session.
  5. All subsequent dashboard loads use this dealer scope until reset.

viewAs() Method

public function viewAs(Request $request): RedirectResponse
{
    $this->authorize('admin.view-as'); // super admin only

    $dealerId = $request->input('dealer_id');

    if ($dealerId === 'admin') {
        // Reset to global admin view
        session()->forget('view_as_dealer_id');
        return redirect()->route('dashboard')->with('success', 'Viewing as: Admin (global)');
    }

    $dealer = Dealer::findOrFail($dealerId);

    // Validate target is actually a dealer
    if (! $dealer->users()->where('role', 'dealer')->exists()) {
        abort(403, 'Target is not a dealer account.');
    }

    session(['view_as_dealer_id' => $dealer->id]);

    return redirect()->route('dashboard')
        ->with('success', "Viewing as: {$dealer->name}");
}

Route: POST /admin/dashboard/view-as

View As Banner

When a View As session is active, Dashboard.jsx renders a visible amber banner at the top:

[Viewing as: City Fiber Networks] [Exit View As →]

Clicking "Exit View As" calls viewAs with dealer_id = 'admin'.

Session Key

view_as_dealer_id is stored in the Laravel session (not a cookie). It persists for the duration of the browser session or until explicitly cleared via "Exit View As".

getDealersForViewAs()

private function getDealersForViewAs(): ?Collection
{
    if (! auth()->user()->isSuperAdmin()) {
        return null; // dealers don't get this list
    }
    return Dealer::select('id', 'name')->orderBy('name')->get();
}

Dashboard.jsx Component Structure

Path: resources/js/Pages/Dashboard.jsx

<Dashboard>
  ├── <ViewAsBanner> (visible only when view_as.is_view_as = true)
  ├── <StatCardGrid>
  │     ├── <StatCard label="Total Customers"  value={stats.total_customers}  icon={UsersIcon}    color="blue" />
  │     ├── <StatCard label="Active"           value={stats.active_customers} icon={CheckIcon}    color="green" />
  │     ├── <StatCard label="Expired"          value={stats.expired_customers} icon={ClockIcon}   color="yellow" />
  │     ├── <StatCard label="Suspended"        value={stats.suspended}        icon={BanIcon}      color="red" />
  │     ├── <StatCard label="Online Now"       value={stats.online_now}       icon={WifiIcon}     color="emerald"
  │     │             subtitle={`as of ${onlineUpdatedAt}`} stale={isStale} />
  │     ├── <StatCard label="Monthly Revenue"  value={formatPKR(stats.monthly_revenue)} icon={CashIcon}   color="indigo" />
  │     └── <StatCard label="Outstanding"      value={formatPKR(stats.outstanding_dues)} icon={AlertIcon} color="orange" />
  ├── <DashboardGrid>  {/* Two-column layout on desktop */}
  │     ├── <RecentPaymentsTable payments={recent_payments} />
  │     └── <ExpiryAlertsPanel  alerts={expiry_alerts} />
  └── <ViewAsDropdown dealers={dealers} currentViewAs={view_as} />
         (rendered in page header, not inline)

StatCard Component

function StatCard({ label, value, icon: Icon, color, subtitle, stale }) {
  return (
    <div className={`rounded-xl border p-6 ${stale ? 'border-amber-400' : 'border-gray-200'}`}>
      <div className="flex items-center justify-between">
        <span className="text-sm text-gray-500">{label}</span>
        <Icon className={`h-5 w-5 text-${color}-500`} />
      </div>
      <div className="mt-2 text-3xl font-bold text-gray-900">{value}</div>
      {subtitle && <p className="mt-1 text-xs text-gray-400">{subtitle}</p>}
    </div>
  );
}

Recent Payments Table

Displays the 10 most recent payments system-wide (or dealer-scoped).

Data Query

private function getRecentPayments(?int $dealerId): array
{
    return Payment::query()
        ->with(['customer:id,name', 'invoice:id,invoice_number', 'recordedBy:id,name'])
        ->when($dealerId, fn($q, $v) => $q->whereRelation('customer', 'dealer_id', $v))
        ->orderByDesc('created_at')
        ->limit(10)
        ->get()
        ->map(fn($p) => [
            'id'             => $p->id,
            'customer_name'  => $p->customer->name,
            'amount'         => $p->amount,
            'invoice_number' => $p->invoice?->invoice_number,
            'recorded_by'    => $p->recordedBy?->name,
            'created_at'     => $p->created_at->diffForHumans(),
        ])
        ->toArray();
}

Table Columns

Column Source
Customer customer.name
Invoice invoice.invoice_number
Amount payments.amount formatted as PKR
Received By recorded_by user name
When created_at relative time

Expiry Alerts Section

Shows customers expiring within the next 7 days to prompt proactive collection follow-up.

Data Query

private function getExpiryAlerts(?int $dealerId): array
{
    return Customer::query()
        ->where('status', 'active')
        ->whereBetween('expiry_date', [now()->toDateString(), now()->addDays(7)->toDateString()])
        ->with(['package:id,name', 'dealer:id,name'])
        ->when($dealerId, fn($q, $v) => $q->where('dealer_id', $v))
        ->orderBy('expiry_date')
        ->limit(20)
        ->get()
        ->map(fn($c) => [
            'id'          => $c->id,
            'name'        => $c->name,
            'phone'       => $c->mobile,
            'package'     => $c->package->name,
            'expiry_date' => $c->expiry_date->format('d M Y'),
            'days_left'   => now()->diffInDays($c->expiry_date),
        ])
        ->toArray();
}

Display

The Expiry Alerts panel shows each customer with a colour-coded days-remaining badge:

  • 0–2 days → red
  • 3–5 days → amber
  • 6–7 days → yellow

Each row has a quick link to the customer's profile.


Admin vs Dealer View Differences

Element Admin (Global) Admin (View As Dealer) Dealer
Stat cards System-wide totals That dealer's totals Own totals
View As dropdown Visible Visible (can switch) Hidden
View As banner Hidden Visible (amber) Hidden
Dealer-wise link Visible Hidden Hidden
Recent payments All dealers That dealer only Own only
Expiry alerts All dealers That dealer only Own only

Revenue Calculation

Monthly Revenue = sum of all payments with created_at in the current calendar month.

private function getMonthlyRevenue(?int $dealerId): float
{
    return Payment::query()
        ->whereYear('created_at', now()->year)
        ->whereMonth('created_at', now()->month)
        ->when($dealerId, fn($q, $v) => $q->whereRelation('customer', 'dealer_id', $v))
        ->sum('amount');
}

What is included: - All payment records regardless of method (cash, bank, online) - Partial payments - Overpayments (recorded as separate payment records)

What is excluded: - Cancelled payments - Credit notes (stored separately in a credits table if applicable)


Outstanding Dues Calculation

Outstanding Dues = sum of invoices.amount for all invoices where status IN ('unpaid', 'overdue').

private function getOutstandingDues(?int $dealerId): float
{
    return Invoice::query()
        ->whereIn('status', ['unpaid', 'overdue'])
        ->when($dealerId, fn($q, $v) => $q->whereRelation('customer', 'dealer_id', $v))
        ->sum('amount');
}

Note: Partial payments reduce the invoice balance_due field, not the amount field. If partial payment tracking is needed in the outstanding figure, the query should sum balance_due instead of amount. Check invoices table schema for current implementation.


Dashboard Refresh Strategy

The dashboard does not auto-refresh by default. Staff refresh the page manually to get updated figures. The Online Now metric is the only value with a visible freshness indicator.

Planned Enhancement

A future enhancement (tracked in pending tasks) is to add a Refresh button that re-fetches only the stat card values via a lightweight JSON endpoint, without a full page reload. This would use Inertia's router.reload({ only: ['stats'] }) partial reload feature.


Last updated: 2026-07-13