22 — Dashboard¶
Table of Contents¶
- Dashboard Architecture
- DashboardController::index()
- Stat Cards Reference
- Online Now Metric
- View As Feature
- Dashboard.jsx Component Structure
- Recent Payments Table
- Expiry Alerts Section
- Admin vs Dealer View Differences
- Revenue Calculation
- Outstanding Dues Calculation
- 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:
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:
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¶
- Admin clicks the View As dropdown in the dashboard header.
- Dropdown lists all dealers (fetched from
getDealersForViewAs()). - Selecting a dealer calls
viewAs($dealerId). - The controller validates the target is a real dealer, stores
dealer_idin the session. - 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:
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