feat(dashboard): add operational overview and charts
This commit is contained in:
@@ -2,66 +2,24 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use App\Services\Dashboard\DashboardService;
|
||||||
use App\Models\Asset;
|
use Illuminate\View\View;
|
||||||
use App\Models\AssetStatus;
|
|
||||||
use App\Models\AssetLocation;
|
|
||||||
use App\Models\AssetMovement;
|
|
||||||
use App\Models\AssetDisposal;
|
|
||||||
use App\Models\AssetAudit;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
class DashboardsController extends Controller
|
class DashboardsController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly DashboardService $dashboard)
|
||||||
public function index()
|
|
||||||
{
|
{
|
||||||
$now = Carbon::now();
|
|
||||||
$last30 = $now->copy()->subDays(30);
|
|
||||||
|
|
||||||
$totalAssets = Asset::count();
|
|
||||||
$movementCount = AssetMovement::where('created_at', '>=', $last30)->count();
|
|
||||||
$disposalCount = AssetDisposal::whereNull('reversed_at')->count();
|
|
||||||
$auditIssues = AssetAudit::whereIn('status', ['missing','damaged'])->count();
|
|
||||||
|
|
||||||
$byStatus = AssetStatus::orderBy('name')
|
|
||||||
->get()
|
|
||||||
->map(function ($status) {
|
|
||||||
return [
|
|
||||||
'label' => $status->name,
|
|
||||||
'value' => $status->assets()->count(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
$byLocation = AssetLocation::orderBy('name')
|
|
||||||
->get()
|
|
||||||
->map(function ($loc) {
|
|
||||||
return [
|
|
||||||
'label' => $loc->name,
|
|
||||||
'value' => $loc->assets()->count(),
|
|
||||||
];
|
|
||||||
})->filter(fn($row) => $row['value'] > 0)->values();
|
|
||||||
|
|
||||||
$recentAudits = AssetAudit::with('asset')
|
|
||||||
->latest()
|
|
||||||
->take(5)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$recentDisposals = AssetDisposal::with('asset')
|
|
||||||
->latest()
|
|
||||||
->take(5)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return view('pages.dashboards.index', compact(
|
|
||||||
'totalAssets',
|
|
||||||
'movementCount',
|
|
||||||
'disposalCount',
|
|
||||||
'auditIssues',
|
|
||||||
'byStatus',
|
|
||||||
'byLocation',
|
|
||||||
'recentAudits',
|
|
||||||
'recentDisposals'
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$data = $this->dashboard->build($user);
|
||||||
|
|
||||||
|
// Backward compatible variable names (dipakai view lama).
|
||||||
|
$data['movementCount'] = $data['movementCount30'] ?? 0;
|
||||||
|
$data['disposalCount'] = $data['activeDisposals'] ?? 0;
|
||||||
|
|
||||||
|
return view('pages.dashboards.index', $data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
223
app/Services/Dashboard/DashboardService.php
Normal file
223
app/Services/Dashboard/DashboardService.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Dashboard;
|
||||||
|
|
||||||
|
use App\Models\Asset;
|
||||||
|
use App\Models\AssetApprovalRequest;
|
||||||
|
use App\Models\AssetAudit;
|
||||||
|
use App\Models\AssetDisposal;
|
||||||
|
use App\Models\AssetLocation;
|
||||||
|
use App\Models\AssetMaintenance;
|
||||||
|
use App\Models\AssetMovement;
|
||||||
|
use App\Models\AssetStatus;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\System\SystemSettingService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class DashboardService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly SystemSettingService $settings)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menyusun seluruh data ringkasan untuk dashboard.
|
||||||
|
*
|
||||||
|
* Catatan:
|
||||||
|
* - Menggunakan scoping per user (departemen/lokasi) jika user tidak punya permission `assets.view_all`.
|
||||||
|
* - Menggunakan cache singkat untuk mengurangi beban query pada halaman yang sering diakses.
|
||||||
|
*/
|
||||||
|
public function build(User $user): array
|
||||||
|
{
|
||||||
|
$cacheKey = sprintf('dashboard.summary.%s', $user->id);
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, now()->addMinutes(2), function () use ($user) {
|
||||||
|
$now = Carbon::now();
|
||||||
|
$last30 = $now->copy()->subDays(30);
|
||||||
|
$trendDays = 14;
|
||||||
|
$trendStart = $now->copy()->subDays($trendDays - 1)->startOfDay();
|
||||||
|
|
||||||
|
$warrantyDays = (int) $this->settings->get('asset.warranty_reminder_days', 30);
|
||||||
|
$lowStockThreshold = (int) $this->settings->get('asset.consumable_low_stock_threshold', 5);
|
||||||
|
|
||||||
|
$assetsQuery = Asset::query()->forUser($user);
|
||||||
|
|
||||||
|
$totalAssets = (clone $assetsQuery)->count();
|
||||||
|
$archivedAssets = (clone $assetsQuery)->whereNotNull('archived_at')->count();
|
||||||
|
$softDeletedAssets = (clone $assetsQuery)->onlyTrashed()->count();
|
||||||
|
|
||||||
|
$activeDisposals = AssetDisposal::query()
|
||||||
|
->whereNull('reversed_at')
|
||||||
|
->where('status', 'approved')
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$movementCount30 = AssetMovement::query()
|
||||||
|
->where('created_at', '>=', $last30)
|
||||||
|
->where('status', 'approved')
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$auditIssues = AssetAudit::query()
|
||||||
|
->whereIn('status', ['missing', 'damaged'])
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$warrantyExpiring = (clone $assetsQuery)
|
||||||
|
->whereNotNull('warranty_end')
|
||||||
|
->whereBetween('warranty_end', [$now->toDateString(), $now->copy()->addDays($warrantyDays)->toDateString()])
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$maintenanceOpen = AssetMaintenance::query()
|
||||||
|
->whereIn('status', ['planned', 'in_progress', 'pending'])
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$lowStockConsumables = (clone $assetsQuery)
|
||||||
|
->where('is_consumable', true)
|
||||||
|
->whereNotNull('available_quantity')
|
||||||
|
->where('available_quantity', '<=', $lowStockThreshold)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$pendingApprovalsCount = AssetApprovalRequest::query()
|
||||||
|
->where('status', 'pending')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$byStatus = AssetStatus::query()
|
||||||
|
->withCount(['assets as assets_count' => fn ($q) => $q->forUser($user)])
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($status) => ['label' => $status->name, 'value' => (int) $status->assets_count])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$byLocation = AssetLocation::query()
|
||||||
|
->withCount(['assets as assets_count' => fn ($q) => $q->forUser($user)])
|
||||||
|
->orderByDesc('assets_count')
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn ($loc) => ['label' => $loc->name, 'value' => (int) $loc->assets_count])
|
||||||
|
->filter(fn ($row) => $row['value'] > 0)
|
||||||
|
->take(10)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$trendLabels = $this->buildTrendLabels($trendStart, $trendDays);
|
||||||
|
$movementTrend = $this->buildDailyTrend(
|
||||||
|
AssetMovement::query()
|
||||||
|
->where('status', 'approved')
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user)),
|
||||||
|
'created_at',
|
||||||
|
$trendStart,
|
||||||
|
$trendDays
|
||||||
|
);
|
||||||
|
$disposalTrend = $this->buildDailyTrend(
|
||||||
|
AssetDisposal::query()
|
||||||
|
->where('status', 'approved')
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user)),
|
||||||
|
'created_at',
|
||||||
|
$trendStart,
|
||||||
|
$trendDays
|
||||||
|
);
|
||||||
|
$auditIssueTrend = $this->buildDailyTrend(
|
||||||
|
AssetAudit::query()
|
||||||
|
->whereIn('status', ['missing', 'damaged'])
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user)),
|
||||||
|
'created_at',
|
||||||
|
$trendStart,
|
||||||
|
$trendDays
|
||||||
|
);
|
||||||
|
|
||||||
|
$recentAudits = AssetAudit::with(['asset'])
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->latest()
|
||||||
|
->take(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$recentDisposals = AssetDisposal::with(['asset'])
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->latest()
|
||||||
|
->take(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$recentMovements = AssetMovement::with(['asset', 'fromLocation', 'toLocation', 'fromDepartment', 'toDepartment', 'fromUser', 'toUser'])
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->latest()
|
||||||
|
->take(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$pendingApprovals = AssetApprovalRequest::with(['approvable', 'requester'])
|
||||||
|
->where('status', 'pending')
|
||||||
|
->latest()
|
||||||
|
->take(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$maintenanceDueSoon = AssetMaintenance::with('asset')
|
||||||
|
->whereIn('status', ['planned', 'in_progress', 'pending'])
|
||||||
|
->whereNotNull('performed_at')
|
||||||
|
->whereBetween('performed_at', [$now->copy()->subDays(1), $now->copy()->addDays(7)])
|
||||||
|
->whereHas('asset', fn ($q) => $q->forUser($user))
|
||||||
|
->orderBy('performed_at')
|
||||||
|
->take(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$readonlyMode = (bool) $this->settings->get('maintenance.readonly_mode', false);
|
||||||
|
|
||||||
|
return compact(
|
||||||
|
'totalAssets',
|
||||||
|
'archivedAssets',
|
||||||
|
'softDeletedAssets',
|
||||||
|
'movementCount30',
|
||||||
|
'activeDisposals',
|
||||||
|
'auditIssues',
|
||||||
|
'warrantyExpiring',
|
||||||
|
'maintenanceOpen',
|
||||||
|
'lowStockConsumables',
|
||||||
|
'pendingApprovalsCount',
|
||||||
|
'byStatus',
|
||||||
|
'byLocation',
|
||||||
|
'trendLabels',
|
||||||
|
'movementTrend',
|
||||||
|
'disposalTrend',
|
||||||
|
'auditIssueTrend',
|
||||||
|
'recentAudits',
|
||||||
|
'recentDisposals',
|
||||||
|
'recentMovements',
|
||||||
|
'pendingApprovals',
|
||||||
|
'maintenanceDueSoon',
|
||||||
|
'readonlyMode'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTrendLabels(Carbon $start, int $days): array
|
||||||
|
{
|
||||||
|
return collect(range(0, $days - 1))
|
||||||
|
->map(fn ($i) => $start->copy()->addDays($i)->format('d/m'))
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Menghasilkan array count per hari (panjang = $days) untuk dataset chart.
|
||||||
|
*/
|
||||||
|
private function buildDailyTrend($query, string $dateColumn, Carbon $start, int $days): array
|
||||||
|
{
|
||||||
|
$end = $start->copy()->addDays($days)->endOfDay();
|
||||||
|
|
||||||
|
/** @var Collection<string,int> $raw */
|
||||||
|
$raw = $query
|
||||||
|
->whereBetween($dateColumn, [$start, $end])
|
||||||
|
->selectRaw('DATE(' . $dateColumn . ') as d, COUNT(*) as c')
|
||||||
|
->groupByRaw('DATE(' . $dateColumn . ')')
|
||||||
|
->pluck('c', 'd')
|
||||||
|
->map(fn ($v) => (int) $v);
|
||||||
|
|
||||||
|
return collect(range(0, $days - 1))
|
||||||
|
->map(function ($i) use ($start, $raw) {
|
||||||
|
$key = $start->copy()->addDays($i)->toDateString();
|
||||||
|
return (int) ($raw[$key] ?? 0);
|
||||||
|
})
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,13 +11,33 @@
|
|||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-3 page-header-breadcrumb flex-wrap gap-2">
|
<div class="d-flex align-items-center justify-content-between mb-3 page-header-breadcrumb flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title fw-medium fs-20 mb-0">Dashboard Aset</h1>
|
<h1 class="page-title fw-medium fs-20 mb-0">Dashboard</h1>
|
||||||
<p class="text-muted mb-0">Ringkasan kesehatan aset, pergerakan, disposal, dan audit.</p>
|
<p class="text-muted mb-0">Ringkasan operasional aset, risiko, workflow approval, dan tindakan yang perlu ditindaklanjuti.</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
@if(!empty($readonlyMode))
|
||||||
|
<span class="badge bg-warning-transparent text-warning">
|
||||||
|
<i class="ri-shield-flash-line me-1"></i>Mode Read-only Aktif
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
<span class="badge bg-primary-transparent text-primary">
|
||||||
|
<i class="ri-time-line me-1"></i>{{ now()->format('d/m/Y H:i') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if(!empty($readonlyMode))
|
||||||
|
<div class="alert alert-warning d-flex align-items-start gap-2" role="alert">
|
||||||
|
<i class="ri-alert-line fs-18 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Mode Read-only sedang aktif</div>
|
||||||
|
<div class="text-muted">Semua aksi tulis (create/update/delete) dibatasi. Nonaktifkan melalui System Settings jika maintenance sudah selesai.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-4 col-xl-2">
|
||||||
<div class="card custom-card">
|
<div class="card custom-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
@@ -29,10 +49,14 @@
|
|||||||
<i class="ri-archive-2-line fs-18"></i>
|
<i class="ri-archive-2-line fs-18"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
<span class="badge bg-light text-default">Arsip: {{ $archivedAssets ?? 0 }}</span>
|
||||||
|
<span class="badge bg-light text-default">Trash: {{ $softDeletedAssets ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-4 col-xl-2">
|
||||||
<div class="card custom-card">
|
<div class="card custom-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
@@ -44,10 +68,11 @@
|
|||||||
<i class="ri-route-line fs-18"></i>
|
<i class="ri-route-line fs-18"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fs-12 text-muted mt-2">Hanya yang berstatus approved.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-4 col-xl-2">
|
||||||
<div class="card custom-card">
|
<div class="card custom-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
@@ -59,10 +84,11 @@
|
|||||||
<i class="ri-delete-bin-6-line fs-18"></i>
|
<i class="ri-delete-bin-6-line fs-18"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fs-12 text-muted mt-2">Belum di-reverse.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-4 col-xl-2">
|
||||||
<div class="card custom-card">
|
<div class="card custom-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
@@ -74,29 +100,139 @@
|
|||||||
<i class="ri-error-warning-line fs-18"></i>
|
<i class="ri-error-warning-line fs-18"></i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fs-12 text-muted mt-2">Status missing/damaged.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-xl-2">
|
||||||
|
<div class="card custom-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-muted">Warranty Akan Habis</p>
|
||||||
|
<h4 class="mb-0">{{ $warrantyExpiring ?? 0 }}</h4>
|
||||||
|
</div>
|
||||||
|
<span class="avatar avatar-md bg-success-transparent text-success">
|
||||||
|
<i class="ri-shield-check-line fs-18"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fs-12 text-muted mt-2">Dalam window reminder setting.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 col-xl-2">
|
||||||
|
<div class="card custom-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 text-muted">Approval Pending</p>
|
||||||
|
<h4 class="mb-0">{{ $pendingApprovalsCount ?? 0 }}</h4>
|
||||||
|
</div>
|
||||||
|
<span class="avatar avatar-md bg-secondary-transparent text-secondary">
|
||||||
|
<i class="ri-shield-user-line fs-18"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fs-12 text-muted mt-2">Movement/Disposal/Maintenance.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
|
<div class="col-xl-8">
|
||||||
|
<div class="card custom-card h-100">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<div class="card-title mb-0">Tren Aktivitas (14 Hari)</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="badge bg-info-transparent text-info">Movement</span>
|
||||||
|
<span class="badge bg-warning-transparent text-warning">Disposal</span>
|
||||||
|
<span class="badge bg-danger-transparent text-danger">Audit Issues</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<canvas id="chartTrend" height="110"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-4">
|
||||||
|
<div class="card custom-card h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title mb-0">Tindak Lanjut Cepat</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Maintenance Terbuka</div>
|
||||||
|
<div class="text-muted fs-12">Planned / in progress yang masih berjalan.</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="fw-semibold">{{ $maintenanceOpen ?? 0 }}</div>
|
||||||
|
@can('maintenance.manage')
|
||||||
|
<a class="fs-12" href="{{ route('asset-maintenances.index') }}">Lihat</a>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Consumable Low Stock</div>
|
||||||
|
<div class="text-muted fs-12">Stok ≤ threshold setting.</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="fw-semibold">{{ $lowStockConsumables ?? 0 }}</div>
|
||||||
|
@canany(['assets.view','assets.manage'])
|
||||||
|
<a class="fs-12" href="{{ route('assets.index', ['type' => 'consumable']) }}">Filter</a>
|
||||||
|
@endcanany
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Warranty Akan Habis</div>
|
||||||
|
<div class="text-muted fs-12">Perlu reminder/penjadwalan.</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="fw-semibold">{{ $warrantyExpiring ?? 0 }}</div>
|
||||||
|
@canany(['assets.view','assets.manage'])
|
||||||
|
<a class="fs-12" href="{{ route('assets.index', ['warranty' => 'expiring']) }}">Filter</a>
|
||||||
|
@endcanany
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@can('approvals.manage')
|
||||||
|
<div class="d-flex align-items-start justify-content-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="fw-semibold">Approval Pending</div>
|
||||||
|
<div class="text-muted fs-12">Perlu keputusan approve/reject.</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<div class="fw-semibold">{{ $pendingApprovalsCount ?? 0 }}</div>
|
||||||
|
<a class="fs-12" href="{{ route('approvals.index') }}">Buka</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mt-0">
|
||||||
<div class="col-xl-6">
|
<div class="col-xl-6">
|
||||||
<div class="card custom-card h-100">
|
<div class="card custom-card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title mb-0">Distribusi Aset per Status</div>
|
<div class="card-title mb-0">Distribusi Aset per Status</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<canvas id="chartStatus"></canvas>
|
<canvas id="chartStatus" height="140"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xl-6">
|
<div class="col-xl-6">
|
||||||
<div class="card custom-card h-100">
|
<div class="card custom-card h-100">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title mb-0">Distribusi Aset per Lokasi</div>
|
<div class="card-title mb-0">Top Lokasi (10)</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<canvas id="chartLocation"></canvas>
|
<canvas id="chartLocation" height="140"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,13 +241,16 @@
|
|||||||
<div class="row g-3 mt-3">
|
<div class="row g-3 mt-3">
|
||||||
<div class="col-xl-6">
|
<div class="col-xl-6">
|
||||||
<div class="card custom-card">
|
<div class="card custom-card">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<div class="card-title mb-0">Audit Terakhir</div>
|
<div class="card-title mb-0">Audit Terakhir</div>
|
||||||
|
@can('audits.manage')
|
||||||
|
<a href="{{ route('asset-audits.index') }}" class="btn btn-sm btn-light">Lihat Semua</a>
|
||||||
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-bordered text-nowrap align-middle mb-0">
|
<table class="table text-nowrap align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Aset</th>
|
<th>Aset</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
@@ -138,13 +277,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-xl-6">
|
<div class="col-xl-6">
|
||||||
<div class="card custom-card">
|
<div class="card custom-card">
|
||||||
<div class="card-header">
|
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<div class="card-title mb-0">Disposal Terakhir</div>
|
<div class="card-title mb-0">Disposal Terakhir</div>
|
||||||
|
@can('disposals.manage')
|
||||||
|
<a href="{{ route('asset-disposals.index') }}" class="btn btn-sm btn-light">Lihat Semua</a>
|
||||||
|
@endcan
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-bordered text-nowrap align-middle mb-0">
|
<table class="table text-nowrap align-middle mb-0">
|
||||||
<thead class="table-light">
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Aset</th>
|
<th>Aset</th>
|
||||||
<th>Alasan</th>
|
<th>Alasan</th>
|
||||||
@@ -176,39 +318,176 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mt-3">
|
||||||
|
<div class="col-xl-7">
|
||||||
|
<div class="card custom-card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<div class="card-title mb-0">Movement Terakhir</div>
|
||||||
|
@can('movements.manage')
|
||||||
|
<a href="{{ route('asset-movements.index') }}" class="btn btn-sm btn-light">Lihat Semua</a>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table text-nowrap align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Aset</th>
|
||||||
|
<th>Dari</th>
|
||||||
|
<th>Ke</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Waktu</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($recentMovements as $mv)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $mv->asset?->code }} - {{ $mv->asset?->name }}</td>
|
||||||
|
<td class="text-muted">
|
||||||
|
{{ $mv->fromLocation?->name ?: '-' }} / {{ $mv->fromDepartment?->name ?: '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted">
|
||||||
|
{{ $mv->toLocation?->name ?: '-' }} / {{ $mv->toDepartment?->name ?: '-' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@php($st = $mv->status ?: 'approved')
|
||||||
|
@if($st === 'approved')
|
||||||
|
<span class="badge bg-success-transparent text-success">Approved</span>
|
||||||
|
@elseif($st === 'pending')
|
||||||
|
<span class="badge bg-warning-transparent text-warning">Pending</span>
|
||||||
|
@else
|
||||||
|
<span class="badge bg-danger-transparent text-danger">{{ ucfirst($st) }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>{{ optional($mv->performed_at)->format('d/m/Y H:i') ?: optional($mv->created_at)->format('d/m/Y H:i') }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="5" class="text-center text-muted">Belum ada data movement.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xl-5">
|
||||||
|
<div class="card custom-card">
|
||||||
|
<div class="card-header d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
|
<div class="card-title mb-0">Approval Pending (Terbaru)</div>
|
||||||
|
@can('approvals.manage')
|
||||||
|
<a href="{{ route('approvals.index') }}" class="btn btn-sm btn-light">Kelola</a>
|
||||||
|
@endcan
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table text-nowrap align-middle mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Requester</th>
|
||||||
|
<th>Dibuat</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($pendingApprovals as $appr)
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge bg-secondary-transparent text-secondary">{{ strtoupper($appr->type) }}</span></td>
|
||||||
|
<td>{{ $appr->requester?->name ?: '-' }}</td>
|
||||||
|
<td>{{ optional($appr->created_at)->format('d/m/Y H:i') }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="3" class="text-center text-muted">Tidak ada approval pending.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
@if(!empty($maintenanceDueSoon) && $maintenanceDueSoon->count())
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="fw-semibold mb-2">Maintenance Due (7 Hari)</div>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
@foreach($maintenanceDueSoon as $m)
|
||||||
|
<li class="d-flex align-items-start justify-content-between gap-2 py-1">
|
||||||
|
<div class="text-truncate">
|
||||||
|
<div class="fw-medium text-truncate">{{ $m->asset?->code }} - {{ $m->asset?->name }}</div>
|
||||||
|
<div class="text-muted fs-12 text-truncate">{{ $m->description }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted fs-12 text-nowrap">{{ optional($m->performed_at)->format('d/m/Y') }}</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
@section('scripts')
|
@section('scripts')
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="{{ asset('build/assets/libs/chart.js/chart.umd.js') }}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
const trendLabels = @json($trendLabels ?? []);
|
||||||
|
const movementTrend = @json($movementTrend ?? []);
|
||||||
|
const disposalTrend = @json($disposalTrend ?? []);
|
||||||
|
const auditIssueTrend = @json($auditIssueTrend ?? []);
|
||||||
|
|
||||||
|
const trendCtx = document.getElementById('chartTrend');
|
||||||
|
if (trendCtx) {
|
||||||
|
new Chart(trendCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: trendLabels,
|
||||||
|
datasets: [
|
||||||
|
{ label: 'Movement', data: movementTrend, borderColor: '#0ea5e9', backgroundColor: 'rgba(14,165,233,0.15)', tension: 0.35, fill: true },
|
||||||
|
{ label: 'Disposal', data: disposalTrend, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.12)', tension: 0.35, fill: true },
|
||||||
|
{ label: 'Audit Issues', data: auditIssueTrend, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,0.10)', tension: 0.35, fill: true },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const statusCtx = document.getElementById('chartStatus');
|
const statusCtx = document.getElementById('chartStatus');
|
||||||
const statusData = @json($byStatus);
|
const statusData = @json($byStatus ?? []);
|
||||||
new Chart(statusCtx, {
|
if (statusCtx) {
|
||||||
type: 'doughnut',
|
new Chart(statusCtx, {
|
||||||
data: {
|
type: 'doughnut',
|
||||||
labels: statusData.map(i => i.label),
|
data: {
|
||||||
datasets: [{
|
labels: statusData.map(i => i.label),
|
||||||
data: statusData.map(i => i.value),
|
datasets: [{
|
||||||
backgroundColor: ['#60a5fa','#34d399','#fbbf24','#f87171','#a78bfa']
|
data: statusData.map(i => i.value),
|
||||||
}]
|
backgroundColor: ['#0ea5e9','#22c55e','#f59e0b','#ef4444','#a78bfa','#94a3b8','#14b8a6','#f97316']
|
||||||
},
|
}]
|
||||||
options: { plugins: { legend: { position: 'bottom' } } }
|
},
|
||||||
});
|
options: { plugins: { legend: { position: 'bottom' } } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const locCtx = document.getElementById('chartLocation');
|
const locCtx = document.getElementById('chartLocation');
|
||||||
const locData = @json($byLocation);
|
const locData = @json($byLocation ?? []);
|
||||||
new Chart(locCtx, {
|
if (locCtx) {
|
||||||
type: 'bar',
|
new Chart(locCtx, {
|
||||||
data: {
|
type: 'bar',
|
||||||
labels: locData.map(i => i.label),
|
data: {
|
||||||
datasets: [{
|
labels: locData.map(i => i.label),
|
||||||
label: 'Jumlah',
|
datasets: [{
|
||||||
data: locData.map(i => i.value),
|
label: 'Jumlah Aset',
|
||||||
backgroundColor: '#22c55e'
|
data: locData.map(i => i.value),
|
||||||
}]
|
backgroundColor: '#22c55e'
|
||||||
},
|
}]
|
||||||
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }
|
},
|
||||||
});
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: { x: { beginAtZero: true, ticks: { precision: 0 } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
108
tests/Feature/DashboardPageTest.php
Normal file
108
tests/Feature/DashboardPageTest.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Asset;
|
||||||
|
use App\Models\AssetApprovalRequest;
|
||||||
|
use App\Models\AssetAudit;
|
||||||
|
use App\Models\AssetDisposal;
|
||||||
|
use App\Models\AssetLocation;
|
||||||
|
use App\Models\AssetMaintenance;
|
||||||
|
use App\Models\AssetMovement;
|
||||||
|
use App\Models\AssetStatus;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DashboardPageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_dashboard_loads_with_summary_data(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$statusActive = AssetStatus::create(['name' => 'Active', 'code' => 'ACTIVE']);
|
||||||
|
$statusRepair = AssetStatus::create(['name' => 'Repair', 'code' => 'REPAIR']);
|
||||||
|
|
||||||
|
$locHq = AssetLocation::create(['name' => 'HQ', 'code' => 'HQ']);
|
||||||
|
$locBranch = AssetLocation::create(['name' => 'Branch', 'code' => 'BR']);
|
||||||
|
|
||||||
|
$asset1 = Asset::create([
|
||||||
|
'code' => 'AST-001',
|
||||||
|
'name' => 'Laptop A',
|
||||||
|
'asset_status_id' => $statusActive->id,
|
||||||
|
'asset_location_id' => $locHq->id,
|
||||||
|
'qr_token' => (string) Str::uuid(),
|
||||||
|
'warranty_end' => now()->addDays(7)->toDateString(),
|
||||||
|
'cost' => 10000000,
|
||||||
|
]);
|
||||||
|
$asset2 = Asset::create([
|
||||||
|
'code' => 'AST-002',
|
||||||
|
'name' => 'Printer B',
|
||||||
|
'asset_status_id' => $statusRepair->id,
|
||||||
|
'asset_location_id' => $locBranch->id,
|
||||||
|
'qr_token' => (string) Str::uuid(),
|
||||||
|
'warranty_end' => now()->addDays(10)->toDateString(),
|
||||||
|
'cost' => 2500000,
|
||||||
|
]);
|
||||||
|
$asset3 = Asset::create([
|
||||||
|
'code' => 'AST-003',
|
||||||
|
'name' => 'Tinta Printer',
|
||||||
|
'asset_status_id' => $statusActive->id,
|
||||||
|
'asset_location_id' => $locHq->id,
|
||||||
|
'qr_token' => (string) Str::uuid(),
|
||||||
|
'is_consumable' => true,
|
||||||
|
'quantity' => 10,
|
||||||
|
'available_quantity' => 3,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Arsip & trash (soft delete).
|
||||||
|
$asset2->update(['archived_at' => now()]);
|
||||||
|
$asset3->delete();
|
||||||
|
|
||||||
|
// Aktivitas: movement/disposal/audit issue/maintenance.
|
||||||
|
$movement = AssetMovement::create([
|
||||||
|
'asset_id' => $asset1->id,
|
||||||
|
'status' => 'approved',
|
||||||
|
'performed_at' => now()->subDays(2),
|
||||||
|
]);
|
||||||
|
AssetDisposal::create([
|
||||||
|
'asset_id' => $asset1->id,
|
||||||
|
'status' => 'approved',
|
||||||
|
'disposed_at' => now()->subDays(1),
|
||||||
|
'reversed_at' => null,
|
||||||
|
]);
|
||||||
|
AssetAudit::create([
|
||||||
|
'asset_id' => $asset1->id,
|
||||||
|
'status' => 'missing',
|
||||||
|
'audited_at' => now()->subDays(1),
|
||||||
|
]);
|
||||||
|
AssetMaintenance::create([
|
||||||
|
'asset_id' => $asset1->id,
|
||||||
|
'status' => 'planned',
|
||||||
|
'performed_at' => now()->addDays(2),
|
||||||
|
'description' => 'Preventive maintenance',
|
||||||
|
]);
|
||||||
|
|
||||||
|
AssetApprovalRequest::create([
|
||||||
|
'approvable_id' => $movement->id,
|
||||||
|
'approvable_type' => AssetMovement::class,
|
||||||
|
'type' => 'movement',
|
||||||
|
'status' => 'pending',
|
||||||
|
'requested_by' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->get(route('index'));
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('Dashboard');
|
||||||
|
$response->assertViewHas('totalAssets', 2);
|
||||||
|
$response->assertViewHas('archivedAssets', 1);
|
||||||
|
$response->assertViewHas('softDeletedAssets', 1);
|
||||||
|
$response->assertViewHas('pendingApprovalsCount', 1);
|
||||||
|
$response->assertViewHas('warrantyExpiring', 2);
|
||||||
|
$response->assertViewHas('auditIssues', 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user