Bagian 9 dari seri

Membuat Dashboard dan Laporan Sistem Kasir Laravel

Diterbitkan 16 September 2026

Pada tahap kesembilan kita akan membuat dashboard dan laporan sistem kasir Laravel.

Tahap ini menggunakan data yang sudah terbentuk dari:

produk
penjualan
pembelian
stok
stock_movements

Tujuannya adalah menampilkan informasi penting secara ringkas agar administrator dapat melihat kondisi toko tanpa membuka seluruh transaksi satu per satu.

Dashboard akan menampilkan:

omzet hari ini
jumlah transaksi hari ini
produk terlaris
stok menipis
stok habis
jumlah produk aktif

Sedangkan laporan akan menyediakan:

filter tanggal
filter status
filter kasir
pencarian invoice
ringkasan omzet
jumlah transaksi
total diskon
daftar transaksi
detail transaksi

Pada struktur tutorial baru, slug lama:

dashboard-laporan-dan-nota

tetap dipertahankan agar URL yang sudah publish tidak berubah.

Namun isi tahap ini difokuskan pada:

dashboard
laporan

Bagian cetak nota akan dibuat lebih detail pada tahap berikutnya dengan slug baru.

Hasil yang Akan Dibuat

Pada tahap ini kita akan membuat:

DashboardController
ReportController

Route:

/dashboard
/reports/sales

View:

resources/views/dashboard/index.blade.php
resources/views/reports/sales.blade.php

Fitur dashboard:

  • omzet hari ini;
  • transaksi hari ini;
  • produk aktif;
  • produk stok menipis;
  • produk habis;
  • produk terlaris;
  • transaksi terbaru.

Fitur laporan:

  • filter tanggal awal;
  • filter tanggal akhir;
  • filter kasir;
  • filter status;
  • pencarian invoice;
  • total omzet;
  • total transaksi;
  • total diskon;
  • pagination;
  • link detail transaksi.

Persiapan

Pastikan Tahap 01 sampai Tahap 08 sudah selesai.

Aplikasi harus sudah mempunyai data:

users
products
sales
sale_details
purchases
stock_movements

Pastikan sudah ada beberapa transaksi penjualan untuk menguji dashboard dan laporan.

Login sebagai administrator:

admin@example.com
admin12345

1. Prinsip Dashboard

Dashboard bukan tempat menampilkan semua data.

Dashboard sebaiknya menampilkan indikator penting yang dapat dipahami cepat.

Pada tutorial ini kita menggunakan:

hari ini

sebagai periode utama.

Data penjualan hanya menghitung transaksi dengan:

status = paid

Transaksi cancelled tidak ikut dihitung sebagai omzet.

2. Membuat DashboardController

Jalankan:

php artisan make:controller DashboardController

File:

app/Http/Controllers/DashboardController.php

3. Menambahkan Route Dashboard

Buka:

routes/web.php

Tambahkan import:

use App\Http\Controllers\DashboardController;

Tambahkan route:

Route::middleware(['auth'])->group(function () {
    Route::get(
        '/dashboard',
        [DashboardController::class, 'index']
    )->name('dashboard');
});

Dashboard dapat dibuka admin maupun kasir.

Namun beberapa informasi sensitif dapat disembunyikan berdasarkan role jika diperlukan.

Untuk tutorial ini keduanya dapat melihat ringkasan operasional.

4. DashboardController Lengkap

Buka:

app/Http/Controllers/DashboardController.php

Isi:

<?php

namespace App\Http\Controllers;

use App\Models\Product;
use App\Models\Sale;
use App\Models\SaleDetail;
use Illuminate\View\View;

class DashboardController extends Controller
{
    public function index(): View
    {
        $today = now()->toDateString();

        $todaySales = Sale::query()
            ->where('status', 'paid')
            ->whereDate('sale_date', $today);

        $todayRevenue = (clone $todaySales)
            ->sum('grand_total');

        $todayTransactions = (clone $todaySales)
            ->count();

        $activeProducts = Product::query()
            ->where('is_active', true)
            ->count();

        $lowStocks = Product::query()
            ->with(['category', 'unit'])
            ->where('is_active', true)
            ->whereColumn('stock', '<=', 'minimum_stock')
            ->orderBy('stock')
            ->limit(10)
            ->get();

        $outOfStockCount = Product::query()
            ->where('is_active', true)
            ->where('stock', 0)
            ->count();

        $bestSellers = SaleDetail::query()
            ->selectRaw(
                'product_id, SUM(quantity) as total_quantity'
            )
            ->whereHas('sale', function ($query) use ($today) {
                $query
                    ->where('status', 'paid')
                    ->whereDate('sale_date', $today);
            })
            ->with('product.unit')
            ->groupBy('product_id')
            ->orderByDesc('total_quantity')
            ->limit(5)
            ->get();

        $latestSales = Sale::query()
            ->with(['user', 'customer'])
            ->latest('sale_date')
            ->limit(10)
            ->get();

        return view('dashboard.index', compact(
            'todayRevenue',
            'todayTransactions',
            'activeProducts',
            'lowStocks',
            'outOfStockCount',
            'bestSellers',
            'latestSales'
        ));
    }
}

5. Mengapa Query Sales Di-clone?

Kita membuat query dasar:

$todaySales = Sale::query()
    ->where('status', 'paid')
    ->whereDate('sale_date', $today);

Kemudian:

(clone $todaySales)->sum('grand_total');

dan:

(clone $todaySales)->count();

Clone digunakan agar query builder dasar dapat digunakan kembali dengan aman.

6. Omzet Hari Ini

Query:

->sum('grand_total')

hanya menghitung transaksi:

paid

dan:

sale_date = hari ini

Transaksi cancelled tidak masuk.

7. Jumlah Transaksi Hari Ini

Query:

->count()

menghitung jumlah header transaksi paid hari ini.

8. Produk Aktif

Query:

Product::where('is_active', true)->count();

menampilkan jumlah produk yang masih aktif.

9. Stok Menipis

Query:

whereColumn(
    'stock',
    '<=',
    'minimum_stock'
)

mengambil produk yang stoknya sudah berada di bawah atau sama dengan batas minimum.

10. Produk Habis

Query:

where('stock', 0)

menghitung produk aktif yang benar-benar habis.

11. Produk Terlaris

Kita mengambil data dari:

sale_details

bukan dari produk.

Query menjumlahkan:

SUM(quantity)

per:

product_id

dan hanya menggunakan penjualan:

status = paid

hari ini.

12. Transaksi Terbaru

Dashboard menampilkan 10 transaksi terbaru.

Transaksi cancelled tetap dapat tampil dengan statusnya agar admin mengetahui aktivitas terbaru.

13. Membuat View Dashboard

Buat folder:

resources/views/dashboard

Buat:

resources/views/dashboard/index.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Dashboard')

@section('content')
<div class="mb-4">
    <h1 class="h4 mb-1">Dashboard</h1>

    <p class="text-muted mb-0">
        Ringkasan aktivitas sistem kasir hari ini.
    </p>
</div>

<div class="row g-3 mb-4">
    <div class="col-md-6 col-xl-3">
        <div class="card h-100">
            <div class="card-body">
                <div class="text-muted small">
                    Omzet Hari Ini
                </div>

                <div class="fs-4 fw-semibold">
                    Rp {{ number_format(
                        (float) $todayRevenue,
                        0,
                        ',',
                        '.'
                    ) }}
                </div>
            </div>
        </div>
    </div>

    <div class="col-md-6 col-xl-3">
        <div class="card h-100">
            <div class="card-body">
                <div class="text-muted small">
                    Transaksi Hari Ini
                </div>

                <div class="fs-4 fw-semibold">
                    {{ $todayTransactions }}
                </div>
            </div>
        </div>
    </div>

    <div class="col-md-6 col-xl-3">
        <div class="card h-100">
            <div class="card-body">
                <div class="text-muted small">
                    Produk Aktif
                </div>

                <div class="fs-4 fw-semibold">
                    {{ $activeProducts }}
                </div>
            </div>
        </div>
    </div>

    <div class="col-md-6 col-xl-3">
        <div class="card h-100">
            <div class="card-body">
                <div class="text-muted small">
                    Produk Habis
                </div>

                <div class="fs-4 fw-semibold">
                    {{ $outOfStockCount }}
                </div>
            </div>
        </div>
    </div>
</div>

<div class="row g-3 mb-4">
    <div class="col-xl-7">
        <div class="card h-100">
            <div class="card-header">
                Produk Stok Menipis
            </div>

            <div class="card-body">
                <div class="table-responsive">
                    <table class="table table-bordered align-middle mb-0">
                        <thead>
                            <tr>
                                <th>Produk</th>
                                <th>Kategori</th>
                                <th class="text-center">Stok</th>
                                <th class="text-center">Minimum</th>
                            </tr>
                        </thead>

                        <tbody>
                            @forelse($lowStocks as $product)
                                <tr>
                                    <td>
                                        <div class="fw-semibold">
                                            {{ $product->name }}
                                        </div>

                                        <div class="small text-muted">
                                            {{ $product->sku }}
                                        </div>
                                    </td>

                                    <td>
                                        {{ $product->category?->name ?? '-' }}
                                    </td>

                                    <td class="text-center">
                                        {{ $product->stock }}
                                        {{ $product->unit?->symbol }}
                                    </td>

                                    <td class="text-center">
                                        {{ $product->minimum_stock }}
                                    </td>
                                </tr>
                            @empty
                                <tr>
                                    <td
                                        colspan="4"
                                        class="text-center text-muted">
                                        Tidak ada produk stok menipis.
                                    </td>
                                </tr>
                            @endforelse
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>

    <div class="col-xl-5">
        <div class="card h-100">
            <div class="card-header">
                Produk Terlaris Hari Ini
            </div>

            <div class="card-body">
                <div class="table-responsive">
                    <table class="table table-bordered align-middle mb-0">
                        <thead>
                            <tr>
                                <th>Produk</th>
                                <th class="text-end">Terjual</th>
                            </tr>
                        </thead>

                        <tbody>
                            @forelse($bestSellers as $item)
                                <tr>
                                    <td>
                                        {{ $item->product?->name ?? 'Produk' }}
                                    </td>

                                    <td class="text-end">
                                        {{ $item->total_quantity }}
                                        {{ $item->product?->unit?->symbol }}
                                    </td>
                                </tr>
                            @empty
                                <tr>
                                    <td
                                        colspan="2"
                                        class="text-center text-muted">
                                        Belum ada penjualan hari ini.
                                    </td>
                                </tr>
                            @endforelse
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
    </div>
</div>

<div class="card">
    <div class="card-header d-flex justify-content-between align-items-center">
        <span>Transaksi Terbaru</span>

        <a
            href="{{ route('sales.index') }}"
            class="btn btn-sm btn-outline-primary">
            Lihat Semua
        </a>
    </div>

    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-bordered align-middle mb-0">
                <thead>
                    <tr>
                        <th>Invoice</th>
                        <th>Tanggal</th>
                        <th>Kasir</th>
                        <th>Pelanggan</th>
                        <th class="text-end">Total</th>
                        <th>Status</th>
                    </tr>
                </thead>

                <tbody>
                    @forelse($latestSales as $sale)
                        <tr>
                            <td>
                                <a
                                    href="{{ route('sales.show', $sale) }}">
                                    {{ $sale->invoice_number }}
                                </a>
                            </td>

                            <td>
                                {{ $sale->sale_date->format('d/m/Y H:i') }}
                            </td>

                            <td>
                                {{ $sale->user?->name ?? '-' }}
                            </td>

                            <td>
                                {{ $sale->customer?->name ?? 'Umum' }}
                            </td>

                            <td class="text-end">
                                Rp {{ number_format(
                                    (float) $sale->grand_total,
                                    0,
                                    ',',
                                    '.'
                                ) }}
                            </td>

                            <td>
                                @if($sale->status === 'paid')
                                    <span class="badge text-bg-success">
                                        Dibayar
                                    </span>
                                @else
                                    <span class="badge text-bg-danger">
                                        Dibatalkan
                                    </span>
                                @endif
                            </td>
                        </tr>
                    @empty
                        <tr>
                            <td
                                colspan="6"
                                class="text-center text-muted">
                                Belum ada transaksi.
                            </td>
                        </tr>
                    @endforelse
                </tbody>
            </table>
        </div>
    </div>
</div>
@endsection

14. Membuat ReportController

Sekarang kita membuat laporan penjualan.

Jalankan:

php artisan make:controller ReportController

File:

app/Http/Controllers/ReportController.php

15. Menambahkan Route Laporan

Tambahkan import:

use App\Http\Controllers\ReportController;

Tambahkan route admin:

Route::middleware(['auth', 'role:admin'])->group(function () {
    Route::get(
        'reports/sales',
        [ReportController::class, 'sales']
    )->name('reports.sales');
});

Laporan hanya dapat diakses administrator.

16. Menambahkan Menu Laporan

Pada layout:

@if(auth()->user()->role === 'admin')
    <li class="nav-item">
        <a
            class="nav-link"
            href="{{ route('reports.sales') }}">
            Laporan Penjualan
        </a>
    </li>
@endif

17. Import ReportController

Gunakan:

use App\Models\Sale;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\View\View;

18. ReportController Lengkap

Buka:

app/Http/Controllers/ReportController.php

Isi:

<?php

namespace App\Http\Controllers;

use App\Models\Sale;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\View\View;

class ReportController extends Controller
{
    public function sales(Request $request): View
    {
        $validated = $request->validate([
            'start_date' => [
                'nullable',
                'date',
            ],
            'end_date' => [
                'nullable',
                'date',
                'after_or_equal:start_date',
            ],
            'status' => [
                'nullable',
                'in:paid,cancelled',
            ],
            'user_id' => [
                'nullable',
                'exists:users,id',
            ],
            'q' => [
                'nullable',
                'string',
                'max:100',
            ],
        ]);

        $startDate = $validated['start_date']
            ?? now()->startOfMonth()->toDateString();

        $endDate = $validated['end_date']
            ?? now()->toDateString();

        $status = $validated['status'] ?? 'paid';
        $userId = $validated['user_id'] ?? null;
        $search = trim((string) ($validated['q'] ?? ''));

        $baseQuery = Sale::query()
            ->whereDate('sale_date', '>=', $startDate)
            ->whereDate('sale_date', '<=', $endDate)
            ->when(
                $status !== '',
                fn ($query) => $query->where('status', $status)
            )
            ->when(
                $userId,
                fn ($query) => $query->where('user_id', $userId)
            )
            ->when(
                $search !== '',
                fn ($query) => $query->where(
                    'invoice_number',
                    'like',
                    '%' . $search . '%'
                )
            );

        $totalRevenue = (clone $baseQuery)
            ->where('status', 'paid')
            ->sum('grand_total');

        $totalDiscount = (clone $baseQuery)
            ->where('status', 'paid')
            ->sum('discount');

        $totalTransactions = (clone $baseQuery)
            ->count();

        $sales = (clone $baseQuery)
            ->with(['user', 'customer'])
            ->latest('sale_date')
            ->paginate(20)
            ->withQueryString();

        $users = User::query()
            ->whereIn('role', ['admin', 'cashier'])
            ->orderBy('name')
            ->get([
                'id',
                'name',
                'role',
            ]);

        return view('reports.sales', compact(
            'sales',
            'users',
            'startDate',
            'endDate',
            'status',
            'userId',
            'search',
            'totalRevenue',
            'totalDiscount',
            'totalTransactions'
        ));
    }
}

19. Default Periode Laporan

Jika user tidak memilih tanggal:

start_date = awal bulan
end_date = hari ini

Kode:

now()->startOfMonth()->toDateString()

dan:

now()->toDateString()

20. Default Status Laporan

Default:

paid

Artinya laporan awal menampilkan transaksi yang benar-benar menjadi penjualan.

Transaksi cancelled dapat dilihat dengan filter status.

21. Total Revenue

Ringkasan omzet hanya menghitung:

status = paid

Walaupun user memilih filter tertentu, omzet tidak boleh menjumlahkan cancelled sebagai pendapatan.

22. Total Discount

Diskon juga hanya dihitung dari transaksi paid.

23. Total Transactions

totalTransactions mengikuti filter base query.

Jika status:

cancelled

maka total transaksi menunjukkan jumlah cancelled sesuai filter.

24. Membuat View Laporan

Buat folder:

resources/views/reports

Buat:

resources/views/reports/sales.blade.php

Isi:

@extends('layouts.app')

@section('title', 'Laporan Penjualan')

@section('content')
<div class="mb-3">
    <h1 class="h4 mb-1">
        Laporan Penjualan
    </h1>

    <p class="text-muted mb-0">
        Filter transaksi berdasarkan periode, status, dan kasir.
    </p>
</div>

<div class="card mb-3">
    <div class="card-body">
        <form
            method="GET"
            action="{{ route('reports.sales') }}"
            class="row g-2">

            <div class="col-lg-2">
                <label class="form-label">
                    Tanggal Awal
                </label>

                <input
                    type="date"
                    name="start_date"
                    value="{{ $startDate }}"
                    class="form-control">
            </div>

            <div class="col-lg-2">
                <label class="form-label">
                    Tanggal Akhir
                </label>

                <input
                    type="date"
                    name="end_date"
                    value="{{ $endDate }}"
                    class="form-control">
            </div>

            <div class="col-lg-2">
                <label class="form-label">
                    Status
                </label>

                <select
                    name="status"
                    class="form-select">

                    <option
                        value="paid"
                        @selected($status === 'paid')}>
                        Dibayar
                    </option>

                    <option
                        value="cancelled"
                        @selected($status === 'cancelled')}>
                        Dibatalkan
                    </option>

                    <option
                        value=""
                        @selected($status === '')>
                        Semua
                    </option>
                </select>
            </div>

            <div class="col-lg-2">
                <label class="form-label">
                    Kasir
                </label>

                <select
                    name="user_id"
                    class="form-select">

                    <option value="">
                        Semua Kasir
                    </option>

                    @foreach($users as $user)
                        <option
                            value="{{ $user->id }}"
                            @selected(
                                (string) $userId
                                ===
                                (string) $user->id
                            )>
                            {{ $user->name }}
                        </option>
                    @endforeach
                </select>
            </div>

            <div class="col-lg-2">
                <label class="form-label">
                    Invoice
                </label>

                <input
                    type="text"
                    name="q"
                    value="{{ $search }}"
                    class="form-control"
                    placeholder="INV-...">
            </div>

            <div class="col-lg-2 d-flex align-items-end">
                <div class="d-flex gap-2 w-100">
                    <button
                        class="btn btn-primary">
                        Filter
                    </button>

                    <a
                        href="{{ route('reports.sales') }}"
                        class="btn btn-secondary">
                        Reset
                    </a>
                </div>
            </div>
        </form>
    </div>
</div>

<div class="row g-3 mb-3">
    <div class="col-md-4">
        <div class="card h-100">
            <div class="card-body">
                <div class="text-muted small">
                    Omzet
                </div>

                <div class="fs-4 fw-semibold">
                    Rp {{ number_format(
                        (float) $totalRevenue,
                        0,
                        ',',
                        '.'
                    ) }}
                </div>
            </div>
        </div>
    </div>

    <div class="col-md-4">
        <div class="card h-100">
            <div class="card-body">
                <div class="text-muted small">
                    Total Diskon
                </div>

                <div class="fs-4 fw-semibold">
                    Rp {{ number_format(
                        (float) $totalDiscount,
                        0,
                        ',',
                        '.'
                    ) }}
                </div>
            </div>
        </div>
    </div>

    <div class="col-md-4">
        <div class="card h-100">
            <div class="card-body">
                <div class="text-muted small">
                    Jumlah Transaksi
                </div>

                <div class="fs-4 fw-semibold">
                    {{ $totalTransactions }}
                </div>
            </div>
        </div>
    </div>
</div>

<div class="card">
    <div class="card-body">
        <div class="table-responsive">
            <table class="table table-bordered align-middle">
                <thead>
                    <tr>
                        <th>No</th>
                        <th>Invoice</th>
                        <th>Tanggal</th>
                        <th>Kasir</th>
                        <th>Pelanggan</th>
                        <th class="text-end">Subtotal</th>
                        <th class="text-end">Diskon</th>
                        <th class="text-end">Total</th>
                        <th>Status</th>
                        <th>Aksi</th>
                    </tr>
                </thead>

                <tbody>
                    @forelse($sales as $sale)
                        <tr>
                            <td>
                                {{ $sales->firstItem() + $loop->index }}
                            </td>

                            <td>
                                <code>
                                    {{ $sale->invoice_number }}
                                </code>
                            </td>

                            <td>
                                {{ $sale->sale_date->format('d/m/Y H:i') }}
                            </td>

                            <td>
                                {{ $sale->user?->name ?? '-' }}
                            </td>

                            <td>
                                {{ $sale->customer?->name ?? 'Umum' }}
                            </td>

                            <td class="text-end">
                                Rp {{ number_format(
                                    (float) $sale->subtotal,
                                    0,
                                    ',',
                                    '.'
                                ) }}
                            </td>

                            <td class="text-end">
                                Rp {{ number_format(
                                    (float) $sale->discount,
                                    0,
                                    ',',
                                    '.'
                                ) }}
                            </td>

                            <td class="text-end">
                                Rp {{ number_format(
                                    (float) $sale->grand_total,
                                    0,
                                    ',',
                                    '.'
                                ) }}
                            </td>

                            <td>
                                @if($sale->status === 'paid')
                                    <span class="badge text-bg-success">
                                        Dibayar
                                    </span>
                                @else
                                    <span class="badge text-bg-danger">
                                        Dibatalkan
                                    </span>
                                @endif
                            </td>

                            <td>
                                <a
                                    href="{{ route('sales.show', $sale) }}"
                                    class="btn btn-sm btn-outline-primary">
                                    Detail
                                </a>
                            </td>
                        </tr>
                    @empty
                        <tr>
                            <td
                                colspan="10"
                                class="text-center text-muted">
                                Tidak ada transaksi sesuai filter.
                            </td>
                        </tr>
                    @endforelse
                </tbody>
            </table>
        </div>

        {{ $sales->links() }}
    </div>
</div>
@endsection

25. Validasi Periode

Laporan menggunakan:

'after_or_equal:start_date'

untuk:

end_date

Artinya tanggal akhir tidak boleh lebih kecil dari tanggal awal.

26. Filter Kasir

Filter menggunakan:

user_id

dan memastikan ID user valid melalui:

exists:users,id

Daftar user dibatasi:

admin
cashier

27. Filter Invoice

Pencarian invoice menggunakan:

LIKE

Contoh:

INV-20260916

dapat menampilkan seluruh transaksi pada pola invoice tersebut.

28. Mengapa Tidak Menghitung Cancelled sebagai Revenue?

Transaksi cancelled hanya histori.

Pendapatan hanya berasal dari:

paid

Karena itu dashboard dan ringkasan omzet harus selalu memisahkan cancelled.

29. Menguji Dashboard

Buat beberapa transaksi hari ini.

Contoh:

Transaksi A = 20.000
Transaksi B = 30.000

Dashboard harus menampilkan:

Omzet = 50.000
Transaksi = 2

Jika transaksi B dibatalkan:

Omzet = 20.000
Transaksi = 1

untuk indikator paid hari ini.

30. Menguji Produk Terlaris

Contoh:

Air Mineral terjual 5
Kopi Sachet terjual 2

Dashboard harus mengurutkan:

Air Mineral
Kopi Sachet

berdasarkan jumlah terjual.

31. Menguji Stok Menipis

Ubah stok melalui transaksi sampai:

stock <= minimum_stock

Produk harus muncul di dashboard.

32. Menguji Laporan Bulan Berjalan

Buka:

/reports/sales

Tanpa filter, periode default:

awal bulan sampai hari ini

status default:

paid

33. Menguji Filter Tanggal

Pilih:

start_date = 2026-09-01
end_date = 2026-09-15

Hanya transaksi pada periode tersebut yang tampil.

34. Menguji Filter Cancelled

Pilih:

status = cancelled

Daftar hanya menampilkan transaksi dibatalkan.

Ringkasan omzet tetap:

0

karena cancelled bukan pendapatan.

35. Menguji Semua Status

Pilih:

status = semua

Daftar menampilkan:

paid
cancelled

Namun omzet hanya menjumlahkan paid.

36. Menguji Filter Kasir

Pilih salah satu kasir.

Daftar hanya menampilkan transaksi user tersebut.

37. Menguji Pagination

Jika transaksi lebih dari 20, pagination muncul.

Karena menggunakan:

withQueryString()

semua filter tetap dipertahankan ketika berpindah halaman.

38. Menguji Tanggal Salah

Masukkan:

start_date = 2026-09-20
end_date = 2026-09-10

Validasi harus menolak.

39. Menguji Akses Kasir ke Laporan

Login sebagai kasir.

Buka:

/reports/sales

Akses harus ditolak karena route laporan menggunakan:

role:admin

40. Dashboard Tetap Bisa Dibuka Kasir

Kasir dapat membuka:

/dashboard

karena dashboard hanya menggunakan:

auth

Jika ingin menyembunyikan omzet dari kasir, view dapat diberi kondisi role.

Contoh:

@if(auth()->user()->role === 'admin')
    ...
@endif

Namun tutorial dasar tetap menampilkan dashboard yang sama.

41. Struktur File Setelah Tahap 09

Controller:

app/Http/Controllers/
├── DashboardController.php
└── ReportController.php

View:

resources/views/dashboard/
└── index.blade.php

resources/views/reports/
└── sales.blade.php

Route:

dashboard
reports.sales

42. Checklist Dashboard

Pastikan:

[ ] omzet hari ini tampil
[ ] transaksi hari ini tampil
[ ] produk aktif tampil
[ ] produk habis tampil
[ ] stok menipis tampil
[ ] produk terlaris tampil
[ ] transaksi terbaru tampil
[ ] cancelled tidak dihitung sebagai omzet

43. Checklist Laporan

Pastikan:

[ ] periode default benar
[ ] filter tanggal bekerja
[ ] filter status bekerja
[ ] filter kasir bekerja
[ ] pencarian invoice bekerja
[ ] omzet benar
[ ] diskon benar
[ ] jumlah transaksi benar
[ ] pagination bekerja
[ ] filter tetap saat pagination
[ ] link detail transaksi bekerja

44. Kesalahan Umum: Dashboard Menghitung Cancelled

Pastikan query memakai:

where('status', 'paid')

untuk omzet dan jumlah transaksi paid.

45. Kesalahan Umum: Produk Terlaris Menghitung Cancelled

Pastikan:

whereHas('sale', function ($query) {
    $query->where('status', 'paid');
})

46. Kesalahan Umum: Laporan Menggunakan created_at

Kita menggunakan:

sale_date

karena itu adalah tanggal transaksi bisnis.

created_at adalah waktu record dibuat.

Keduanya dapat berbeda.

47. Kesalahan Umum: Omzet Mengikuti Status Semua

Jika filter:

status = semua

jangan menjumlahkan cancelled.

Gunakan:

where('status', 'paid')

pada query ringkasan omzet.

48. Kesalahan Umum: Query N+1

Gunakan:

with(['user', 'customer'])

ketika menampilkan daftar transaksi.

49. Kesalahan Umum: Produk Terlaris Tidak Group By

Pastikan:

groupBy('product_id')

agar quantity dijumlahkan per produk.

50. Pengembangan Lanjutan

Dashboard dan laporan dapat dikembangkan dengan:

grafik omzet harian
grafik omzet bulanan
laba kotor
produk paling menguntungkan
laporan pembelian
laporan stok
export Excel
export PDF
perbandingan periode
dashboard per kasir

Untuk tutorial dasar, fitur saat ini sudah cukup untuk memberikan ringkasan operasional.

51. Tentang Laba

Tutorial ini belum menghitung laba secara akuntansi.

Walaupun produk mempunyai:

purchase_price
selling_price

menghitung laba historis yang benar memerlukan keputusan metode biaya.

Contoh:

harga beli berubah
stok berasal dari beberapa batch

Karena itu dashboard saat ini fokus pada:

omzet
jumlah transaksi
produk terlaris
stok

bukan laba bersih.

52. Mengapa Nota Dipisahkan?

Pada versi lama, artikel ini mencakup:

dashboard
laporan
nota

Namun jika nota dibahas lengkap, dibutuhkan:

data toko
layout thermal
58 mm
80 mm
print CSS
route cetak
setting toko
auto print opsional

Agar tutorial tidak terlalu padat, nota dipisahkan menjadi tahap berikutnya.

URL lama:

dashboard-laporan-dan-nota

tetap dipertahankan.

53. Checkpoint Hasil

Setelah Tahap 09:

Login                         : selesai
Role admin/kasir              : selesai
Database                      : selesai
Model dan relasi              : selesai
Seeder                        : selesai
CRUD master                   : selesai
CRUD produk                   : selesai
Penjualan                     : selesai
Pembelian                     : selesai
Kartu stok                    : selesai
Penyesuaian stok              : selesai
Dashboard                     : selesai
Laporan penjualan             : selesai
Produk terlaris               : selesai
Stok menipis                  : selesai
Nota                          : tahap berikutnya
Testing                       : belum
Deploy                        : belum

54. Ringkasan

Pada tahap kesembilan kita telah membuat dashboard dan laporan sistem kasir.

Dashboard menampilkan:

omzet hari ini
jumlah transaksi
produk aktif
produk habis
stok menipis
produk terlaris
transaksi terbaru

Laporan penjualan menyediakan:

filter tanggal
filter status
filter kasir
pencarian invoice
omzet
diskon
jumlah transaksi
pagination
detail transaksi

Seluruh ringkasan pendapatan hanya menghitung:

status = paid

Transaksi cancelled tetap tersedia untuk histori tetapi tidak dihitung sebagai omzet.

Tahap berikutnya kita akan membuat cetak nota kasir dengan tampilan thermal 58 mm dan 80 mm.

Lanjut: Cetak Nota Kasir

Komentar

Belum ada komentar.

Tulis Komentar

Komentar pengunjung diperiksa sebelum ditampilkan.