Setelah lima proyek — blog, inventaris, REST API, keuangan, dan pemesanan — kita menutup Course Laravel dengan proyek yang tidak menerima input baru sama sekali: dashboard analisis data untuk toko alat tulis dan buku “Toko Lentera”. Aplikasi sebelumnya sibuk menulis data; proyek ini sibuk membacanya kembali dan menjawab pertanyaan pemilik toko dalam satu layar: berapa pendapatan hari ini, bagaimana tren dua belas bulan terakhir, produk apa yang paling menghasilkan, dan ke kategori mana uang pembeli mengalir. Tantangannya bergeser dari menjaga konsistensi penulisan menjadi menjaga kebenaran angka — satu salah hitung di SUM() langsung menjadi keputusan bisnis yang salah — sehingga seluruh agregasi kita kunci dalam satu service class dan dibuktikan lewat test dengan angka eksak.

Dashboard yang akan kita bangun bekerja sebagai satu halaman publik tanpa login:

  • menghitung metrik hari ini dan bulan acuan: pendapatan, jumlah pesanan, dan rata-rata nilai pesanan (average order value),
  • menggambar grafik garis tren bulanan dengan Chart.js yang dimuat dari CDN, tetapi seluruh datanya disuntik dari server lewat @json sehingga bisa diuji,
  • menampilkan pangsa kategori sebagai batang murni CSS yang lebarnya persentase — angka yang sama juga dituntut tepat oleh test,
  • merangking lima produk teratas berdasarkan pendapatan pada rentang yang dipilih,
  • menerima filter rentang tanggal from/to yang tervalidasi, termasuk penolakan tanggal akhir yang mendahului awal,
  • menyajikan tabel penjualan terpaginasi sepuluh baris per halaman yang tetap membawa query filter saat berpindah halaman.

Batas scope juga tegas sejak awal: dashboard ini pembaca, bukan penulis. Tidak ada form input, tidak ada autentikasi, tidak ada edit data — pemilik toko membukanya dari layar kasir dan hanya butuh angka yang benar.

Tujuan Pembelajaran #

Setelah menyelesaikan proyek ini, kita mampu:

  • memusatkan agregasi SUM, COUNT, dan groupBy dalam satu service class sehingga hanya ada satu tempat yang boleh menghitung uang,
  • menekan database, bukan PHP, yang memeras ratusan baris menjadi belasan angka,
  • menyuntik array PHP ke JavaScript lewat @json tanpa kehilangan kemampuan menguji,
  • memilih visualisasi yang tepat sasaran: Chart.js untuk grafik interaktif, batang CSS untuk angka yang harus bisa diperiksa assertion,
  • memvalidasi query parameter rentang tanggal termasuk aturan lintas-kolom yang ditulis manual,
  • memakai pagination yang menjaga query string filter,
  • menulis test agregasi dengan angka eksak di atas seeder deterministik.

Prasyarat #

Proyek ini tidak memperkenalkan materi baru, melainkan memakai materi yang sudah dibahas. Chapter yang paling sering dirujuk:

Struktur Data #

Tiga tabel menopang seluruh angka: categories menyimpan kategori produk dengan warna penanda, products menyimpan katalog beserta harganya, dan sales menjadi satu-satunya sumber kebenaran angka — setiap baris adalah satu pesanan satu produk dengan quantity, price snapshot, dan sale_date. Kolom sale_date diberi index tersendiri karena hampir setiap query dashboard memfilternya. Peta enam bagian beserta keluaran utamanya:

BagianKeluaran utama
1. Perancangantiga migration + daftar metrik + aliran data
2. Pengumpulan Dataseeder 498 penjualan deterministik sepanjang 2026
3. Query dan AgregasiDashboardService dengan lima metode agregasi
4. Visualisasikartu, grafik Chart.js @json, batang CSS pangsa kategori
5. Filter dan Paginationvalidasi from/to + tabel terpaginasi
6. Pengujian21 test (feature + unit) yang selalu hijau

Tahapan Pengerjaan #

Proyek dikerjakan dalam enam bagian berurutan. Setiap bagian menghasilkan aplikasi yang dapat dijalankan penuh:

  1. Bagian 1: Perancangan Dashboard — menetapkan metrik, membangun migrasi tiga tabel, dan memetakan aliran data,
  2. Bagian 2: Pengumpulan Data — menulis seeder penjualan 2026 yang dibangkitkan lewat rumus aritmetika,
  3. Bagian 3: Query dan Agregasi — memusatkan seluruh perhitungan dalam DashboardService,
  4. Bagian 4: Visualisasi Data — menyusun kartu ringkasan, grafik garis, dan batang kategori,
  5. Bagian 5: Filter dan Pagination — menerima filter rentang tanggal dan memaginasi tabel penjualan,
  6. Bagian 6: Pengujian Dashboard — membuktikan angka agregasi dan tampilannya lewat feature test.

Mulailah dari Bagian 1 dan ikuti urutannya karena setiap bagian dibangun di atas berkas bagian sebelumnya: angka yang diuji di Bagian 6 pertama kali lahir dari rumus seeder di Bagian 2. Seluruh berkas sumber tersedia pada folder proyek ini: app/, database/, resources/, routes/, dan tests/.

Menjalankan Aplikasi #

Setelah mengikuti seluruh bagian (atau menyalin berkas sumber ke proyek Laravel baru dengan database SQLite), siapkan database beserta data contoh:

bash
php artisan migrate --seed

Output yang Dihasilkan:

text
   INFO  Preparing database.

  Creating migration table ....................................... DONE

   INFO  Running migrations.

  0001_01_01_000000_create_users_table ........................... DONE
  0001_01_01_000001_create_cache_table ........................... DONE
  0001_01_01_000002_create_jobs_table ............................ DONE
  2026_10_13_153049_create_personal_access_tokens_table .......... DONE
  2026_10_14_000010_create_categories_table ...................... DONE
  2026_10_14_000020_create_products_table ........................ DONE
  2026_10_14_000030_create_sales_table ........................... DONE


   INFO  Seeding database.

Kategori terisi: 3
Produk terisi: 8
Penjualan terisi: 498

Jalankan php artisan serve, buka http://localhost:8000, dan Anda langsung diarahkan ke /dashboard yang menampilkan angka hari berjalan. Untuk melihat cerita setahun penuh, terapkan filter from=2026-01-01 dan to=2026-12-31: kartu menampilkan angka Desember, grafik garis memuat dua belas titik, dan tabel penjualan menampilkan 498 baris dalam 50 halaman. Sebelum menjelajah, pastikan seluruh aturan aplikasi masih utuh dengan menjalankan php artisan test — 21 test dengan 74 assertion seharusnya berakhir hijau dalam waktu kurang dari satu detik, persis seperti output yang kita bedah pada Bagian 6.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

app/Http/Controllers/Controller.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

abstract class Controller
{
    //
}

app/Http/Controllers/DashboardController.php

Unduh berkas
php
<?php

namespace App\Http\Controllers;

use App\Services\DashboardService;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;

class DashboardController extends Controller
{
    public function __construct(private readonly DashboardService $dashboard)
    {
    }

    public function index(Request $request): View
    {
        $validated = $request->validate([
            'from' => ['nullable', 'date_format:Y-m-d'],
            'to' => ['nullable', 'date_format:Y-m-d'],
        ]);

        $from = isset($validated['from']) ? Carbon::parse($validated['from']) : null;
        $to = isset($validated['to']) ? Carbon::parse($validated['to']) : null;

        if ($from !== null && $to !== null && $to->lt($from)) {
            throw ValidationException::withMessages([
                'to' => 'Tanggal akhir harus sama dengan atau setelah tanggal awal.',
            ]);
        }

        $reference = $to ?? Carbon::today();
        $from ??= $reference->copy()->subMonths(11)->startOfMonth();

        $trend = $this->dashboard->monthlyTrend($from, $reference);

        return view('dashboard.index', [
            'reference' => $reference,
            'from' => $from,
            'to' => $reference,
            'today' => $this->dashboard->todaySummary($reference),
            'month' => $this->dashboard->monthSummary($reference),
            'trend' => $trend,
            'chart' => [
                'labels' => $trend->pluck('label')->all(),
                'revenue' => $trend->pluck('revenue')->all(),
                'orders' => $trend->pluck('orders')->all(),
            ],
            'topProducts' => $this->dashboard->topProducts($from, $reference),
            'categoryShare' => $this->dashboard->categoryShare($from, $reference),
            'sales' => $this->dashboard->salesQuery($from, $reference)
                ->paginate(10)
                ->withQueryString(),
        ]);
    }
}

app/Models/Category.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Category extends Model
{
    protected $fillable = [
        'name',
        'color',
    ];

    public function products(): HasMany
    {
        return $this->hasMany(Product::class);
    }
}

app/Models/Product.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    protected $fillable = [
        'category_id',
        'name',
        'price',
    ];

    protected function casts(): array
    {
        return [
            'price' => 'integer',
        ];
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function sales(): HasMany
    {
        return $this->hasMany(Sale::class);
    }
}

app/Models/Sale.php

Unduh berkas
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Sale extends Model
{
    protected $fillable = [
        'product_id',
        'quantity',
        'price',
        'sale_date',
    ];

    protected function casts(): array
    {
        return [
            'quantity' => 'integer',
            'price' => 'integer',
            'sale_date' => 'date:Y-m-d',
        ];
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}

app/Services/DashboardService.php

Unduh berkas
php
<?php

namespace App\Services;

use App\Models\Sale;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Collection;

class DashboardService
{
    public function __construct()
    {
        Carbon::setLocale('id');
    }

    /**
     * Pendapatan dan jumlah pesanan pada satu hari.
     *
     * @return array{revenue: int, orders: int}
     */
    public function todaySummary(CarbonInterface $day): array
    {
        $row = Sale::query()
            ->where('sale_date', $day->toDateString())
            ->selectRaw('COALESCE(SUM(quantity * price), 0) AS revenue')
            ->selectRaw('COUNT(*) AS orders')
            ->first();

        return [
            'revenue' => (int) $row->revenue,
            'orders' => (int) $row->orders,
        ];
    }

    /**
     * Pendapatan, jumlah pesanan, dan rata-rata nilai pesanan pada
     * bulan yang memuat tanggal acuan.
     *
     * @return array{revenue: int, orders: int, avg_order_value: int}
     */
    public function monthSummary(CarbonInterface $day): array
    {
        $row = Sale::query()
            ->whereBetween('sale_date', [
                $day->copy()->startOfMonth()->toDateString(),
                $day->copy()->endOfMonth()->toDateString(),
            ])
            ->selectRaw('COALESCE(SUM(quantity * price), 0) AS revenue')
            ->selectRaw('COUNT(*) AS orders')
            ->first();

        $revenue = (int) $row->revenue;
        $orders = (int) $row->orders;

        return [
            'revenue' => $revenue,
            'orders' => $orders,
            'avg_order_value' => $orders > 0
                ? (int) round($revenue / $orders)
                : 0,
        ];
    }

    /**
     * Pendapatan dan jumlah pesanan per bulan di dalam rentang,
     * termasuk bulan tanpa penjualan (diisi nol).
     *
     * @return Collection<int, array{month: string, label: string, revenue: int, orders: int}>
     */
    public function monthlyTrend(CarbonInterface $from, CarbonInterface $to): Collection
    {
        $rows = Sale::query()
            ->whereBetween('sale_date', [$from->toDateString(), $to->toDateString()])
            ->groupBy('bulan')
            ->selectRaw('substr(sale_date, 1, 7) AS bulan')
            ->selectRaw('SUM(quantity * price) AS revenue')
            ->selectRaw('COUNT(*) AS orders')
            ->orderBy('bulan')
            ->get()
            ->keyBy('bulan');

        $trend = new Collection();

        $cursor = $from->copy()->startOfMonth();
        $last = $to->copy()->startOfMonth();

        while ($cursor->lessThanOrEqualTo($last)) {
            $key = $cursor->format('Y-m');
            $row = $rows->get($key);

            $trend->push([
                'month' => $key,
                'label' => $cursor->translatedFormat('M Y'),
                'revenue' => (int) ($row->revenue ?? 0),
                'orders' => (int) ($row->orders ?? 0),
            ]);

            $cursor->addMonth();
        }

        return $trend;
    }

    /**
     * Produk dengan pendapatan terbesar di dalam rentang.
     *
     * @return Collection<int, array{id: int, name: string, qty: int, revenue: int}>
     */
    public function topProducts(CarbonInterface $from, CarbonInterface $to, int $limit = 5): Collection
    {
        return Sale::query()
            ->join('products', 'products.id', '=', 'sales.product_id')
            ->whereBetween('sale_date', [$from->toDateString(), $to->toDateString()])
            ->groupBy('products.id', 'products.name')
            ->selectRaw('products.id AS id')
            ->selectRaw('products.name AS name')
            ->selectRaw('SUM(sales.quantity) AS qty')
            ->selectRaw('SUM(sales.quantity * sales.price) AS revenue')
            ->orderByDesc('revenue')
            ->limit($limit)
            ->get()
            ->map(fn (object $row) => [
                'id' => (int) $row->id,
                'name' => $row->name,
                'qty' => (int) $row->qty,
                'revenue' => (int) $row->revenue,
            ]);
    }

    /**
     * Pendapatan per kategori di dalam rentang beserta persentase
     * dari total — angka yang menjadi lebar batang di dashboard.
     *
     * @return Collection<int, array{name: string, color: string, revenue: int, percent: int}>
     */
    public function categoryShare(CarbonInterface $from, CarbonInterface $to): Collection
    {
        $rows = Sale::query()
            ->join('products', 'products.id', '=', 'sales.product_id')
            ->join('categories', 'categories.id', '=', 'products.category_id')
            ->whereBetween('sale_date', [$from->toDateString(), $to->toDateString()])
            ->groupBy('categories.id', 'categories.name', 'categories.color')
            ->selectRaw('categories.name AS name')
            ->selectRaw('categories.color AS color')
            ->selectRaw('SUM(sales.quantity * sales.price) AS revenue')
            ->orderByDesc('revenue')
            ->get();

        $total = $rows->reduce(fn (int $carry, object $row) => $carry + (int) $row->revenue, 0);

        return $rows->map(fn (object $row) => [
            'name' => $row->name,
            'color' => $row->color,
            'revenue' => (int) $row->revenue,
            'percent' => $total > 0
                ? (int) round((int) $row->revenue / $total * 100)
                : 0,
        ]);
    }

    /**
     * Query dasar tabel penjualan: sudah terurut, terfilter rentang,
     * dan membawa nama produk serta kategori untuk ditampilkan.
     */
    public function salesQuery(CarbonInterface $from, CarbonInterface $to)
    {
        return Sale::query()
            ->join('products', 'products.id', '=', 'sales.product_id')
            ->join('categories', 'categories.id', '=', 'products.category_id')
            ->whereBetween('sale_date', [$from->toDateString(), $to->toDateString()])
            ->orderByDesc('sale_date')
            ->orderByDesc('sales.id')
            ->select('sales.*')
            ->selectRaw('products.name AS product_name')
            ->selectRaw('categories.name AS category_name');
    }
}

app/Support/Rupiah.php

Unduh berkas
php
<?php

namespace App\Support;

class Rupiah
{
    public static function format(int $value): string
    {
        return 'Rp ' . number_format($value, 0, ',', '.');
    }
}

database/migrations/2026_10_14_000010_create_categories_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('categories', function (Blueprint $table) {
            $table->id();
            $table->string('name')->unique();
            $table->string('color', 7);
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categories');
    }
};

database/migrations/2026_10_14_000020_create_products_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('products', function (Blueprint $table) {
            $table->id();
            $table->foreignId('category_id')
                ->constrained()
                ->cascadeOnDelete();
            $table->string('name');
            $table->unsignedInteger('price');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('products');
    }
};

database/migrations/2026_10_14_000030_create_sales_table.php

Unduh berkas
php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('sales', function (Blueprint $table) {
            $table->id();
            $table->foreignId('product_id')
                ->constrained()
                ->restrictOnDelete();
            $table->unsignedInteger('quantity');
            $table->unsignedInteger('price');
            $table->date('sale_date');
            $table->timestamps();

            $table->index('sale_date');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('sales');
    }
};

database/seeders/DatabaseSeeder.php

Unduh berkas
php
<?php

namespace Database\Seeders;

use App\Models\Category;
use App\Models\Product;
use App\Models\Sale;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class DatabaseSeeder extends Seeder
{
    /**
     * Data penjualan 2026 dibangkitkan murni lewat rumus aritmetika —
     * tanpa rand() dan tanpa now() — sehingga angkanya selalu sama
     * setiap kali seeder dijalankan dan bisa dituntut tepat oleh test.
     */
    public function run(): void
    {
        $categoryIds = collect([
            ['name' => 'Alat Tulis', 'color' => '#2563eb'],
            ['name' => 'Buku', 'color' => '#16a34a'],
            ['name' => 'Kertas & Catatan', 'color' => '#f97316'],
        ])->mapWithKeys(function (array $row): array {
            $category = Category::create($row);

            return [$category->name => $category->id];
        });

        $products = collect([
            ['Alat Tulis', 'Pulpen Gel Hitam 0,5', 15000],
            ['Alat Tulis', 'Pensil Kayu HB isi 12', 28000],
            ['Alat Tulis', 'Penggaris Besi 30 cm', 22000],
            ['Buku', 'Buku Tulis Sivi 58 halaman', 6500],
            ['Buku', 'Buku Agenda Hard Cover', 89000],
            ['Buku', 'Novel Perjalanan Pulang', 98000],
            ['Kertas & Catatan', 'Kertas HVS A4 80g 1 rim', 55000],
            ['Kertas & Catatan', 'Sticky Notes Warna 3x3', 18000],
        ])->map(function (array $row) use ($categoryIds): Product {
            return Product::create([
                'category_id' => $categoryIds[$row[0]],
                'name' => $row[1],
                'price' => $row[2],
            ]);
        })->values();

        $sales = [];
        $total = 0;

        foreach (range(1, 12) as $month) {
            $monthStart = Carbon::create(2026, $month, 1);
            $daysInMonth = $monthStart->daysInMonth;
            $count = 35 + $month;

            foreach (range(0, $count - 1) as $j) {
                $day = ($j * 269 + $month * 101) % $daysInMonth + 1;
                $product = $products[($j * 5 + $month) % $products->count()];
                $quantity = 1 + (($j + $month) % 3);

                $sales[] = [
                    'product_id' => $product->id,
                    'quantity' => $quantity,
                    'price' => $product->price,
                    'sale_date' => $monthStart->copy()->day($day)->toDateString(),
                    'created_at' => now()->toDateTimeString(),
                    'updated_at' => now()->toDateTimeString(),
                ];

                $total++;
            }
        }

        foreach (array_chunk($sales, 100) as $chunk) {
            DB::table('sales')->insert($chunk);
        }

        $this->command?->info('Kategori terisi: ' . $categoryIds->count());
        $this->command?->info('Produk terisi: ' . $products->count());
        $this->command?->info('Penjualan terisi: ' . $total);
    }
}

routes/web.php

Unduh berkas
php
<?php

use App\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route;

Route::get('/', fn () => redirect()->route('dashboard'))->name('home');

Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');

tests/Feature/DashboardFilterTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Services\DashboardService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class DashboardFilterTest extends TestCase
{
    use RefreshDatabase;

    private DashboardService $dashboard;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seed();
        $this->dashboard = app(DashboardService::class);
    }

    public function test_filter_rentang_membatasi_agregasi_ke_periode_terpilih(): void
    {
        $maret = $this->dashboard->categoryShare(
            Carbon::parse('2026-03-01'),
            Carbon::parse('2026-03-31'),
        );

        $this->assertSame(['Buku', 'Kertas & Catatan', 'Alat Tulis'], $maret->pluck('name')->all());
        $this->assertSame([2122000, 602000, 551000], $maret->pluck('revenue')->all());
        $this->assertSame([65, 18, 17], $maret->pluck('percent')->all());
    }

    public function test_penjumlahan_dua_semester_sama_dengan_setahun(): void
    {
        $semester1 = $this->dashboard->monthlyTrend(
            Carbon::parse('2026-01-01'),
            Carbon::parse('2026-06-30'),
        );
        $semester2 = $this->dashboard->monthlyTrend(
            Carbon::parse('2026-07-01'),
            Carbon::parse('2026-12-31'),
        );

        $this->assertSame(19326500, $semester1->sum('revenue'));
        $this->assertSame(21979500, $semester2->sum('revenue'));
        $this->assertSame(
            $semester1->sum('revenue') + $semester2->sum('revenue'),
            41306000,
        );
    }

    public function test_halaman_menerima_filter_rentang_tanggal(): void
    {
        $response = $this->get('/dashboard?from=2026-03-01&to=2026-03-31');

        $response->assertOk();
        $response->assertSee('per 31 Maret 2026');
        $response->assertSee('Rp 3.275.000');
        $response->assertSee('38 pesanan');
    }

    public function test_format_tanggal_tidak_sah_ditolak(): void
    {
        $response = $this->get('/dashboard?from=31-06-2026');

        $response->assertStatus(302);
        $response->assertSessionHasErrors('from');
    }

    public function test_tanggal_akhir_mendahului_awal_ditolak(): void
    {
        $response = $this->get('/dashboard?from=2026-05-31&to=2026-05-01');

        $response->assertStatus(302);
        $response->assertSessionHasErrors(['to' => 'Tanggal akhir harus sama dengan atau setelah tanggal awal.']);
    }

    public function test_rentang_tanpa_penjualan_menampilkan_nol(): void
    {
        $response = $this->get('/dashboard?from=2025-01-01&to=2025-12-31');

        $response->assertOk();
        $response->assertSee('Rp 0');
        $response->assertSee('Belum ada penjualan pada rentang ini.');
        $response->assertSee('"revenue":[0,0,0,0,0,0,0,0,0,0,0,0]', false);
    }
}

tests/Feature/DashboardMetricTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use App\Models\Sale;
use App\Services\DashboardService;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class DashboardMetricTest extends TestCase
{
    use RefreshDatabase;

    private DashboardService $dashboard;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seed();
        $this->dashboard = app(DashboardService::class);
    }

    public function test_ringkasan_hari_menghitung_pendapatan_dan_pesanan_tepat(): void
    {
        $juli = $this->dashboard->todaySummary(Carbon::parse('2026-07-15'));
        $tahunBaru = $this->dashboard->todaySummary(Carbon::parse('2026-01-01'));

        $this->assertSame(['revenue' => 15000, 'orders' => 1], $juli);
        $this->assertSame(['revenue' => 267000, 'orders' => 1], $tahunBaru);
    }

    public function test_ringkasan_bulan_menghitung_total_pesanan_dan_rata_rata(): void
    {
        $januari = $this->dashboard->monthSummary(Carbon::parse('2026-01-15'));
        $desember = $this->dashboard->monthSummary(Carbon::parse('2026-12-15'));

        $this->assertSame(3038000, $januari['revenue']);
        $this->assertSame(36, $januari['orders']);
        $this->assertSame(84389, $januari['avg_order_value']);

        $this->assertSame(3924000, $desember['revenue']);
        $this->assertSame(47, $desember['orders']);
        $this->assertSame(83489, $desember['avg_order_value']);
    }

    public function test_tren_bulanan_setahun_menghasilkan_dua_belas_titik(): void
    {
        $trend = $this->dashboard->monthlyTrend(
            Carbon::parse('2026-01-01'),
            Carbon::parse('2026-12-31'),
        );

        $this->assertCount(12, $trend);

        $this->assertSame(
            [3038000, 2954000, 3275000, 3222500, 3435500, 3401500,
             3404500, 3630000, 3359000, 3822000, 3840000, 3924000],
            $trend->pluck('revenue')->all(),
        );

        $this->assertSame(
            [36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47],
            $trend->pluck('orders')->all(),
        );

        $this->assertSame('Des 2026', $trend->last()['label']);
    }

    public function test_tren_bulanan_mengisi_bulan_tanpa_penjualan_dengan_nol(): void
    {
        $trend = $this->dashboard->monthlyTrend(
            Carbon::parse('2025-11-01'),
            Carbon::parse('2026-02-28'),
        );

        $this->assertSame(0, $trend[0]['revenue']);
        $this->assertSame(0, $trend[1]['revenue']);
        $this->assertSame(3038000, $trend[2]['revenue']);
        $this->assertSame(2954000, $trend[3]['revenue']);
    }

    public function test_produk_teratas_diurutkan_menurun_berdasar_pendapatan(): void
    {
        $top = $this->dashboard->topProducts(
            Carbon::parse('2026-01-01'),
            Carbon::parse('2026-12-31'),
        );

        $this->assertSame(
            ['Novel Perjalanan Pulang', 'Buku Agenda Hard Cover', 'Kertas HVS A4 80g 1 rim',
             'Pensil Kayu HB isi 12', 'Penggaris Besi 30 cm'],
            $top->pluck('name')->all(),
        );

        $this->assertSame(
            [12054000, 11214000, 6930000, 3444000, 2772000],
            $top->pluck('revenue')->all(),
        );
    }

    public function test_pangsa_kategori_berjumlah_seratus_persen(): void
    {
        $share = $this->dashboard->categoryShare(
            Carbon::parse('2026-01-01'),
            Carbon::parse('2026-12-31'),
        );

        $this->assertSame(['Buku', 'Kertas & Catatan', 'Alat Tulis'], $share->pluck('name')->all());
        $this->assertSame([24074000, 9126000, 8106000], $share->pluck('revenue')->all());
        $this->assertSame([58, 22, 20], $share->pluck('percent')->all());
        $this->assertSame(100, $share->sum('percent'));
    }

    public function test_agregasi_sql_sama_dengan_penjumlahan_per_baris(): void
    {
        $revenue = 0;
        $orders = 0;

        foreach (Sale::all() as $sale) {
            $revenue += $sale->quantity * $sale->price;
            $orders++;
        }

        $tahun = $this->dashboard->monthlyTrend(
            Carbon::parse('2026-01-01'),
            Carbon::parse('2026-12-31'),
        );

        $this->assertSame($revenue, $tahun->sum('revenue'));
        $this->assertSame($orders, $tahun->sum('orders'));
        $this->assertSame(498, $orders);
    }
}

tests/Feature/DashboardPageTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class DashboardPageTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        $this->seed();
    }

    public function test_dashboard_tampil_tanpa_login(): void
    {
        $response = $this->get('/dashboard');

        $response->assertOk();
        $response->assertSee('Dashboard Penjualan');
        $response->assertSee('Tren Bulanan');
        $response->assertSee('Pangsa Kategori');
        $response->assertSee('Riwayat Penjualan');
    }

    public function test_muatan_grafik_garis_terender_penuh_dari_server(): void
    {
        $response = $this->get('/dashboard?from=2026-01-01&to=2026-12-31');

        $payload = json_encode([
            'labels' => ['Jan 2026', 'Feb 2026', 'Mar 2026', 'Apr 2026', 'Mei 2026', 'Jun 2026',
                         'Jul 2026', 'Agt 2026', 'Sep 2026', 'Okt 2026', 'Nov 2026', 'Des 2026'],
            'revenue' => [3038000, 2954000, 3275000, 3222500, 3435500, 3401500,
                          3404500, 3630000, 3359000, 3822000, 3840000, 3924000],
            'orders' => [36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47],
        ], 15);

        $response->assertOk();
        $response->assertSee('const trend = ' . $payload . ';', false);
    }

    public function test_batang_kategori_menampilkan_lebar_persentase(): void
    {
        $response = $this->get('/dashboard?from=2026-01-01&to=2026-12-31');

        $response->assertOk();
        $response->assertSee('width: 58%; background: #16a34a;', false);
        $response->assertSee('width: 22%; background: #f97316;', false);
        $response->assertSee('width: 20%; background: #2563eb;', false);
        $response->assertSee('Rp 24.074.000 (58%)');
    }

    public function test_kartu_ringkasan_menampilkan_angka_bulan_acuan(): void
    {
        $response = $this->get('/dashboard?from=2026-01-01&to=2026-12-31');

        $response->assertOk();
        $response->assertSee('per 31 Desember 2026');
        $response->assertSee('Pendapatan Bulan Desember');
        $response->assertSee('Rp 3.924.000');
        $response->assertSee('47 pesanan');
        $response->assertSee('Rp 83.489');
    }

    public function test_tabel_penjualan_terpaginasi_sepuluh_baris_per_halaman(): void
    {
        $response = $this->get('/dashboard?from=2026-01-01&to=2026-12-31');

        $response->assertOk();

        // 17 baris = 6 dari tabel produk teratas (kepala + 5) + 11 dari
        // tabel penjualan (kepala + 10 baris terpaginasi).
        $this->assertSame(17, substr_count($response->getContent(), '<tr>'));
        $response->assertSee('Menampilkan 1&ndash;10', false);
        $response->assertSee('dari 498 penjualan');
    }

    public function test_paginasi_menjaga_query_filter(): void
    {
        $response = $this->get('/dashboard?from=2026-01-01&to=2026-12-31&page=2');

        $response->assertOk();
        $response->assertSee('Menampilkan 11&ndash;20', false);
        $response->assertSee('from=2026-01-01&amp;to=2026-12-31&amp;page=3', false);
        $response->assertSee('from=2026-01-01&amp;to=2026-12-31&amp;page=1', false);
    }
}

tests/Feature/ExampleTest.php

Unduh berkas
php
<?php

namespace Tests\Feature;

use Tests\TestCase;

class ExampleTest extends TestCase
{
    public function test_halaman_utama_mengalihkan_ke_dashboard(): void
    {
        $this->get('/')->assertRedirect(route('dashboard'));
    }
}