Setelah lima proyek — blog, inventaris, REST API, keuangan, dan pemesanan — kita menutup Course Laravel dengan proyek yang tidak menerima input baru sama sekali: dashboard analisis data untuk toko alat tulis dan buku “Toko Lentera”. Aplikasi sebelumnya sibuk menulis data; proyek ini sibuk membacanya kembali dan menjawab pertanyaan pemilik toko dalam satu layar: berapa pendapatan hari ini, bagaimana tren dua belas bulan terakhir, produk apa yang paling menghasilkan, dan ke kategori mana uang pembeli mengalir. Tantangannya bergeser dari menjaga konsistensi penulisan menjadi menjaga kebenaran angka — satu salah hitung di SUM() langsung menjadi keputusan bisnis yang salah — sehingga seluruh agregasi kita kunci dalam satu service class dan dibuktikan lewat test dengan angka eksak.
Dashboard yang akan kita bangun bekerja sebagai satu halaman publik tanpa login:
- menghitung metrik hari ini dan bulan acuan: pendapatan, jumlah pesanan, dan rata-rata nilai pesanan (average order value),
- menggambar grafik garis tren bulanan dengan Chart.js yang dimuat dari CDN, tetapi seluruh datanya disuntik dari server lewat
@jsonsehingga bisa diuji, - menampilkan pangsa kategori sebagai batang murni CSS yang lebarnya persentase — angka yang sama juga dituntut tepat oleh test,
- merangking lima produk teratas berdasarkan pendapatan pada rentang yang dipilih,
- menerima filter rentang tanggal
from/toyang tervalidasi, termasuk penolakan tanggal akhir yang mendahului awal, - menyajikan tabel penjualan terpaginasi sepuluh baris per halaman yang tetap membawa query filter saat berpindah halaman.
Batas scope juga tegas sejak awal: dashboard ini pembaca, bukan penulis. Tidak ada form input, tidak ada autentikasi, tidak ada edit data — pemilik toko membukanya dari layar kasir dan hanya butuh angka yang benar.
Tujuan Pembelajaran #
Setelah menyelesaikan proyek ini, kita mampu:
- memusatkan agregasi
SUM,COUNT, dangroupBydalam satu service class sehingga hanya ada satu tempat yang boleh menghitung uang, - menekan database, bukan PHP, yang memeras ratusan baris menjadi belasan angka,
- menyuntik array PHP ke JavaScript lewat
@jsontanpa kehilangan kemampuan menguji, - memilih visualisasi yang tepat sasaran: Chart.js untuk grafik interaktif, batang CSS untuk angka yang harus bisa diperiksa assertion,
- memvalidasi query parameter rentang tanggal termasuk aturan lintas-kolom yang ditulis manual,
- memakai pagination yang menjaga query string filter,
- menulis test agregasi dengan angka eksak di atas seeder deterministik.
Prasyarat #
Proyek ini tidak memperkenalkan materi baru, melainkan memakai materi yang sudah dibahas. Chapter yang paling sering dirujuk:
- Membuat Migration , Foreign Keys , dan Indexes dan Constraints — skema tiga tabel beserta index untuk query agregasi,
- Konsep Eloquent
dan One to Many
— relasi
Category-Product-Sale, - Pagination — tabel penjualan terpaginasi,
- Layouts dan Sections serta Menampilkan Data — halaman dashboard,
- Validation Rules — filter rentang tanggal,
- Feature Tests dan Database Testing — pengujian angka dan tampilan.
Struktur Data #
Tiga tabel menopang seluruh angka: categories menyimpan kategori produk dengan warna penanda, products menyimpan katalog beserta harganya, dan sales menjadi satu-satunya sumber kebenaran angka — setiap baris adalah satu pesanan satu produk dengan quantity, price snapshot, dan sale_date. Kolom sale_date diberi index tersendiri karena hampir setiap query dashboard memfilternya. Peta enam bagian beserta keluaran utamanya:
| Bagian | Keluaran utama |
|---|---|
| 1. Perancangan | tiga migration + daftar metrik + aliran data |
| 2. Pengumpulan Data | seeder 498 penjualan deterministik sepanjang 2026 |
| 3. Query dan Agregasi | DashboardService dengan lima metode agregasi |
| 4. Visualisasi | kartu, grafik Chart.js @json, batang CSS pangsa kategori |
| 5. Filter dan Pagination | validasi from/to + tabel terpaginasi |
| 6. Pengujian | 21 test (feature + unit) yang selalu hijau |
Tahapan Pengerjaan #
Proyek dikerjakan dalam enam bagian berurutan. Setiap bagian menghasilkan aplikasi yang dapat dijalankan penuh:
- Bagian 1: Perancangan Dashboard — menetapkan metrik, membangun migrasi tiga tabel, dan memetakan aliran data,
- Bagian 2: Pengumpulan Data — menulis seeder penjualan 2026 yang dibangkitkan lewat rumus aritmetika,
- Bagian 3: Query dan Agregasi — memusatkan seluruh perhitungan dalam
DashboardService, - Bagian 4: Visualisasi Data — menyusun kartu ringkasan, grafik garis, dan batang kategori,
- Bagian 5: Filter dan Pagination — menerima filter rentang tanggal dan memaginasi tabel penjualan,
- Bagian 6: Pengujian Dashboard — membuktikan angka agregasi dan tampilannya lewat feature test.
Mulailah dari Bagian 1 dan ikuti urutannya karena setiap bagian dibangun di atas berkas bagian sebelumnya: angka yang diuji di Bagian 6 pertama kali lahir dari rumus seeder di Bagian 2. Seluruh berkas sumber tersedia pada folder proyek ini: app/, database/, resources/, routes/, dan tests/.
Menjalankan Aplikasi #
Setelah mengikuti seluruh bagian (atau menyalin berkas sumber ke proyek Laravel baru dengan database SQLite), siapkan database beserta data contoh:
php artisan migrate --seedOutput 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 ........................... DONE
INFO Seeding database.
Kategori terisi: 3
Produk terisi: 8
Penjualan terisi: 498Jalankan php artisan serve, buka http://localhost:8000, dan Anda langsung diarahkan ke /dashboard yang menampilkan angka hari berjalan. Untuk melihat cerita setahun penuh, terapkan filter from=2026-01-01 dan to=2026-12-31: kartu menampilkan angka Desember, grafik garis memuat dua belas titik, dan tabel penjualan menampilkan 498 baris dalam 50 halaman. Sebelum menjelajah, pastikan seluruh aturan aplikasi masih utuh dengan menjalankan php artisan test — 21 test dengan 74 assertion seharusnya berakhir hijau dalam waktu kurang dari satu detik, persis seperti output yang kita bedah pada Bagian 6.
Tentukan metrik, sumber data, dan layout dashboard.
Buka → # Bagian 2: Pengumpulan DataSiapkan seeder data penjualan yang realistis untuk dashboard.
Buka → # Bagian 3: Query dan AgregasiHitung total, rata-rata, dan tren dengan aggregate Eloquent.
Buka → # Bagian 4: Visualisasi DataBangun grafik garis dan kartu ringkasan dengan Blade dan Chart.js.
Buka → # Bagian 5: Filter dan PaginationSediakan filter rentang tanggal dan tabel data terpaginasi.
Buka → # Bagian 6: Pengujian DashboardUji kebenaran angka agregasi dan tampilannya.
Buka →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'));
}
}