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():
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 indeks1(Pensil Kayu HB isi 12), qty1 + (1 % 3) = 2,j=1: hari(269 + 101) % 31 + 1 = 30, produk indeks6(Kertas HVS A4 80g 1 rim), qty1 + (2 % 3) = 3,j=2: hari(538 + 101) % 31 + 1 = 20, produk indeks3(Buku Tulis Sivi 58 halaman), qty1 + (0 % 3) = 1.
Setelah php artisan db:seed, tiga baris pertama tabel menurut id:
Output yang Dihasilkan:
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-20Hitungan 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:
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:
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=3924000Volume 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
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'));
}
}