Setiap dashboard yang berhasil lahir dari daftar pertanyaan, bukan dari kerinduan memakai library grafik. Pemilik Toko Lentera hanya membawa empat pertanyaan setiap pagi: berapa penjualan kemarin dan hari ini, bagaimana bulan ini dibanding bulan-bulan sebelumnya, produk apa yang paling menghasilkan, dan ke kategori mana uang pembeli mengalir. Bagian ini menetapkan pertanyaan itu menjadi metrik yang bisa dihitung, lalu membangun tiga tabel yang menopangnya. Satu keputusan paling penting diambil sejak awal: dashboard ini murni pembaca — tanpa form, tanpa login, tanpa tulis data — sehingga seluruh energi desain kita habis di satu arah, kebenaran angka.
Dari Pertanyaan ke Metrik #
Empat pertanyaan pemilik toko kita terjemahkan menjadi tabel padanan yang menjadi kontrak seluruh proyek:
| Pertanyaan pemilik toko | Metrik | Sumber |
|---|---|---|
| Berapa penjualan hari ini? | pendapatan & jumlah pesanan hari acuan | sales per sale_date |
| Bagaimana bulan ini berjalan? | pendapatan, pesanan, rata-rata nilai pesanan bulan acuan | sales per bulan |
| Produk apa yang paling laku? | lima produk dengan pendapatan terbesar | sales join products |
| Ke mana uang mengalir? | pangsa pendapatan per kategori dalam persen | sales join products join categories |
Baris terakhir tabel menyimpan istilah yang akan sering muncul: tanggal acuan. Kartu “hari ini” dan “bulan ini” dashboard tidak diikat pada jam dinding server, melainkan pada tanggal akhir rentang yang sedang dilihat — hari ini saat tanpa filter, atau tanggal to saat filter dipakai. Pola ini membuat halaman tetap deterministik saat diuji dan tetap jujur menampilkan asal angkanya.
Aliran Data Satu Layar #
Seluruh permintaan melewati jalur yang sama, dari query string sampai kanvas grafik:
Bagian menarik dari diagram ini ada di ujungnya: Chart.js hanya menggambar, tidak pernah menghitung. Array yang digambar disuntik dari server lewat @json, jadi angka yang dilihat pengguna adalah angka yang bisa dituntut oleh test — pembahasan lengkapnya ada di Bagian 4. Panah dari DashboardService ke database juga sengaja satu-satunya jalur agregasi: controller dan view dilarang menghitung.
Tiga Tabel dan Satu Index #
Kategori dan produk adalah tabel master yang akrab dari proyek sebelumnya; bintangnya adalah sales. Migrasi database/migrations/..._create_sales_table.php memuat tiga keputusan:
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');
});Pertama, price disimpan sebagai snapshot di sisi penjualan, bukan sekadar merujuk products.price — pola yang kita pakai juga pada proyek aplikasi pemesanan agar riwayat tidak ikut berubah saat harga katalog naik. Kedua, restrictOnDelete() menjaga produk yang pernah terjual tidak bisa dihapus, karena setiap baris sales adalah bukti transaksi. Ketiga, sale_date diberi index tersendiri: hampir semua query dashboard — ringkasan hari, ringkasan bulan, tren, pangsa, tabel — menyaring kolom ini, dan index pada kolom yang sering difilter
adalah cara termurah menjaga dashboard tetap cepat saat data bertambah.
Tabel categories menyimpan name unik dan color kode hex tujuh karakter yang nanti menjadi warna batang pangsa, sedangkan products berelasi belongsTo ke kategorinya. Setelah ketiga file migration dibuat, skema berdiri berurutan:
php artisan migrateOutput yang Dihasilkan:
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 ........................... DONELayout halaman sendiri mengikuti urutan baca manusia: filter di paling atas karena mengubah seluruh angka di bawahnya, empat kartu ringkasan untuk jawaban sekilas, grafik tren dan batang kategori di tengah untuk pola, lalu tabel penjualan terpaginasi di dasar untuk detail. Urutan ini juga urutan frekuensi baca: kartu dilihat setiap pagi, tren saat pekan berjalan, tabel hanya saat ada kejanggalan yang perlu ditelusuri. Form filter kita kirim dengan method GET, bukan POST — parameter tampil di URL sehingga rentang kuartal terbaik bisa dibagikan atau dibookmark, dan halaman tidak pernah menulis apa pun ke database. Skema sudah berdiri; sekarang kita butuh data yang cukup banyak untuk membuat agregasinya bermakna — tugas seeder 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
namespace App\Http\Controllers;
abstract class Controller
{
//
}
app/Http/Controllers/DashboardController.php
Unduh berkas<?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
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
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
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
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
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
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
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
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
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
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
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
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
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–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–20', false);
$response->assertSee('from=2026-01-01&to=2026-12-31&page=3', false);
$response->assertSee('from=2026-01-01&to=2026-12-31&page=1', false);
}
}
tests/Feature/ExampleTest.php
Unduh berkas<?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'));
}
}