Data 498 baris sudah menunggu; sekarang database harus menjawab pertanyaan pemilik toko tanpa memindahkan 498 baris tersebut ke PHP. Prinsip kerja bagian ini satu kalimat: database yang memeras, PHP yang menerima hasil jadi. Seluruh perhitungan hidup dalam satu class app/Services/DashboardService.php — satu-satunya tempat yang boleh menghitung uang — dengan lima metode publik: ringkasan hari, ringkasan bulan, tren bulanan, produk teratas, dan pangsa kategori. Controller cukup menyambungkan tanggal, dan view cukup menampilkan.

Ringkasan Hari dan Bulan #

Dua metode pertama memakai agregasi yang sama pada jendela waktu berbeda:

php
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,
    ];
}

Perbandingan where('sale_date', $day->toDateString()) sengaja memakai string tanggal, bukan whereDate() — membandingkan kolom langsung dengan '2026-07-15' memungkinkan database memakai index Bagian 1, sedangkan whereDate() membungkus kolom dalam fungsi tanggal yang memaksa pembacaan seluruh tabel. Ekspresi SUM(quantity * price) adalah pendapatan per baris yang dijumlahkan, dan COALESCE(..., 0) menjaga hari tanpa penjualan menghasilkan nol, bukan NULL. Versi bulanan mengganti filter menjadi whereBetween dari startOfMonth() sampai endOfMonth(), lalu menambah rata-rata nilai pesanan:

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

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

Pembagi nol dijaga secara eksplisit karena bulan tanpa penjualan bukan galat, melainkan kenyataan toko yang baru buka. Kedua metode ini paling sering dipanggil berdampingan oleh controller, dan inilah angkanya atas data seeder:

Output yang Dihasilkan:

text
todaySummary 2026-07-15  revenue=15000 orders=1
todaySummary 2026-01-01  revenue=267000 orders=1
monthSummary 2026-01      revenue=3038000 orders=36 aov=84389
monthSummary 2026-07      revenue=3404500 orders=42 aov=81060
monthSummary 2026-12      revenue=3924000 orders=47 aov=83489

Tren Dua Belas Bulan #

Metode monthlyTrend() mengelompokkan per bulan lewat substr(sale_date, 1, 7) — memotong 2026-03-05 menjadi 2026-03 — lalu berjalan bulan demi bulan dengan Carbon untuk mengisi bulan kosong dengan nol, sehingga grafik selalu menerima titik data yang rapi:

php
$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');

$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();
}

Satu query mengembalikan dua belas baris, dua belas iterasi while menyusun urutannya, dan translatedFormat('M Y') setelah Carbon::setLocale('id') di constructor menghasilkan label Jan 2026 sampai Des 2026. Mengapa pengelompokan tidak berhenti di hasil query? Karena SQL hanya mengembalikan bulan yang punya baris — bulan kosong absen total — sedangkan grafik garis butuh dua belas titik berjarak sama. Pengisian nol juga berlaku untuk bulan di luar data: rentang 2025-11 sampai 2026-02 menghasilkan 0, 0, 3038000, 2954000, sehingga sumbu grafik tidak pernah bolong dan perbandingan antar bulan tetap adil.

Join Dua Tabel dan Jebakan Kolom Kembar #

topProducts() dan categoryShare() membutuhkan nama produk dan kategori, jadi query builder berpindah dari Eloquent ke join :

php
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,
    ]);

Dua keputusan kecil di sini punya alasan. limit($limit) diletakkan di query, bukan ->take(5) pada collection, karena memotong di database berarti hanya lima grup produk yang pernah dihitung; take() akan mengagregasi seluruh delapan produk dulu lalu membuang tiga. Sedangkan groupBy mendaftarkan semua kolom non-agregat secara eksplisit agar query tetap sah di database yang ketat soal kolom telanjang. categoryShare() menambah satu join lagi ke categories, lalu menghitung persentase di PHP setelah total diketahui: (int) round($row->revenue / $total * 100) — bilangan bulat stabil yang nanti menjadi lebar batang. Hasil keduanya untuk setahun penuh:

text
topProducts 2026:
  1. Novel Perjalanan Pulang   qty=123 revenue=12054000
  2. Buku Agenda Hard Cover    qty=126 revenue=11214000
  3. Kertas HVS A4 80g 1 rim   qty=126 revenue=6930000
  4. Pensil Kayu HB isi 12     qty=123 revenue=3444000
  5. Penggaris Besi 30 cm      qty=126 revenue=2772000

categoryShare 2026:
  Buku              #16a34a revenue=24074000 percent=58
  Kertas & Catatan  #f97316 revenue=9126000  percent=22
  Alat Tulis        #2563eb revenue=8106000  percent=20

Sebagai penutup, kita cross-check total tahunan dengan cara paling bodoh yang bisa dipercaya: memuat seluruh baris ke PHP lalu menjumlahkan satu per satu — hasilnya 41306000 dari 498 pesanan, persis jumlah kedua semester (19326500 + 21979500) dan jumlah dua belas angka tren. Dua jalur berbeda, satu angka yang sama; pola ini akan dijadikan test di Bagian 6. Angka-angka yang benar kini menunggu wujud — tugas 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'));
    }
}