Dashboard butuh data bervolume untuk bermakna — tren dua belas bulan tidak bisa dibuktikan dengan lima baris. Kebutuhan khusus proyek ini lebih dalam dari sekadar “banyak”: data harus deterministik, artinya setiap kali seeder dijalankan, baris yang sama lahir dengan angka yang sama, karena test di Bagian 6 akan menuntut total pendapatan sampai ke rupiah terakhir. Karena itu kita meninggalkan kebiasaan memakai rand() atau Faker tanpa seed, dan menggantinya dengan pembangkit murni aritmetika: tiga rumus kecil yang bisa dihitung ulang dengan kertas dan pensil.

Tiga Rumus Pembangkit #

Inti database/seeders/DatabaseSeeder.php adalah loop ganda berikut — tiga kategori dan delapan produk dimasukkan lebih dulu lewat Category::create() dan Product::create():

php
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);
}

Mari kita bedah empat baris rumusnya. $count = 35 + $month membuat volume tumbuh dari 36 pesanan pada Januari sampai 47 pada Desember — toko yang perlahan ramai, total 498 baris selama setahun. $day = ($j * 269 + $month * 101) % $daysInMonth + 1 menyebarkan tanggal; angka 269 dipilih karena koprima dengan panjang bulan mana pun (28, 29, 30, 31), sehingga langkah j tidak pernah tersangkut di subset tanggal. $product = $products[($j * 5 + $month) % 8] memutar produk tiap baris dengan offset per bulan, membuat bauran penjualan tiap bulan sedikit berbeda. Terakhir $quantity = 1 + (($j + $month) % 3) menghasilkan jumlah beli satu sampai tiga yang merata.

Memeriksa Rumus dengan Tangan #

Determinisme baru berguna bila bisa diverifikasi. Ambil Januari 2026 ($month = 1) dan tiga baris pertama ($j = 0, 1, 2):

  • j=0: hari (0 + 101) % 31 + 1 = 9, produk indeks 1 (Pensil Kayu HB isi 12), qty 1 + (1 % 3) = 2,
  • j=1: hari (269 + 101) % 31 + 1 = 30, produk indeks 6 (Kertas HVS A4 80g 1 rim), qty 1 + (2 % 3) = 3,
  • j=2: hari (538 + 101) % 31 + 1 = 20, produk indeks 3 (Buku Tulis Sivi 58 halaman), qty 1 + (0 % 3) = 1.

Setelah php artisan db:seed, tiga baris pertama tabel menurut id:

Output yang Dihasilkan:

text
id=1 product_id=2 qty=2 price=28000 sale_date=2026-01-09
id=2 product_id=7 qty=3 price=55000 sale_date=2026-01-30
id=3 product_id=4 qty=1 price=6500  sale_date=2026-01-20

Hitungan manual di atas cocok satu per satu: produk indeks 1 adalah product_id=2, indeks 6 adalah product_id=7, indeks 3 adalah product_id=4 — sesuai urutan insert delapan produk. Kolom price terisi dari products.price saat itu juga, itulah harga snapshot. Sebaran hasil akhirnya bisa dilihat dari pengelompokan per bulan — query yang sama akan menjadi metode monthlyTrend() di Bagian 3:

php
DB::table('sales')
    ->groupBy('bulan')
    ->selectRaw("substr(sale_date, 1, 7) AS bulan")
    ->selectRaw('COUNT(*) AS orders')
    ->selectRaw('SUM(quantity * price) AS revenue')
    ->orderBy('bulan')
    ->get();

Output yang Dihasilkan:

text
2026-01  orders=36  revenue=3038000
2026-02  orders=37  revenue=2954000
2026-03  orders=38  revenue=3275000
2026-04  orders=39  revenue=3222500
2026-05  orders=40  revenue=3435500
2026-06  orders=41  revenue=3401500
2026-07  orders=42  revenue=3404500
2026-08  orders=43  revenue=3630000
2026-09  orders=44  revenue=3359000
2026-10  orders=45  revenue=3822000
2026-11  orders=46  revenue=3840000
2026-12  orders=47  revenue=3924000

Volume naik stabil mengikuti 35 + $month, sedangkan pendapatan bergelombang karena rotasi produk membuat bulan-bulan tertentu menjual lebih banyak barang mahal. Jumlah pesanan per bulan dan total setahun itulah yang nanti dituntut persis oleh test. Dua baris now() pada kolom created_at/updated_at tidak masalah — tidak ada metrik yang membacanya.

Penyisipan 498 baris dipecah array_chunk(..., 100) lalu dimasukkan lewat query builder, bukan satu Sale::create() per baris, agar seeder selesai dalam hitungan milidetik. Tahun 2026 juga ditulis mati lewat Carbon::create(2026, $month, 1), bukan now() — waktu nyata bergerak setiap detik, sedangkan assertion kita nanti menuntut angka yang diam. Tiga baris command?->info() di akhir seeder sengaja memakai nullsafe: pesan tampil saat seeder dijalankan lewat php artisan db:seed, tetapi diam saat dipanggil ulang oleh test yang memanggil $this->seed() berkali-kali, sehingga output runner tetap bersih. Data sudah ada di meja; saatnya mengajukan pertanyaan pertama kepada database pada bagian berikutnya.

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'));
    }
}