Documentation Portal

Dashboard View Model Implementation

Sentinel

Dashboard View Model Implementation

View Model Implementation This is how the Laravel view model is used with the spares dashboard to remove all the logic from the blade view

The DashboardViewModel

<?php

namespace Modules\Spares\ViewModels;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Modules\Spares\Repositories\API\APIStorageRepository;
use Psr\SimpleCache\InvalidArgumentException;
use Throwable;

final class DashboardViewModel
{
    public array $returns = [];
    public array $returnsLabels = [];
    public array $returnsValues = [];
    public float $totalPOValue = 0.0;

    public array $inventory = [];
    public array $inventoryLabels = [];
    public array $inventoryValues = [];
    public float $totalInventoryValue = 0.0;

    public array $issues = [];
    public array $issuesLabels = [];
    public array $issuesValues = [];
    public float $totalIssuesValue = 0.0;

    public array $stock = [];
    public array $stockLabels = [];
    public array $stockValues = [];
    public float $totalStockValue = 0.0;
    public float $totalStockValueLastMonth = 0.0;

    public float $grandTotal = 0.0;

    public array $topTenIssues = [];
    public array $topTenInventory = [];

    /**
     * Logs are normalized for the Blade:
     * [
     *   ['key'=>'inventory','label'=>'<i ...>Adjustments Sync','run_at'=>'14 Oct 2025, 12:30','server_name'=>'maximo01'],
     *   ...
     * ]
     */
    public array $logs = [];

    private APIStorageRepository $api;
    private bool $useCache;
    private int $cacheMinutes;

    public function __construct(APIStorageRepository $api = null, ?bool $useCache = null, int $cacheMinutes = 60)
    {
        $this->api = $api ?? new APIStorageRepository();
        $this->useCache = $useCache ?? (bool)settings()->group('maximo')->get('dashboard_cache', false);
        $this->cacheMinutes = $cacheMinutes;
    }

    /**
     * Build the data ready fro the view
     * @return $this
     * @throws Throwable
     * @throws InvalidArgumentException
     */
    public function build(): self
    {
        $start = microtime(true);
        $apiToken = (string)$this->api->getApiToken();
        $params = [];

        // ------- Returns -------
        $this->returns = $this->fetch('/api/v1/returns/index', $apiToken, $params, 'returns');
        $returnsByWeek = $this->groupByIsoWeek($this->returns, 'SRRTRDT', 'SRRUCST');
        $this->returnsLabels = array_keys($returnsByWeek);
        $this->returnsValues = array_values($returnsByWeek);
        $this->totalPOValue = array_sum($this->returnsValues);

        // ------- Inventory -------
        $this->inventory = $this->fetch('/api/v1/inventory/index', $apiToken, $params, 'inventory');
        $this->totalInventoryValue = (float)(collect($this->inventory)->sum('SPACOST') ?: 0.0);
        $inventoryByWeek = $this->groupByIsoWeek($this->inventory, 'SPATRDT', 'SPACOST');
        $this->inventoryLabels = array_keys($inventoryByWeek);
        $this->inventoryValues = array_values($inventoryByWeek);

        // ------- Issues -------
        $this->issues = $this->fetch('/api/v1/issues/index', $apiToken, $params, 'issues');
        $issuesByWeek = $this->groupByIsoWeek($this->issues, 'SPRTRDT', 'SPRUCST');
        $this->issuesLabels = array_keys($issuesByWeek);
        $this->issuesValues = array_values($issuesByWeek);
        $this->totalIssuesValue = array_sum($this->issuesValues);

        // ------- Stock -------
        $stock = $this->fetch('/api/v1/stock/index', $apiToken, $params, 'stock');
        $stockLastMonth = $this->fetch('/api/v1/stock/index/last_month', $apiToken, $params, 'stock_last_month');

        $totalStockNow = (float)(collect($stock)->sum('SPSRCST') ?: 0.0);
        $this->totalStockValueLastMonth = (float)(collect($stockLastMonth)->sum('SPSRCST') ?: 0.0);
        $this->totalStockValue = $totalStockNow - $this->totalStockValueLastMonth;

        $combinedStock = array_merge(is_array($stock) ? $stock : [], is_array($stockLastMonth) ? $stockLastMonth : []);
        $this->stock = $combinedStock;

        $stockByMonth = $this->groupByMonth($combinedStock, 'SPSTRDT', 'SPSRCST');
        $this->stockLabels = array_keys($stockByMonth);
        $this->stockValues = array_values($stockByMonth);
        array_multisort($this->stockLabels, SORT_ASC, $this->stockValues);

        // ------- Top Tens (defensive) -------
        $this->topTenIssues = $this->fetch('/api/v1/issues/index/top_ten', $apiToken, $params, 'top_ten_issues');
        $this->topTenInventory = $this->fetch('/api/v1/inventory/index/top_ten', $apiToken, $params,
            'top_ten_inventory');

        // ------- Logs (with label mapping & formatted timestamps) -------
        $labelMap = [
            'INVENTORY' => '<i class="fa-fw fas fa-sliders-h mr-2"></i>Adjustments Sync',
            'ISSUES' => '<i class="fas fa-exchange-alt mr-2"></i>Item Usage Sync',
            'RETURNS' => '<i class="fa-fw fas fa-thermometer-full mr-2"></i>PO Analysis Sync',
            'STOCK' => '<i class="fa-fw fas fa-warehouse mr-2"></i>Movement Sync',
        ];

        $this->logs = collect(['RETURNS', 'STOCK', 'ISSUES', 'INVENTORY'])
            ->map(function (string $type) use ($labelMap) {
                $raw = (array)$this->api->getRunLog('/api/v1/runlog/last', $type);

                return [
                    'key' => strtolower($type),
                    'label' => $labelMap[$type] ?? e($type),
                    'run_at' => $this->formatDate($raw['run_at'] ?? null),
                    'server_name' => (string)($raw['server_name'] ?? 'N/A'),
                ];
            })
            ->values()
            ->toArray();

        $this->grandTotal = $this->totalPOValue + $this->totalIssuesValue + $this->totalInventoryValue + $this->totalStockValue;

        if (app()->environment(['local', 'dev', 'qat'])) {
            $runtime = round(microtime(true) - $start, 3);
            Log::channel('dashboard')->info(emoji('orange') . "Dashboard ViewModel build() runtime: {$runtime} seconds");
        }

        return $this;
    }

    /**
     * Returns all the variables as an array.
     * @return array
     */
    public function toArray(): array
    {
        return [
            'totalPOValue' => $this->totalPOValue,
            'totalInventoryValue' => $this->totalInventoryValue,
            'totalIssuesValue' => $this->totalIssuesValue,
            'totalStockValueLastMonth' => $this->totalStockValueLastMonth,
            'totalStockValue' => $this->totalStockValue,
            'grandTotal' => $this->grandTotal,

            'inventory' => $this->inventory,
            'inventoryLabels' => $this->inventoryLabels,
            'inventoryValues' => $this->inventoryValues,

            'stock' => $this->stock,
            'stockLabels' => $this->stockLabels,
            'stockValues' => $this->stockValues,

            'issues' => $this->issues,
            'issuesLabels' => $this->issuesLabels,
            'issuesValues' => $this->issuesValues,

            'returns' => $this->returns,
            'returnsLabels' => $this->returnsLabels,
            'returnsValues' => $this->returnsValues,

            'topTenIssues' => $this->topTenIssues,
            'topTenInventory' => $this->topTenInventory,
            'logs' => $this->logs,
        ];
    }

    // ----------------- helpers -----------------

    /**
     * A combined log formatter
     * @param string $url
     * @param string $token
     * @param array $params
     * @param string $name
     * @return array|mixed
     */
    private function fetch(string $url, string $token, array $params, string $name)
    {
        // normalize everything to strings for cache key safety
        $url = (string)$url;
        $name = (string)$name;
        $paramsJson = json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

        $key = $name . '_' . md5($url . '|' . ($paramsJson ?? '[]'));

        if (!$this->useCache) {
            return $this->executeApi($token, $url, $params);
        }

        return Cache::remember($key, now()->addMinutes($this->cacheMinutes), function () use ($token, $url, $params) {
            return $this->executeApi($token, $url, $params);
        });
    }

    /**
     * A common API executor
     * @param string $token
     * @param string $url
     * @param array $params
     * @return array
     * @throws InvalidArgumentException
     */
    private function executeApi(string $token, string $url, array $params)
    {
        try {
            $data = APIStorageRepository::execute($token, $url, $params);
            // Always return an array so downstream code is predictable
            return is_array($data) ? $data : [];
        } catch (Throwable $e) {
            if (app()->environment(['local', 'dev', 'qat'])) {
                Log::channel('dashboard')->warning("API fetch failed for {$url}: {$e->getMessage()}");
            }
            return [];
        }
    }

    /** ISO year-week to avoid cross-year collisions, e.g. '2025-W01'. */
    private function groupByIsoWeek(array $data, string $dateField, string $valueField): array
    {
        return collect($data)
            ->filter(fn($i) => !empty($i[$dateField]) && is_numeric($i[$valueField] ?? null))
            ->groupBy(function ($i) use ($dateField) {
                $d = Carbon::parse($i[$dateField]);
                return $d->format('o-\WW');
            })
            ->map(fn($items) => collect($items)->sum($valueField))
            ->toArray();
    }

    private function groupByMonth(array $data, string $dateField, string $valueField): array
    {
        return collect($data)
            ->filter(fn($i) => !empty($i[$dateField]) && is_numeric($i[$valueField] ?? null))
            ->groupBy(fn($i) => Carbon::parse($i[$dateField])->format('Y-m'))
            ->map(fn($items) => collect($items)->sum($valueField))
            ->toArray();
    }

    /**
     * Nice helper for format dates
     * @param string|null $value
     * @return string
     */
    private function formatDate(?string $value): string
    {
        if (empty($value)) {
            return 'N/A';
        }
        try {
            return Carbon::parse($value)->format('d M Y, H:i');
        } catch (Throwable $e) {
            return 'N/A';
        }
    }
}

And the new controller DashBoardController.php

<?php

namespace Modules\Spares\Http\Controllers;

use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use Modules\Spares\ViewModels\DashboardViewModel;

class DashboardController extends Controller
{

    /**
     * Main View Loader
     * now uses a view model
     * @return View
     *
     */
    public function index(): View
    {
        $dashboardViewModel = (new DashboardViewModel())->build();

        if (app()->environment(['local', 'dev', 'qat'])) {
            $useCache = (bool)settings()->group('maximo')->get('dashboard_cache', false);
            logger()->channel('dashboard')->info(
                $useCache
                    ? emoji('green') . 'Using Cached Data'
                    : emoji('red') . 'Not Using Cached Data'
            );
        }
        return view('spares::Components.dashboard', $dashboardViewModel->toArray());
    }
}

This abstracts the data from the view and makes it far more maintainable and extendable.