Aplikasi Booking Online Laravel 9 & Livewire #4: Dashboard Admin Page

Aplikasi Booking Online Laravel 9 & Livewire #4: Dashboard Admin Page

Pendahuluan

Klinik memiliki akses penuh dalam mengelola data Pasien, maka melalui artikel ini kita akan membuat 3 buah fitur, yakni: fitur Authentication menggunakan Laravel Jetstream, Kelola data Pasien dan Kelola data antrian. Setiap User yang login dapat melihat informasi lengkap tentang Pasien, melakukan filter data berdasarkan status dan slot antrian, serta mengelola antrian dan berhak untuk membatalkan jika Pasien tidak datang.

Artikel ini akan menjadi materi penutup dalam serial belajar membuat Aplikasi Booking Online menggunakan Laravel 9 & Livewire, dimana saya akan memberikan challenge bagi pembaca untuk menyelesaikan fitur Manage User guna untuk melengkapi fitur dari aplikasi yang telah kita buat. Konsepnya sederhana, hanya menampilkan, menambahkan dan menghapus data User. Berbekal pengetahuan yang kita peroleh sepanjang serial ini, saya yakin teman-teman sudah bisa mengerjakannya sendiri.

Baca Juga: Aplikasi Booking Online Laravel 9 & Livewire - Livewire Component & Sitem Antrian

Authentication Laravel Jetstream

Sebenarnya materi ini sudah pernah kita bahas dalam artikel yang lain: Membuat Login & Register Laravel 8, jadi saya tidak akan menjelaskannya secara detail lagi. Dari command line, install Jetstream package dengan command

composer require laravel/jetstream

Karena kita tidak membutuhkan fitur teams, maka install Jetstream Livewire dengan command

php artisan jetstream:install livewire

Kemudian generate css dan js-nya dengan command

npm install && npm run dev

Lalu jalankan migrate untuk mengeksekusi migration yang dibuat

php artisan migrate

Kita publish vendor dari Jetstream dengan command

php artisan vendor:publish --tag=jetstream-views

Selamat!, Kita sudah memiliki halaman Login lengkap dengan dashboard-nya. Halaman otentikasi bisa diakses dengan url `http://localhost:3000/login

Untuk membuat user login-nya, kita generate sebuah seeder baru

php artisan make:seeder UsersTableSeeder

Buka file database/seeders/UsersTableSeeder.php dan modifikasi menjadi

<?php

namespace Database\Seeders;

use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use App\Models\User;

class UsersTableSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        User::create([
            'name' => 'DaengWeb',
            'email' => '[email protected]',
            'password' => bcrypt('secret')
        ]);
    }
}

Silahkan login menggunakan user yang baru saja ditambahkan.

Kelola Data Pasien

Menampilkan data Pasien secara lengkap dan detail akan menjadi pencapaian kita kali ini, dimana informasi ini akan digunakan oleh User di klinik untuk mengetahui aktivitas yang akan terjadi dikliniknya. Adapun tampilan yang akan kita buat terlihat seperti gambar berikut

Dari command line, generate sebuah Livewire component dengan command

php artisan make:livewire Admin.Order.Index

Buka file app/Http/Livewire/Admin/Order/OrderIndex.php dan modifikasi menjadi

<?php

namespace App\Http\Livewire\Admin\Order;

use Livewire\Component;
use Livewire\WithPagination;
use App\Models\Order;
use App\Models\DailySlot;
use Carbon\Carbon;

class OrderIndex extends Component
{
    use WithPagination;

    public $dailySlot = [];
    
    public $filterStatus;
    public $filterSlot;

    public function mount()
    {
        //QUERY INI KITA BUAT DIDALAM MOUNT KARENA DATANYA HANYA PERLU DI-LOAD SEKALI SAJA
        $this->dailySlot = DailySlot::orderBy('id', 'ASC')->get();
    }

    public function render()
    {
        //MEMBUAT QUERY UNTUK MENGAMBIL DATA PASIEN SECARA LENGKAP
        $orders = Order::with(['daily_slot'])
            ->when($this->filterStatus != '', function($query) {
                //JIKA FILTER STATUS TIDAK KOSONG, MAKA KITA BUAT QUERY FILTER BERDASARKAN STATUS YANG INGIN DILIHAT OLEH USER
                $query->where('status', $this->filterStatus);
            })
            ->when($this->filterSlot != '', function($query) {
                $query->where('daily_slot_id', $this->filterSlot);
            })
            //KEMUDIAN KITA URUTKAN BERDASARKAN DATA TERBARU
            ->orderBy('created_at', 'DESC')
            ->paginate(10); 

        //QUERY UNTUK MENGAMBIL TOTAL DATA, FUNGSI QUERY INI HAMPIR SAMA DENGAN QUERY YANG KITA BUAT PADA ARTIKEL SEBELUMNYA
        $totalOrder = Order::where('day', Carbon::now()->format('Y-m-d'))->whereIn('status', [0, 1, 2, 3])->count();
        $onProgress = Order::where('day', Carbon::now()->format('Y-m-d'))->whereIn('status', [0, 1])->count();
        $complete = Order::where('day', Carbon::now()->format('Y-m-d'))->whereIn('status', [2])->count();
      
        return view('livewire.admin.order.order_index', compact('orders', 'totalOrder', 'onProgress', 'complete'))->layout('layouts.app');
        //KITA GUNAKAN FUNGSI LAYOUT() UNTUK MENENTUKAN MASTER TEMPLATE MANA YANG AKAN KITA GUNAKAN
    }
}

Kemudian kita buat tampilannya, buka file livewire.admin.order.order_index.php dan tambahkan code

<div>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            Daftar Pasien
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="grid grid-rows-3 grid-flow-col py-3">
                <div class="row-span-3 bg-gray-100 block mb-3 p-6 max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
                    <h5 class="mb-2 text-1xl tracking-tight text-gray-900 dark:text-white">Pasien Hari Ini</h5>
                    <h1 class="text-3xl font-bold tracking-tight text-gray-700 dark:text-gray-400">{{ $totalOrder }}</h1>
                </div>
                <div class="row-span-3 bg-blue-100 block mb-3 p-6 max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
                    <h5 class="mb-2 text-1xl tracking-tight text-gray-900 dark:text-white">Antrian Hari Ini</h5>
                    <h1 class="text-3xl font-bold tracking-tight text-gray-700 dark:text-gray-400">{{ $onProgress }}</h1>
                </div>
                <div class="row-span-3 bg-green-100 block mb-3 p-6 max-w-sm bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
                    <h5 class="mb-2 text-1xl tracking-tight text-gray-900 dark:text-white">Selesai Ditangani</h5>
                    <h1 class="text-3xl font-bold tracking-tight text-gray-700 dark:text-gray-400">{{ $complete }}</h1>
                </div>
            </div>

            <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-3">
                <div class="grid grid-rows-4 grid-flow-col py-3">
                    <div class="row-span-4">
                        <label for="status" class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-400">Filter Status</label>
                        <select id="status" wire:model="filterStatus" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
                            <option value="">Pilih</option>
                            <option value="0">Dalam Antrian</option>
                            <option value="1">Dilayani</option>
                            <option value="2">Selesai</option>
                            <option value="3">Ditangguhkan</option>
                        </select>
                    </div>
                    <div class="row-span-4 px-2">
                        <label for="slot" class="block mb-2 text-sm font-medium text-gray-900 dark:text-gray-400">Filter Slot</label>
                        <select id="slot" wire:model="filterSlot" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
                            <option value="">Pilih</option>
                            @foreach ($dailySlot as $slot)
                            <option value="{{ $slot->id }}">{{ $slot->name }}</option>
                            @endforeach
                        </select>
                    </div>
                    <div class="row-span-4"></div>
                    <div class="row-span-4"></div>
                </div>
                <div class="overflow-x-auto relative">
                    <table class="w-full text-sm text-left text-gray-500 dark:text-gray-400">
                        <thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
                            <tr>
                                <th scope="col" class="py-3 px-6">ID</th>
                                <th scope="col" class="py-3 px-6">Hari/Tanggal</th>
                                <th scope="col" class="py-3 px-6">Slot Waktu</th>
                                <th scope="col" class="py-3 px-6">Nama</th>
                                <th scope="col" class="py-3 px-6">Whatsapp</th>
                                <th scope="col" class="py-3 px-6">Catatan</th>
                                <th scope="col" class="py-3 px-6">Status</th>
                            </tr>
                        </thead>
                        <tbody>
                            @forelse ($orders as $row)
                            <tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700">
                                <td class="py-4 px-6">{{ $row->order_id }}</td>
                                <td class="py-4 px-6">{{ \Carbon\Carbon::parse($row->day)->format('l, d-m-Y') }}</td>
                                <td class="py-4 px-6">{{ $row->daily_slot->name }}</td>
                                <th scope="row" class="py-4 px-6 font-medium text-gray-900 whitespace-nowrap dark:text-white">
                                    {{ $row->name }} <small>({{ $row->age }} thn)</small>
                                </th>
                                <td class="py-4 px-6">
                                    <a href="https://wa.me/{{ $row->phone_number }}" class="font-medium text-blue-600 dark:text-blue-500 hover:underline" target="_blank">Kirim Pesan</a>
                                </td>
                                <td class="py-4 px-6">
                                    {{ $row->note }}
                                </td>
                                <td class="py-4 px-6">
                                    {!! $row->status_label_admin !!}
                                </td>
                            </tr>
                            @empty
                          	<tr class="py-4 px-6">
                                <td colspan="7">Tidak ada data</td>
                            </tr>
                            @endforelse
                        </tbody>
                    </table>
                    
                    <div class="px-3 py-3">
                        {!! $orders->links() !!}
                    </div>
                </div>
            </div>
        </div>
    </div>
    
</div>

Ada Accessors baru yang kita gunakan, yaitu: status_label_admin karena homepage menggunakan Bootstrap dan admin page menggunakan Tailwind jadi secara class css berbeda. Buka file app/Models/Order.php dan tambahkan code

public function getStatusLabelAdminAttribute()
{
    if ($this->status == 0) {
        return '<span style="background-color: #7f8c8d; padding-left: 0.625rem; padding-right: 0.625rem; padding-bottom: 0.125rem; padding-top: 0.125rem; font-weight: 600;font-size: .75rem;line-height: 1rem;border-radius: 0.25rem; color: #ecf0f1">Dalam Antrian</span>';
    } elseif ($this->status == 1) {
        return '<span style="background-color: #2980b9; padding-left: 0.625rem; padding-right: 0.625rem; padding-bottom: 0.125rem; padding-top: 0.125rem; font-weight: 600;font-size: .75rem;line-height: 1rem;border-radius: 0.25rem; color: #ecf0f1">Sedang Dilayani</span>';
    } elseif ($this->status == 2) {
        return '<span style="background-color: #2ecc71; padding-left: 0.625rem; padding-right: 0.625rem; padding-bottom: 0.125rem; padding-top: 0.125rem; font-weight: 600;font-size: .75rem;line-height: 1rem;border-radius: 0.25rem; color: #ecf0f1">Selesai</span>';
    }
    return '<span style="background-color: #e74c3c; padding-left: 0.625rem; padding-right: 0.625rem; padding-bottom: 0.125rem; padding-top: 0.125rem; font-weight: 600;font-size: .75rem;line-height: 1rem;border-radius: 0.25rem; color: #ecf0f1">Ditangguhkan</span>';
}

Jangan lupa modifikasi $appends menjadi

protected $appends = ['order_id', 'status_label', 'status_label_admin'];

Saatnya untuk membuat routing, buka file routes/web.php dan tambahkan route berikut di dalam route group

Route::get('/appointment', OrderIndex::class)->name('admin.order.index');

Jangan lupa untuk menambahkan use statement

use App\Http\Livewire\Admin\Order\OrderIndex;

Tambahkan menu navigation, buka file resources/views/navigation-menu.blade.php dan temukan <!-- Navigation Links --> tambahkan tag berikut didalamnya.

<div class="hidden space-x-8 sm:-my-px sm:ml-10 sm:flex">
    <x-jet-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')">
        {{ __('Dashboard') }}
    </x-jet-nav-link>
  
  	<!-- TAMBAHKAN TAG INI -->
    <x-jet-nav-link href="{{ route('admin.order.index') }}" :active="request()->routeIs('admin.order.index')">
        Daftar Pasien
    </x-jet-nav-link>
    <!-- TAMBAHKAN TAG INI -->
</div>

Kelola Antrian Pasien

Ada dua fitur penting dari sub-bab ini, yaitu tombol Antrian Berikutnya dan tombol Tidak Datang. Kedua tombol ini akan berperan untuk mengganti antrian dan memperbaharui status dari antrian sebelumnya. Adapun tampilan yang akan kita buat akan terlihat seperti gambar berikut

Generate Livewire component dengan command

php artisan make:livewire Admin.Order.OrderProgress

Buka file app/Http/Livewire/Admin/Order/OrderProgress.php dan modifikasi menjadi

<?php

namespace App\Http\Livewire\Admin\Order;

use Livewire\Component;
use App\Models\Order;
use Carbon\Carbon;
use DB;

class OrderProgress extends Component
{
    public $day;

    public function mount()
    {
        //KITA BUAT PROPERTY DAY AGAR TIDAK PERLU BERULANG-ULANG MENGINISIASI TANGGAL HARI INI
        $this->day = Carbon::now()->format('Y-m-d');
    }

    public function render()
    {
        //KEDUA QUERY INI SUDAH PERNAH KITA BUAT PADA ARTIKEL SEBELUMNYA
        $onProgress = Order::where('status', 1)->where('day', $this->day)->take(2)->get();
        $next = Order::where('status', 0)
            ->where('day', $this->day)
            ->orderBy('daily_slot_id', 'ASC')
            ->orderBy('created_at', 'ASC')
            ->take(3)->get();
        return view('livewire.admin.order.order-progress', compact('onProgress', 'next'))->layout('layouts.app');
    }

    //FUNGSI INI AKAN MENG-HANDLE PROSES KETIKA TOMBOL ANTRIAN SELANJUTNYA DITEKAN
    public function updateProgress()
    {
        try {
            //MEMULAI DATABASE TRANSACTION
            DB::beginTransaction();
            $user = auth()->user();
            //BUAT QUERY UNTUK MENGAMBIL DATA PASIEN YANG SEDANG DILAYANI
            $order = Order::where('status', 1)->where('day', $this->day)
                ->orderBy('daily_slot_id', 'ASC')
                ->orderBy('created_at', 'DESC')
                ->first();
          
            //JIKA DATANYA ADA
            if ($order) {
                //MAKA UPDATE STATUSNYA MENJADI SELESAI DAN TAMBAHKAN USER ID SEBAGAI TANDA USER YANG SEDANG BERTUGAS
                $order->update(['status' => 2, 'user_id' => $user->id]);
                $this->nextOrder(); //KEMUDIAN PANGGIL FUNGSI NEXT ORDER 
                DB::commit(); //COMMIT DATA AGAR BENAR-BENAR DISIMPAN KE DATABASE
                //MENGIRIM NOTIFIKASI KE USER BAHWA ANTRIAN SUDAH BERGANTI
                return session()->flash('success', 'Antrian: ' . $order->order_id . ' Selesai');
            }

          	//MENGAMBIL DATA ANTRIAN YANG MASIH MENUNGGU
            $available = $this->waitingList();
            //JIKA ADA DATANYA
            if ($available > 0) {
                $this->nextOrder(); //MAKA PANGGIL FUNGSI NEXT ORDER
                DB::commit();
                return session()->flash('success', 'Antrian Sedang Dilayani');
            }

            return session()->flash('error', 'Tidak ada antrian/Pasien yang sedang dilayani');
        } catch (\Exception $e) {
            DB::rollback(); //JIKA ADA ERROR, MAKA KITA ROLLBACK SEMUA QUERY DATABASE
            //LALU KIRIM PESAN ERROR
            return session()->flash('error', $e->getMessage());
        }
    }

    //FUNGSI INI UNTUK MENGGANTI ANTRIAN
    private function nextOrder()
    {
        //BUAT QUERY UNTUK MENGAMBIL DATA ANTRIAN YANG STATUSNYA MASIH MENUNGGU
        $nextOrder = Order::where('status', 0)->where('day', $this->day)
            ->orderBy('daily_slot_id', 'ASC')->orderBy('created_at', 'ASC')->first();
        if ($nextOrder) {
            //KEMUDIAN KITA UPDATE STATUSNYA MENJADI SEDANG DILAYANI
            $nextOrder->update(['status' => 1]);
        }
    }

    private function waitingList()
    {
        //MEMBUAT QUERY UNTUK MENGAMBIL JUMLAH DATA ANTRIAN YANG SEDNG MENUNGGU
        $available = Order::where('status', 0)->where('day', $this->day)
            ->orderBy('daily_slot_id', 'ASC')->orderBy('created_at', 'DESC')->count();
        return $available;    
    }

    //FUNGSI INI AKAN MENG-HANDLE PROSES KETIKA TOMBOL TIDAK DATANG DITEKAN
    public function cancelProgress()
    {
        try {
            DB::beginTransaction();
            $user = auth()->user();
            //MEMBUAT QUERY DATA PASIEN YANG SEDANG DILAYANI
            $order = Order::where('status', 1)->where('day', $this->day)
                ->orderBy('daily_slot_id', 'ASC')
                ->orderBy('created_at', 'DESC')
                ->first();
            //JIKA ADA DATANYA
            if ($order) {
                //MAKA UPDATE STATUSNYA MENJADI DITANGGUHKAN / DIBATALKAN
                $order->update(['status' => 3, 'user_id' => $user->id]);
                $this->nextOrder(); //KEMUDIAN LANJUT KE ANTRIAN BERIKUTNYA
                DB::commit();
                return session()->flash('success', 'Antrian: ' . $order->order_id . ' Dibatalkan');
            }

            return session()->flash('error', 'Tidak ada antrian yang sedang dilayani');
        } catch (\Exception $e) {
            DB::rollback();
            return session()->flash('error', $e->getMessage());
        }
    }
}

Lanjutnya, buka file resources/views/livewire/admin/order/order-progress.blade.php dan tambahkan code

<div>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            Antrian Online
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-3">
                <div class="grid grid-rows-2 grid-flow-col py-3">
                    <div class="row-span-full bg-green-100 block mb-3 p-6 max-w-lg bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
                        <h5 class="mb-2 text-1xl tracking-tight text-gray-900 dark:text-white">Sedang Dilayani</h5>
                        @foreach ($onProgress as $row)
                        <h1 class="text-3xl font-bold tracking-tight text-gray-700 dark:text-gray-400">{{ $row->order_id }}</h1>
                        @endforeach
                    </div>
                    <div class="row-span-full bg-gray-100 block mb-3 p-6 max-w-lg bg-white rounded-lg border border-gray-200 shadow-md hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
                        <h5 class="mb-2 text-1xl tracking-tight text-gray-900 dark:text-white">Antrian Selanjutnya</h5>
                        @foreach ($next as $row)
                        <h1 class="text-3xl font-bold tracking-tight text-gray-700 dark:text-gray-400">- {{ $row->order_id }}</h1>
                        @endforeach
                    </div>
                </div>
                <button type="button" wire:loading.attr="disabled" wire:click="updateProgress" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800">Antrian Berikutnya</button>
                <button type="button" wire:loading.attr="disabled" wire:click="cancelProgress" class="text-white bg-red-700 hover:bg-red-800 focus:ring-4 focus:ring-red-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2 dark:bg-red-600 dark:hover:bg-red-700 focus:outline-none dark:focus:ring-red-800">Tidak Datang</button>
                
                @if (session('success'))
                <div class="p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800" role="alert">
                    {{ session('success') }}
                </div>
                @endif

                @if (session('error'))
                <div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800" role="alert">
                    {{ session('error') }}
                </div>
                @endif
            </div>
        </div>
    </div>
</div>

Saatnya membuat routing, buka file routes/web.php dan tambahkan code

Route::get('/antrian', OrderProgress::class)->name('admin.order.progress');

Jangan lupa untuk menambahkan use statement

use App\Http\Livewire\Admin\Order\OrderProgress;

Tambahkan menu navigasi, buka file resources/views/navigation-menu.blade.php dan tambahkan tag berikut tepat dibawah tag Daftar Pasien

<x-jet-nav-link href="{{ route('admin.order.progress') }}" :active="request()->routeIs('admin.order.progress')">
    Antrian Pasien
</x-jet-nav-link>

Kesimpulan

Terima kasih telah belajar bersama dalam seri membuat aplikasi booking online menggunakan Laravel 9 dan Livewire. Sepanjang artikel ini kita telah belajar beberapa hal, diantaranya

  1. Laravel Jetstream sebagai starter kit untuk membuat fitur lengkap dari otentikasi
  2. Mengulang kembali tentang Livewire component untuk mengasah kemampuan kita
  3. Membuat seeders
  4. Membuat & Menampilkan flash message
  5. Database transaction

Adapun source code dari aplikasi ini bisa kamu temukan di Github.

Category:
Share:

Comments