Tutorial Laravel Livewire - #11 - Membuat Module Dashboard Dengan Livewire

Artikel ini merupakan series dari Tutorial Laravel Livewire Study Case Point Of Sales, disini kita akan membuat sebuah module dashboard dengan livewire.

Rafi Taufiqurrahman
Dipublish 09/07/2024

Pendahuluan

Setelah berhasil membuat module pos, disini kita akan lanjutkan untuk pembuatan module dashboard. Module ini merupakan module terakhir sekaligus artikel terakhir dari series

Component Pos Index

Silahkan teman-teman buka terminal-nya, kemudian jalankan perintah berikut ini :

Terminal
php artisan make:livewire pages.dashboard.index

Setelah berhasil menjalankan perintah diatas, kita akan mendapatkan 2 buah file baru yang terletak di :

  1. app/Livewire/Pages/Dashboard/Index.php
  2. resources/views/livewire/pages/dashboard/index.blade.php

Silahkan teman-teman buka file App/Livewire/Pages/Dashboard/Index.php, kemudian tambahkan kode berikut ini :

Dashboard/Index.php
<?php

namespace App\Livewire\Pages\Dashboard;

use App\Models\Product;
use Livewire\Component;
use App\Models\Transaction;
use Livewire\Attributes\Title;
use Livewire\Attributes\Layout;
use Illuminate\Support\Facades\DB;

class Index extends Component
{
    // define layout
    #[Layout('layouts.app')]
    // define title
    #[Title('Dashboard')]

    public function render()
    {
        // sum grandtotal all transaction
        $revenue = Transaction::query()
            ->whereUserId(auth()->user()->id)
            ->sum('grand_total');

        // sum grandtotal transaction this month
        $monthly_revenue = Transaction::query()
            ->whereUserId(auth()->user()->id)
            ->whereMonth('created_at', date('m'))
            ->sum('grand_total');

        // count all transaction
        $transaction = Transaction::count();

        // get product out of stock
        $product_out_stocks = Product::query()
            ->with(['category' => fn($query) => $query->select('id', 'name')])
            ->select('id', 'category_id', 'name', 'quantity')
            ->where('quantity', '<=', 5)
            ->limit(8)->get();

        // get best selling product
        $best_selling_products = Product::query()
            ->select('products.id', 'products.name', DB::raw('SUM(transaction_details.quantity) as total_sold'))
            ->join('transaction_details', 'products.id', '=', 'transaction_details.product_id')
            ->groupBy('products.id', 'products.name')
            ->orderBy('total_sold', 'DESC')
            ->limit(10)
            ->get();

        // render view
        return view('livewire.pages.dashboard.index', compact('revenue', 'monthly_revenue', 'transaction', 'product_out_stocks', 'best_selling_products'));
    }
}

Pada kode diatas, pertama - tama kita mendefinisikan sebuah attribute title dan layout.

Dashboard/Index.php
// define layout
#[Layout('layouts.app')]
// define title
#[Title('Dashboard')]

Selanjutnya didalam method render kita mendefinisikan beberapa data baru diantaranya revenue, monthly_revenue, transaction, product_out_stocks, dan best_selling_products.

Dashboard/Index.php
// sum grandtotal all transaction
$revenue = Transaction::query()
    ->whereUserId(auth()->user()->id)
    ->sum('grand_total');

Pada kode diatas, kita menghitung total kolom grand_total dari tabel transactions berdasarkan user yang sedang login saat ini.

Dashboard/Index.php
// sum grandtotal transaction this month
$monthly_revenue = Transaction::query()
    ->whereUserId(auth()->user()->id)
    ->whereMonth('created_at', date('m'))
    ->sum('grand_total');

Selanjutnya kode ini kita gunakan untuk menghitung total kolom grand_total dari tabel transactions dan juga berdasarkan user yang sedang login saat ini beserta data transactions yang kita hitung hanya data transactions yang terjadi pada bulan saat ini.

Dashboard/Index.php
// count all transaction
$transaction = Transaction::count();

Berikutnya kode ini kita gunakan untuk menghitung seluruh data transactions yang terjadi.

Dashboard/Index.php
// get product out of stock
$product_out_stocks = Product::query()
    ->with(['category' => fn($query) => $query->select('id', 'name')])
    ->select('id', 'category_id', 'name', 'quantity')
    ->where('quantity', '<=', 5)
    ->limit(8)->get();

Kemudian kode ini, kita gunakan untuk menampilkan data products yang quantity stocknya kurang dari 5, dan disini kita hanya tampilkan 8 data saja.

Dashboard/Index.php
// get best selling product
$best_selling_products = Product::query()
    ->select('products.id', 'products.name', DB::raw('SUM(transaction_details.quantity) as total_sold'))
    ->join('transaction_details', 'products.id', '=', 'transaction_details.product_id')
    ->groupBy('products.id', 'products.name')
    ->orderBy('total_sold', 'DESC')
    ->limit(10)
    ->get();

Pada kode ini, kita gunakan untuk menampilkan data product dengan jumlah transactions quantity terbanyak dan disini kita hanya tampilkan 10 data saja.

Kemudian, seluruh data yang telah kita definisikan tersebut, kita lempar kedalam view dengan cara menggunakan sebuah method compact, agar data tersebut bisa kita gunakan didalam view.

Dashboard/Index.php
// render view
return view('livewire.pages.dashboard.index', compact('revenue', 'monthly_revenue', 'transaction', 'product_out_stocks', 'best_selling_products'));

Route Component Dashboard Index

Setelah berhasil membuat component dashboard index, sekarang kita akan lanjutkan untuk membuat route-nya, Silahkan teman - teman buka file routes/web.php, kemudian tambahkan kode berikut ini :

web.php
<?php

use Illuminate\Support\Facades\Route;
use App\Livewire\Pages\Categories\Index as CategoryIndex;
use App\Livewire\Pages\Categories\Create as CategoryCreate;
use App\Livewire\Pages\Categories\Edit as CategoryEdit;
use App\Livewire\Pages\Products\Index as ProductIndex;
use App\Livewire\Pages\Products\Create as ProductCreate;
use App\Livewire\Pages\Products\Edit as ProductEdit;
use App\Livewire\Pages\Pos\Index as PosIndex;
use App\Livewire\Pages\Dashboard\Index as DashboardIndex;


Route::view('/', 'welcome');

Route::group(['middleware' => ['auth']], function(){
    // dashboard route
    Route::get('/dashboard', DashboardIndex::class)->name('dashboard');
    // categories route
    Route::group(['prefix' => 'categories', 'as' => 'categories.'], function(){
        Route::get('/', CategoryIndex::class)->name('index');
        Route::get('/create', CategoryCreate::class)->name('create');
        Route::get('/edit/{category}', CategoryEdit::class)->name('edit');
    });
    // products route
    Route::group(['prefix' => 'products', 'as' => 'products.'], function(){
        Route::get('/', ProductIndex::class)->name('index');
        Route::get('/create', ProductCreate::class)->name('create');
        Route::get('/edit/{product}', ProductEdit::class)->name('edit');
    });
   // pos route
    Route::get('/pos', PosIndex::class)->name('pos');
});

Route::view('profile', 'profile')
    ->middleware(['auth'])
    ->name('profile');

require __DIR__.'/auth.php';

Dari perubahan kode diatas, kita menambahkan sebuah route baru dengan nama dashboard, untuk memastikan route yang kita buat berfungsi, teman - teman bisa jalankan perintah berikut ini dalam terminal.

Terminal
php artisan r:l --name=dashboard

Setelah perintah artisan diatas berhasil dijalankan maka kita akan mendapatkan output, kurang lebih seperti berikut ini.

Terminal
GET|HEAD       dashboard ............................................. dashboard › App\Livewire\Pages\Dashboard\Index

View Component Dashboard Index

Silahkan teman - teman buka file yang bernama index.blade.php yang terletak di dalam folder resources/views/livewire/pages/dashboard, kemudian tambahkan kode berikut ini :

index.blade.php
<div class="py-12 px-4">
    <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-center">
            <x-widget title="Revenue" subtitle="Total amount of revenue" :data="'IDR ' . number_format($revenue, 0)">
                <div class="p-2 rounded-lg bg-teal-100 text-teal-500">
                    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
                        stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
                        class="icon icon-tabler icons-tabler-outline icon-tabler-cash">
                        <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                        <path d="M7 9m0 2a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2z" />
                        <path d="M14 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
                        <path d="M17 9v-2a2 2 0 0 0 -2 -2h-10a2 2 0 0 0 -2 2v6a2 2 0 0 0 2 2h2" />
                    </svg>
                </div>
            </x-widget>
            <x-widget title="Monthly Revenue" subtitle="Total revenue this month" :data="'IDR ' . number_format($monthly_revenue, 0)">
                <div class="p-2 rounded-lg bg-sky-100 text-sky-500">
                    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
                        fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
                        stroke-linejoin="round"
                        class="icon icon-tabler icons-tabler-outline icon-tabler-calendar-dollar">
                        <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                        <path d="M13 21h-7a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v3" />
                        <path d="M16 3v4" />
                        <path d="M8 3v4" />
                        <path d="M4 11h12.5" />
                        <path d="M21 15h-2.5a1.5 1.5 0 0 0 0 3h1a1.5 1.5 0 0 1 0 3h-2.5" />
                        <path d="M19 21v1m0 -8v1" />
                    </svg>
                </div>
            </x-widget>
            <x-widget title="Transaction" subtitle="Grand total of transactions" :data="$transaction">
                <div class="p-2 rounded-lg bg-indigo-100 text-indigo-500">
                    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
                        fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
                        stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-invoice">
                        <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                        <path d="M14 3v4a1 1 0 0 0 1 1h4" />
                        <path
                            d="M19 12v7a1.78 1.78 0 0 1 -3.1 1.4a1.65 1.65 0 0 0 -2.6 0a1.65 1.65 0 0 1 -2.6 0a1.65 1.65 0 0 0 -2.6 0a1.78 1.78 0 0 1 -3.1 -1.4v-14a2 2 0 0 1 2 -2h7l5 5v4.25" />
                    </svg>
                </div>
            </x-widget>
            <x-widget title="Product" subtitle="Grand total of the products" :data="$transaction">
                <div class="p-2 rounded-lg bg-yellow-100 text-yellow-500">
                    <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
                        fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
                        stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-box">
                        <path stroke="none" d="M0 0h24v24H0z" fill="none" />
                        <path d="M12 3l8 4.5l0 9l-8 4.5l-8 -4.5l0 -9l8 -4.5" />
                        <path d="M12 12l8 -4.5" />
                        <path d="M12 12l0 9" />
                        <path d="M12 12l-8 -4.5" />
                    </svg>
                </div>
            </x-widget>
        </div>
        <div class="grid grid-cols-1 md:grid-cols-2 gap-4 items-start py-4">
            <div>
                <div class="p-4 bg-white rounded-t-lg border">
                    <div class='flex items-center gap-2 uppercase font-semibold text-sm'>
                        Top 10 Best Selling Products
                    </div>
                </div>
                <div class="bg-white rounded-b-lg border border-t-0">
                    <div class="w-full overflow-hidden overflow-x-auto border-collapse rounded-xl">
                        <table class="w-full text-sm border-collapse">
                            <thead class="border-b">
                                <tr>
                                    <th scope="col"
                                        class="h-12 w-10 px-6 text-left align-middle font-medium whitespace-nowrap">
                                        No
                                    </th>
                                    <th scope="col"
                                        class="h-12 px-6 text-left align-middle font-medium whitespace-nowrap">
                                        Product Name
                                    </th>
                                    <th scope="col"
                                        class="h-12 px-6 text-center align-middle font-medium whitespace-nowrap">
                                        Number of Sales
                                    </th>
                                </tr>
                            </thead>
                            <tbody class="divide-y">
                                @forelse ($best_selling_products as $key => $item)
                                    <tr>
                                        <td class="whitespace-nowrap px-6 py-2 text-gray-700 rounded-b-lg text-sm">
                                            {{ $key + 1 }}</td>
                                        <td class="whitespace-nowrap px-6 py-2 text-gray-700 rounded-b-lg text-sm">
                                            {{ $item->name }}
                                        </td>
                                        <td
                                            class="whitespace-nowrap px-6 py-2 text-gray-700 rounded-b-lg text-sm text-center">
                                            {{ $item->total_sold }}
                                        </td>
                                    </tr>
                                @empty
                                    <tr>
                                        <td colspan="4"
                                            class="whitespace-nowrap px-6 py-2 text-rose-700 rounded-b-lg text-sm text-center">
                                            Sorry, we couldn't find anything...
                                        </td>
                                    </tr>
                                @endforelse
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
            <div>
                <div class="p-4 bg-white rounded-t-lg border">
                    <div class='flex items-center gap-2 uppercase font-semibold text-sm'>
                        Product out of Stock
                    </div>
                </div>
                <div class="bg-white rounded-b-lg border border-t-0">
                    <div class="w-full overflow-hidden overflow-x-auto border-collapse rounded-xl">
                        <table class="w-full text-sm border-collapse">
                            <thead class="border-b">
                                <tr>
                                    <th scope="col"
                                        class="h-12 w-10 px-6 text-left align-middle font-medium whitespace-nowrap">
                                        No
                                    </th>
                                    <th scope="col"
                                        class="h-12 px-6 text-left align-middle font-medium whitespace-nowrap">
                                        Product Name
                                    </th>
                                    <th scope="col"
                                        class="h-12 px-6 text-left align-middle font-medium whitespace-nowrap">
                                        Product Category
                                    </th>
                                    <th scope="col"
                                        class="h-12 px-6 text-center align-middle font-medium whitespace-nowrap">
                                        Product Quantity
                                    </th>
                                </tr>
                            </thead>
                            <tbody class="divide-y">
                                @forelse ($product_out_stocks as $key => $item)
                                    <tr>
                                        <td class="whitespace-nowrap px-6 py-2 text-gray-700 rounded-b-lg text-sm">
                                            {{ $key + 1 }}</td>
                                        <td class="whitespace-nowrap px-6 py-2 text-gray-700 rounded-b-lg text-sm">
                                            {{ $item->name }}
                                        </td>
                                        <td class="whitespace-nowrap px-6 py-2 text-gray-700 rounded-b-lg text-sm">
                                            {{ $item->category->name }}
                                        </td>
                                        <td
                                            class="whitespace-nowrap px-6 py-2 text-gray-700 rounded-b-lg text-sm text-center">
                                            {{ $item->quantity }}
                                        </td>
                                    </tr>
                                @empty
                                    <tr>
                                        <td colspan="4"
                                            class="whitespace-nowrap px-6 py-2 text-rose-700 rounded-b-lg text-sm text-center">
                                            Sorry, we couldn't find anything...
                                        </td>
                                    </tr>
                                @endforelse
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Pada kode diatas, kita hanya melakukan pemanggilan component <x-widget/> dengan props title, subtitle, dan data.

Seluruh varibel data diatas kita dapatkan dari passing data yang ada di component dashboard index didalam method render().

pos-dashboard

Fixing Route Profile

Jika teman - teman akses route profile maka teman - teman akan mengalami error seperti gambar dibawah ini, error ini terjadi dikarenakan kita telah mengubah component modal bawaan dari livewire.

profile-validation

Untuk mengatasi hal tersebut teman - teman bisa buat file baru dengan nama modal-profile.blade.php didalam folder resources/views/components, kemudian masukan kode berikut ini.

modal-profile.blade.php
@props([
    'name',
    'show' => false,
    'maxWidth' => '2xl'
])

@php
$maxWidth = [
    'sm' => 'sm:max-w-sm',
    'md' => 'sm:max-w-md',
    'lg' => 'sm:max-w-lg',
    'xl' => 'sm:max-w-xl',
    '2xl' => 'sm:max-w-2xl',
][$maxWidth];
@endphp

<div
    x-data="{
        show: @js($show),
        focusables() {
            // All focusable element types...
            let selector = 'a, button, input:not([type=\'hidden\']), textarea, select, details, [tabindex]:not([tabindex=\'-1\'])'
            return [...$el.querySelectorAll(selector)]
                // All non-disabled elements...
                .filter(el => ! el.hasAttribute('disabled'))
        },
        firstFocusable() { return this.focusables()[0] },
        lastFocusable() { return this.focusables().slice(-1)[0] },
        nextFocusable() { return this.focusables()[this.nextFocusableIndex()] || this.firstFocusable() },
        prevFocusable() { return this.focusables()[this.prevFocusableIndex()] || this.lastFocusable() },
        nextFocusableIndex() { return (this.focusables().indexOf(document.activeElement) + 1) % (this.focusables().length + 1) },
        prevFocusableIndex() { return Math.max(0, this.focusables().indexOf(document.activeElement)) -1 },
    }"
    x-init="$watch('show', value => {
        if (value) {
            document.body.classList.add('overflow-y-hidden');
            {{ $attributes->has('focusable') ? 'setTimeout(() => firstFocusable().focus(), 100)' : '' }}
        } else {
            document.body.classList.remove('overflow-y-hidden');
        }
    })"
    x-on:open-modal.window="$event.detail == '{{ $name }}' ? show = true : null"
    x-on:close-modal.window="$event.detail == '{{ $name }}' ? show = false : null"
    x-on:close.stop="show = false"
    x-on:keydown.escape.window="show = false"
    x-on:keydown.tab.prevent="$event.shiftKey || nextFocusable().focus()"
    x-on:keydown.shift.tab.prevent="prevFocusable().focus()"
    x-show="show"
    class="fixed inset-0 overflow-y-auto px-4 py-6 sm:px-0 z-50"
    style="display: {{ $show ? 'block' : 'none' }};"
>
    <div
        x-show="show"
        class="fixed inset-0 transform transition-all"
        x-on:click="show = false"
        x-transition:enter="ease-out duration-300"
        x-transition:enter-start="opacity-0"
        x-transition:enter-end="opacity-100"
        x-transition:leave="ease-in duration-200"
        x-transition:leave-start="opacity-100"
        x-transition:leave-end="opacity-0"
    >
        <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
    </div>

    <div
        x-show="show"
        class="mb-6 bg-white rounded-lg overflow-hidden shadow-xl transform transition-all sm:w-full {{ $maxWidth }} sm:mx-auto"
        x-transition:enter="ease-out duration-300"
        x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
        x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
        x-transition:leave="ease-in duration-200"
        x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
        x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
    >
        {{ $slot }}
    </div>
</div>

Setelah berhasil membuat component tersebut silahkan teman - teman buka file yang bernama delete-user-form.blade.php yang terletak di folder resources/views/livewire/profile, kemudian ubah semua kodenya menjadi seperti berikut ini.

delete-user-form.blade.php
<?php

use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Volt\Component;

new class extends Component
{
    public string $password = '';

    /**
     * Delete the currently authenticated user.
     */
    public function deleteUser(Logout $logout): void
    {
        $this->validate([
            'password' => ['required', 'string', 'current_password'],
        ]);

        tap(Auth::user(), $logout(...))->delete();

        $this->redirect('/', navigate: true);
    }
}; ?>

<section class="space-y-6">
    <header>
        <h2 class="text-lg font-medium text-gray-900">
            {{ __('Delete Account') }}
        </h2>

        <p class="mt-1 text-sm text-gray-600">
            {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
        </p>
    </header>

    <x-danger-button
        x-data=""
        x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
    >{{ __('Delete Account') }}</x-danger-button>

    <x-modal-profile name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable>
        <form wire:submit="deleteUser" class="p-6">

            <h2 class="text-lg font-medium text-gray-900">
                {{ __('Are you sure you want to delete your account?') }}
            </h2>

            <p class="mt-1 text-sm text-gray-600">
                {{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
            </p>

            <div class="mt-6">
                <x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />

                <x-text-input
                    wire:model="password"
                    id="password"
                    name="password"
                    type="password"
                    class="mt-1 block w-3/4"
                    placeholder="{{ __('Password') }}"
                />

                <x-input-error :messages="$errors->get('password')" class="mt-2" />
            </div>

            <div class="mt-6 flex justify-end">
                <x-secondary-button x-on:click="$dispatch('close')">
                    {{ __('Cancel') }}
                </x-secondary-button>

                <x-danger-button class="ms-3">
                    {{ __('Delete Account') }}
                </x-danger-button>
            </div>
        </form>
    </x-modal-profile>
</section>

Langkah berikutnya teman - teman bisa buka file yang bernama profile.blade.php yang terletak di folder resources/views, kemudian masukan kode berikut ini.

profile.blade.php
<x-app-layout>
    <x-slot name="title">
        Profile
    </x-slot>

    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('Profile') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
            <div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
                <div class="max-w-xl">
                    <livewire:profile.update-profile-information-form />
                </div>
            </div>

            <div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
                <div class="max-w-xl">
                    <livewire:profile.update-password-form />
                </div>
            </div>

            <div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
                <div class="max-w-xl">
                    <livewire:profile.delete-user-form />
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

Selanjutnya teman - teman bisa kunjungi route profile maka akan tampil seperti berikut ini.

profile-fix

Screenshoot Hasil

pos-dashboard

categories-data

products-data

pos-view

pos-modal

Penutup

Pada artikel kali ini kita telah berhasil menyelesaikan pembuatan module dashboard, dan ini merupakan artikel terakhir dari seris Tutorial Laravel Livewire Study Case Point Of Sales, saya ucapkan banyak terimakasih kepada teman - teman yang sudah mengikuti seri ini dari awal sampai akhir, jika ada pertanyaan bisa langsung ke telegram saya Rafi Taufiqurrahman. nantikan seri - seri selanjutnya dari saya, semoga bermanfaat terimakasih : )

Artikel Lainnya

Beberapa artikel rekomendasi lainnya untuk menambah pengetahuan.

JurnalKoding

Mulai asah skill dengan berbagai macam teknologi - teknologi terbaru seperti Laravel, React, Vue, Inertia, Tailwind CSS, dan masih banyak lagi.

© 2024 JurnalKoding, Inc. All rights reserved.