Angka yang benar belum tentu angka yang terbaca cepat — 3404500 baru bermakna ketika berdiri sebagai titik di antara sebelas titik lain yang menanjak. Bagian ini mengubah keluaran DashboardService menjadi satu layar, dan kita memakai dua strategi visualisasi yang dipilih dengan sadar: Chart.js untuk grafik garis tren yang interaktif, dan batang murni CSS untuk pangsa kategori yang lebarnya harus bisa dituntut oleh test. Keduanya berpegang pada satu prinsip: server yang menghitung, browser hanya menggambar.
Empat Kartu Ringkasan #
Puncak halaman resources/views/dashboard/index.blade.php diisi empat kartu yang menjawab pertanyaan pagi pemilik toko:
<div class="cards">
<div class="card">
<span class="card-title">Pendapatan Hari Ini</span>
<span class="card-value">{{ \App\Support\Rupiah::format($today['revenue']) }}</span>
<span class="card-sub">{{ $today['orders'] }} pesanan · per {{ $reference->translatedFormat('d M Y') }}</span>
</div>
<div class="card">
<span class="card-title">Pendapatan Bulan {{ $reference->translatedFormat('F') }}</span>
<span class="card-value">{{ \App\Support\Rupiah::format($month['revenue']) }}</span>
<span class="card-sub">{{ $month['orders'] }} pesanan</span>
</div>
</div>Perhatikan subtitle kartu pertama: per {{ $reference->translatedFormat('d M Y') }}. Tanggal acuan selalu tertulis di layar sehingga pembaca tahu “hari ini” merujuk tanggal mana — hari berjalan saat tanpa filter, atau tanggal akhir rentang saat filter dipakai. Dua kartu sisanya menampilkan jumlah pesanan bulan acuan dan rata-rata nilai pesanan; keempatnya hanya memakan dua panggilan service — todaySummary() dan monthSummary() — karena satu kartu memakai angka utama, kartu berikutnya memakai angka pendampingnya. Pemformatan rupiah berpusat di app/Support/Rupiah.php ('Rp ' . number_format($value, 0, ',', '.')) supaya 3924000 tampil sebagai Rp 3.924.000 di seluruh halaman.
Grafik Garis dengan Data dari Server #
Bagian tengah halaman menampung kanvas dan satu blok @push('scripts'):
@push('scripts')
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script>
const trend = @json($chart);
new Chart(document.getElementById('trend-chart'), {
type: 'line',
data: {
labels: trend.labels,
datasets: [
{
label: 'Pendapatan (Rp)',
data: trend.revenue,
borderColor: '#4f46e5',
backgroundColor: 'rgba(79, 70, 229, 0.12)',
fill: true,
tension: 0.3,
yAxisID: 'y',
},
{
label: 'Pesanan',
data: trend.orders,
borderColor: '#16a34a',
borderDash: [5, 5],
pointRadius: 3,
yAxisID: 'y1',
},
],
},
options: {
responsive: true,
scales: {
y: { beginAtZero: true },
y1: { position: 'right', grid: { drawOnChartArea: false } },
},
},
});
</script>
@endpushBaris terpenting justru yang paling pendek: const trend = @json($chart);. Directive @json meng-encode array PHP menjadi literal JavaScript di dalam HTML — bukan hasil panggilan AJAX — sehingga array yang digambar Chart.js sudah jadi sebelum browser mulai bekerja. Controller menyusun $chart dari koleksi tren (labels, revenue, orders), dan inilah muatannya saat filter setahun 2026 diterapkan, disalin apa adanya dari HTML yang dikirim server:
const trend = {"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]};Karena array ini teks biasa di respons HTTP, feature test dapat mencocokkannya secara utuh — pembuktianya di Bagian 6. Grafik menggambar dua garis: pendapatan pada sumbu kiri, jumlah pesanan pada sumbu kanan bergaris putus-putus, keduanya berbagi label bulan yang sama. Dua sumbu dipisah karena satuannya berbeda — rupiah jutaan dan puluhan pesanan tidak boleh berbagi skala, jika tidak garis pesanan akan menempel di dasar grafik. Opsi tension: 0.3 melengkungkan garis sedikit agar tren terbaca lebih halus, dan fill: true mengarsir area di bawah garis pendapatan sehingga lonjakan Desember terasa “penuh”.
Batang CSS untuk Pangsa Kategori #
Untuk pangsa kategori kita menolak Chart.js dan kembali ke dua kelas CSS dari layout: .bar-track sebagai lintasan abu-abu dan .bar-fill sebagai batang berwarna:
@foreach ($categoryShare as $row)
<div class="bar-row">
<span class="bar-label">
<span class="color-dot" style="background: {{ $row['color'] }};"></span>{{ $row['name'] }}
</span>
<div class="bar-track">
<div class="bar-fill" style="width: {{ $row['percent'] }}%; background: {{ $row['color'] }};"></div>
</div>
<span class="bar-value">
{{ \App\Support\Rupiah::format($row['revenue']) }} ({{ $row['percent'] }}%)
</span>
</div>
@endforeachSeluruh keputusan visual ada di server: warna diambil dari kolom categories.color, lebar adalah percent hasil categoryShare() Bagian 3, dan browser hanya menerima width: 58%. Hasil render setahun 2026:
<div class="bar-fill" style="width: 58%; background: #16a34a;"></div>
<div class="bar-fill" style="width: 22%; background: #f97316;"></div>
<div class="bar-fill" style="width: 20%; background: #2563eb;"></div>Buku menguasai lebih dari separuh lintasan dengan 58%, cerita yang terbaca tanpa satu angka pun dihitung. Tabel produk teratas dan tabel penjualan di bawahnya hanyalah HTML <table> biasa — penjumlahan quantity * price per baris ditampilkan apa adanya karena setiap angka rinci harus bisa dilacak ke baris database.
Layar sudah hidup, tetapi masih berjangkar pada satu tanggal. Bagian berikutnya memberi pengguna kendali penuh atas rentang waktu: filter tervalidasi dan tabel yang terpaginasi tanpa kehilangan filternya.
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'));
}
}