diff --git a/app/Http/Controllers/DashboardsController.php b/app/Http/Controllers/DashboardsController.php index 079de1e..d1456c7 100644 --- a/app/Http/Controllers/DashboardsController.php +++ b/app/Http/Controllers/DashboardsController.php @@ -2,66 +2,24 @@ namespace App\Http\Controllers; -use Illuminate\Http\Request; -use App\Models\Asset; -use App\Models\AssetStatus; -use App\Models\AssetLocation; -use App\Models\AssetMovement; -use App\Models\AssetDisposal; -use App\Models\AssetAudit; -use Carbon\Carbon; +use App\Services\Dashboard\DashboardService; +use Illuminate\View\View; class DashboardsController extends Controller { - - public function index() + public function __construct(private readonly DashboardService $dashboard) { - $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); + } } diff --git a/app/Services/Dashboard/DashboardService.php b/app/Services/Dashboard/DashboardService.php new file mode 100644 index 0000000..02790a6 --- /dev/null +++ b/app/Services/Dashboard/DashboardService.php @@ -0,0 +1,223 @@ +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 $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(); + } +} + diff --git a/resources/views/pages/dashboards/index.blade.php b/resources/views/pages/dashboards/index.blade.php index 2c9c2fc..ff8c82a 100644 --- a/resources/views/pages/dashboards/index.blade.php +++ b/resources/views/pages/dashboards/index.blade.php @@ -11,13 +11,33 @@
-

Dashboard Aset

-

Ringkasan kesehatan aset, pergerakan, disposal, dan audit.

+

Dashboard

+

Ringkasan operasional aset, risiko, workflow approval, dan tindakan yang perlu ditindaklanjuti.

+
+
+ @if(!empty($readonlyMode)) + + Mode Read-only Aktif + + @endif + + {{ now()->format('d/m/Y H:i') }} +
+@if(!empty($readonlyMode)) + +@endif +
-
+
@@ -29,10 +49,14 @@
+
+ Arsip: {{ $archivedAssets ?? 0 }} + Trash: {{ $softDeletedAssets ?? 0 }} +
-
+
@@ -44,10 +68,11 @@
+
Hanya yang berstatus approved.
-
+
@@ -59,10 +84,11 @@
+
Belum di-reverse.
-
+
@@ -74,29 +100,139 @@
+
Status missing/damaged.
+
+
+
+
+
+
+
+
+

Warranty Akan Habis

+

{{ $warrantyExpiring ?? 0 }}

+
+ + + +
+
Dalam window reminder setting.
+
+
+
+
+
+
+
+
+

Approval Pending

+

{{ $pendingApprovalsCount ?? 0 }}

+
+ + + +
+
Movement/Disposal/Maintenance.
+
+
+
+
Tren Aktivitas (14 Hari)
+
+ Movement + Disposal + Audit Issues +
+
+
+ +
+
+
+
+
+
+
Tindak Lanjut Cepat
+
+
+
+
+
+
Maintenance Terbuka
+
Planned / in progress yang masih berjalan.
+
+
+
{{ $maintenanceOpen ?? 0 }}
+ @can('maintenance.manage') + Lihat + @endcan +
+
+
+
+
Consumable Low Stock
+
Stok ≤ threshold setting.
+
+
+
{{ $lowStockConsumables ?? 0 }}
+ @canany(['assets.view','assets.manage']) + Filter + @endcanany +
+
+
+
+
Warranty Akan Habis
+
Perlu reminder/penjadwalan.
+
+
+
{{ $warrantyExpiring ?? 0 }}
+ @canany(['assets.view','assets.manage']) + Filter + @endcanany +
+
+ @can('approvals.manage') +
+
+
Approval Pending
+
Perlu keputusan approve/reject.
+
+
+
{{ $pendingApprovalsCount ?? 0 }}
+ Buka +
+
+ @endcan +
+
+
+
+
+ +
Distribusi Aset per Status
- +
-
Distribusi Aset per Lokasi
+
Top Lokasi (10)
- +
@@ -105,13 +241,16 @@
-
+
Audit Terakhir
+ @can('audits.manage') + Lihat Semua + @endcan
- - +
+ @@ -138,13 +277,16 @@
-
+
Disposal Terakhir
+ @can('disposals.manage') + Lihat Semua + @endcan
-
Aset Status
- +
+ @@ -176,39 +318,176 @@ + +
+
+
+
+
Movement Terakhir
+ @can('movements.manage') + Lihat Semua + @endcan +
+
+
+
Aset Alasan
+ + + + + + + + + + + @forelse($recentMovements as $mv) + + + + + + + + @empty + + @endforelse + +
AsetDariKeStatusWaktu
{{ $mv->asset?->code }} - {{ $mv->asset?->name }} + {{ $mv->fromLocation?->name ?: '-' }} / {{ $mv->fromDepartment?->name ?: '-' }} + + {{ $mv->toLocation?->name ?: '-' }} / {{ $mv->toDepartment?->name ?: '-' }} + + @php($st = $mv->status ?: 'approved') + @if($st === 'approved') + Approved + @elseif($st === 'pending') + Pending + @else + {{ ucfirst($st) }} + @endif + {{ optional($mv->performed_at)->format('d/m/Y H:i') ?: optional($mv->created_at)->format('d/m/Y H:i') }}
Belum ada data movement.
+
+
+
+
+
+
+
+
Approval Pending (Terbaru)
+ @can('approvals.manage') + Kelola + @endcan +
+
+
+ + + + + + + + + + @forelse($pendingApprovals as $appr) + + + + + + @empty + + @endforelse + +
TypeRequesterDibuat
{{ strtoupper($appr->type) }}{{ $appr->requester?->name ?: '-' }}{{ optional($appr->created_at)->format('d/m/Y H:i') }}
Tidak ada approval pending.
+
+ @if(!empty($maintenanceDueSoon) && $maintenanceDueSoon->count()) +
+
Maintenance Due (7 Hari)
+
    + @foreach($maintenanceDueSoon as $m) +
  • +
    +
    {{ $m->asset?->code }} - {{ $m->asset?->name }}
    +
    {{ $m->description }}
    +
    +
    {{ optional($m->performed_at)->format('d/m/Y') }}
    +
  • + @endforeach +
+
+ @endif +
+
+
+
@endsection @section('scripts') - + @endsection diff --git a/tests/Feature/DashboardPageTest.php b/tests/Feature/DashboardPageTest.php new file mode 100644 index 0000000..74a67f6 --- /dev/null +++ b/tests/Feature/DashboardPageTest.php @@ -0,0 +1,108 @@ +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); + } +}