Tutorial CRUD Laravel Inertia Vue 3: Arsitektur SPA Modern
Implementasi tutorial CRUD Laravel Inertia Vue 3 adalah solusi rekayasa perangkat lunak modern untuk membangun Single Page Application (SPA) berkecepatan tinggi tanpa perlu memisahkan repositori backend dan frontend atau merancang REST API maupun GraphQL secara terpisah. Inertia.js bekerja sebagai protokol perantara (glue layer) yang mengizinkan pengontrol Laravel merender komponen <script setup> Vue 3 secara langsung melalui pertukaran payload JSON ringan di atas XHR request.
Saat saya memimpin perombakan arsitektur dasbor Enterprise SaaS untuk klien logistik tahun lalu, tim kami sempat kewalahan mengelola dua repositori terpisah antara API Laravel dan SPA Vue.js. Setiap kali ada perubahan satu kolom saja di skema basis data, developer harus memodifikasi Controller, menyesuaikan API Resource, memperbarui state management Pinia, serta menyinkronkan ulang token autentikasi JWT.
Dengan beralih ke arsitektur Modern Monolith menggunakan tumpukan teknologi Laravel, Inertia.js, dan Vue 3, beban boilerplate tersebut terpangkas hingga 60%. Anda tetap mendapatkan transisi halaman instan khas SPA yang hemat penggunaan CPU di infrastruktur Cloud VPS, sambil mempertahankan keamanan sesi bawaan Laravel serta produktivitas routing di satu basis kode terpadu.
1. Prasyarat Teknis & Komparasi Arsitektur Aplikasi
Sebelum mengeksekusi baris kode pada panduan ini, pastikan lingkungan pengembangan lokal atau container Docker di mesin Anda telah memenuhi spesifikasi dependency minimum berikut agar terhindar dari konflik kompilasi bundler Vite:
Engine Backend: PHP 8.2 atau 8.3 (ekstensi PDO, Mbstring, dan OpenSSL aktif) dengan Composer v2.7+.
Framework & Adapter Server: Laravel 11.x dan paket
inertiajs/inertia-laravelv1.x.Runtime Frontend & Bundler: Node.js v20 LTS+, Vite v5+, Vue 3.4+, dan adapter klien
@inertiajs/vue3v1.x.Sistem Basis Data: MySQL 8.0+ atau PostgreSQL 15+ yang terhubung pada environment lokal.
Mengapa arsitektur Modern Monolith dengan Inertia.js jauh lebih unggul untuk mayoritas aplikasi bisnis dibandingkan memisahkan frontend dan backend? Tabel spesifikasi di bawah ini merinci perbandingan teknis berdasarkan parameter performa di server produksi:
Parameter Arsitektur | Blade Tradisional (SSR Murni) | Decoupled SPA (REST API + Vue) | Laravel + Inertia + Vue 3 |
|---|---|---|---|
Mekanisme Transisi Halaman | Full page reload (muat ulang dokumen HTML utuh). | Pertukaran JSON via Fetch/Axios di sisi klien. | Pertukaran komponen & props JSON via protokol XHR Inertia. |
Sistem Keamanan & Auth | Session & Cookie bawaan (CSRF Token). | Kompleks (Sanctum SPA / OAuth2 / JWT + CORS). | Session & Cookie bawaan (otomatis sinkron tanpa CORS). |
Manajemen State Klien | Tidak ada (bergantung penuh pada memori server). | Wajib menggunakan Pinia / Vuex untuk sinkronisasi. | Minimalis (data langsung diinjeksi sebagai |
Biaya Infrastruktur Cloud / VPS | Rendah (1 instance web server). | Tinggi (2 pipeline CI/CD, server API & Node terpisah). | Efisien (1 repositori dan 1 deployment pipeline terpadu). |
2. Konfigurasi Lingkungan & Instalasi Stack Inertia
Kita akan membangun modul manajemen inventaris produk untuk skenario sistem ERP dan katalog Cloud Server. Langkah pertama adalah menginstal paket adapter Inertia di sisi server menggunakan Composer dan membuat middleware utamanya.
Buka terminal proyek Laravel Anda dan jalankan perintah instalasi berikut secara berurutan:
# 1. Instal adapter Inertia sisi server dan pustaka routing Ziggy
composer require inertiajs/inertia-laravel tightenco/ziggy
# 2. Publikasikan middleware HandleInertiaRequests
php artisan inertia:middleware
# 3. Instal Vue 3, plugin Vite Vue, dan adapter klien Inertia
npm install vue@latest @vitejs/plugin-vue @inertiajs/vue3Setelah middleware berhasil dibuat, daftarkan kelas HandleInertiaRequests ke dalam berkas bootstrap/app.php (standar arsitektur baru Laravel 11) agar setiap permintaan HTTP web otomatis menyertakan metadata sesi dan pesan flash notification:
// bootstrap/app.php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();Selanjutnya, modifikasi metode share() di dalam berkas app/Http/Middleware/HandleInertiaRequests.php. Konfigurasi ini bertugas menyuntikkan pesan notifikasi keberhasilan transaksi database dari sesi server langsung ke memori komponen Vue 3:
// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'flash' => [
'message' => fn () => $request->session()->get('message'),
],
]);
}Terakhir pada tahap inisialisasi, siapkan berkas titik masuk (entry point) JavaScript di resources/js/app.js dan templat akar di resources/views/app.blade.php:
// resources/js/app.js
import './bootstrap';
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { ZiggyVue } from '../../vendor/tightenco/ziggy';
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true });
return pages[`./Pages/${name}.vue`];
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue)
.mount(el);
},
progress: {
color: '#4f46e5',
showSpinner: true,
},
});Pada kode app.js di atas, fungsi import.meta.glob milik Vite akan memindai seluruh komponen halaman di dalam direktori resources/js/Pages/. Integrasi plugin ZiggyVue memungkinkan kita memanggil fungsi pembantu route('products.store') persis seperti di templat Blade, sementara objek progress menampilkan indikator pemuatan di bagian atas layar peramban saat terjadi navigasi asinkron.
3. Pembuatan Skema Database, Model & Form Request
Untuk menjaga kualitas Enterprise Architecture dan keamanan API internal, kita tidak akan menaruh logika validasi mentah di dalam Controller. Mari buat Model Product sekaligus berkas Migration dan Form Request terpisah melalui perintah Artisan:
php artisan make:model Product -m
php artisan make:request StoreProductRequestBuka berkas migrasi di direktori database/migrations/xxxx_xx_xx_create_products_table.php, lalu definisikan skema tabel dengan indeks pencarian pada kolom SKU dan nama produk:
// database/migrations/xxxx_xx_xx_create_products_table.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->string('sku', 50)->unique();
$table->string('name', 150)->index();
$table->unsignedBigInteger('price');
$table->unsignedInteger('stock')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};Jangan lupa mendefinisikan properti $fillable pada app/Models/Product.php untuk mencegah celah keamanan Mass Assignment Vulnerability:
// app/Models/Product.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
use HasFactory;
protected $fillable = [
'sku',
'name',
'price',
'stock',
];
}Selanjutnya, susun aturan validasi di dalam berkas app/Http/Requests/StoreProductRequest.php. Kita menggunakan aturan Rule::unique yang dinamis agar kelas validasi yang sama dapat dipakai baik saat menambah produk baru (POST) maupun saat memperbarui produk yang sudah ada (PUT) tanpa memicu bentrok SKU miliknya sendiri:
// app/Http/Requests/StoreProductRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Ubah sesuai kebijakan Gate/Policy otorisasi Anda
}
public function rules(): array
{
$productId = $this->route('product')?->id;
return [
'sku' => ['required', 'string', 'max:50', Rule::unique('products', 'sku')->ignore($productId)],
'name' => ['required', 'string', 'min:3', 'max:150'],
'price' => ['required', 'numeric', 'min:1000'],
'stock' => ['required', 'integer', 'min:0'],
];
}
}Eksekusi perintah php artisan migrate di terminal Anda untuk membangun tabel secara fisik di server database.
4. Implementasi Backend: Resource Controller & Routing
Sekarang kita merancang otak pemrosesan di app/Http/Controllers/ProductController.php. Perbedaan utama dengan Controller Laravel biasa terletak pada pengembalian fungsi Inertia::render() yang menggantikan fungsi view():
// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;
use App\Http\Requests\StoreProductRequest;
use App\Models\Product;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class ProductController extends Controller
{
public function index(): Response
{
// Seleksi kolom spesifik untuk menghemat payload memori JSON
$products = Product::query()
->select(['id', 'sku', 'name', 'price', 'stock', 'created_at'])
->latest()
->paginate(10)
->withQueryString();
return Inertia::render('Products/Index', [
'products' => $products,
]);
}
public function create(): Response
{
return Inertia::render('Products/Form', [
'product' => null,
]);
}
public function store(StoreProductRequest $request): RedirectResponse
{
Product::create($request->validated());
return to_route('products.index')
->with('message', 'Data produk berhasil ditambahkan ke sistem.');
}
public function edit(Product $product): Response
{
return Inertia::render('Products/Form', [
'product' => $product->only(['id', 'sku', 'name', 'price', 'stock']),
]);
}
public function update(StoreProductRequest $request, Product $product): RedirectResponse
{
$product->update($request->validated());
return to_route('products.index')
->with('message', 'Spesifikasi produk berhasil diperbarui.');
}
public function destroy(Product $product): RedirectResponse
{
$product->delete();
return to_route('products.index')
->with('message', 'Data produk telah dihapus secara permanen.');
}
}Mari kita bedah tiga keputusan teknis penting pada kode Controller di atas:
Optimasi Query
select()&only(): Pada aplikasi SPA, semua data yang dikirimkan keInertia::render()akan diserialisasi menjadi teks JSON dan dapat dilihat melalui tab Network DevTools peramban. Membatasi kolom yang diambil mencegah kebocoran atribut sensitif sekaligus memperkecil ukuran paket jaringan.Single Form Architecture: Metode
create()danedit()sama-sama memanggil komponenProducts/Form. Jika propproductbernilainull, komponen Vue otomatis berjalan dalam mode Tambah Data; jika berisi objek, komponen berubah ke mode Edit.Redirect berbasis Server: Setelah operasi
store,update, ataudestroyselesai, kita wajib mengembalikanto_route('products.index'). Inertia akan menangkap respons pengalihan HTTP 302/303 ini dan secara otomatis memuat ulang data terbaru di halaman tujuan tanpa menyegarkan layar peramban secara penuh.
Daftarkan Resource Controller tersebut di dalam berkas routes/web.php hanya dengan satu baris deklarasi bersih:
// routes/web.php
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;
Route::resource('products', ProductController::class)->except(['show']);
💡 Tips Praktis & Optimasi Performa dari Pengalaman Saya:
Saat tabel produk di lingkungan produksi sudah menampung puluhan ribu baris dan Anda hanya memicu aksi hapus atau pembaruan status cepat di halaman yang sama, manfaatkan fitur Partial Reloads milik Inertia:
router.reload({ only: ['products'] }). Selain itu, pada metode Controller yang memuat banyak data referensi (seperti daftar dropdown kategori atau merek), bungkus kueri referensi tersebut menggunakanInertia::lazy()agar kueri SQL hanya dieksekusi saat komponen modal benar-benar dibuka oleh pengguna. Teknik sederhana ini berhasil memangkas beban CPU RDS database kami hingga 40% pada jam sibuk operasional.
5. Implementasi Frontend: Komponen Vue 3 (<script setup>)
Kini kita beralih ke lapisan tampilan. Buat direktori resources/js/Pages/Products/ dan tambahkan dua berkas komponen utama: Index.vue untuk menampilkan daftar tabel beserta aksi penghapusan, serta Form.vue untuk menangani input dan pembaruan data.
A. Komponen Daftar & Hapus Data (Pages/Products/Index.vue)
Salin kode produksi berikut ke dalam resources/js/Pages/Products/Index.vue. Kita memanfaatkan komponen <Link> untuk navigasi SPA tanpa reload serta objek router.delete dengan opsi preserveScroll:
<!-- resources/js/Pages/Products/Index.vue -->
<script setup>
import { Head, Link, router } from '@inertiajs/vue3';
defineProps({
products: {
type: Object,
required: true,
},
});
const deleteProduct = (id, name) => {
if (confirm(`Apakah Anda yakin ingin menghapus produk "${name}"?`)) {
router.delete(route('products.destroy', id), {
preserveScroll: true,
});
}
};
const formatIDR = (val) => {
return new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: 'IDR',
minimumFractionDigits: 0,
}).format(val);
};
</script>
<template>
<Head title="Manajemen Inventaris Produk" />
<div style="max-width: 1024px; margin: 40px auto; padding: 0 20px;">
<!-- Notifikasi Flash Session -->
<div
v-if="$page.props.flash.message"
style="background-color: #dcfce7; border: 1px solid #86efac; color: #166534; padding: 12px 16px; border-radius: 6px; margin-bottom: 20px;"
>
{{ $page.props.flash.message }}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h1 style="font-size: 1.5rem; font-weight: 700; margin: 0;">Katalog Produk Enterprise</h1>
<Link
:href="route('products.create')"
style="background-color: #4f46e5; color: #ffffff; padding: 10px 16px; border-radius: 6px; text-decoration: none; font-weight: 600;"
>
+ Tambah Produk Baru
</Link>
</div>
<table style="width: 100%; border-collapse: collapse; border: 1px solid #e2e8f0;">
<thead>
<tr style="background-color: #f8fafc; text-align: left;">
<th style="padding: 12px; border-bottom: 1px solid #e2e8f0;">SKU</th>
<th style="padding: 12px; border-bottom: 1px solid #e2e8f0;">Nama Produk</th>
<th style="padding: 12px; border-bottom: 1px solid #e2e8f0;">Harga Satuan</th>
<th style="padding: 12px; border-bottom: 1px solid #e2e8f0;">Stok</th>
<th style="padding: 12px; border-bottom: 1px solid #e2e8f0; text-align: right;">Aksi</th>
</tr>
</thead>
<tbody>
<tr v-for="item in products.data" :key="item.id" style="border-bottom: 1px solid #e2e8f0;">
<td style="padding: 12px; font-family: monospace;">{{ item.sku }}</td>
<td style="padding: 12px; font-weight: 600;">{{ item.name }}</td>
<td style="padding: 12px;">{{ formatIDR(item.price) }}</td>
<td style="padding: 12px;">{{ item.stock }} unit</td>
<td style="padding: 12px; text-align: right;">
<Link
:href="route('products.edit', item.id)"
style="color: #2563eb; margin-right: 12px; text-decoration: none; font-weight: 500;"
>
Edit
</Link>
<button
@click="deleteProduct(item.id, item.name)"
type="button"
style="background: none; border: none; color: #dc2626; cursor: pointer; font-weight: 500;"
>
Hapus
</button>
</td>
</tr>
<tr v-if="products.data.length === 0">
<td colspan="5" style="padding: 24px; text-align: center; color: #64748b;">
Belum ada data produk yang tersimpan di dalam database.
</td>
</tr>
</tbody>
</table>
</div>
</template>Perhatikan cara kita memanggil $page.props.flash.message secara langsung di dalam template. Karena nilai tersebut telah dibagikan oleh middleware HandleInertiaRequests, kita tidak perlu mendeklarasikannya ulang di dalam defineProps().
B. Komponen Formulir Reaktif dengan useForm (Pages/Products/Form.vue)
Salah satu fitur terbaik dari paket @inertiajs/vue3 adalah composable useForm. Utilitas ini secara otomatis melacak perubahan input, menangkap pesan error validasi 422 dari server Laravel, dan menyediakan properti boolean form.processing untuk mengunci tombol kirim agar pengguna tidak melakukan double-submit:
<!-- resources/js/Pages/Products/Form.vue -->
<script setup>
import { computed } from 'vue';
import { Head, Link, useForm } from '@inertiajs/vue3';
const props = defineProps({
product: {
type: Object,
default: null,
},
});
const isEditMode = computed(() => Boolean(props.product));
// Inisialisasi state reaktif formulir dengan useForm helper
const form = useForm({
sku: props.product?.sku ?? '',
name: props.product?.name ?? '',
price: props.product?.price ?? 0,
stock: props.product?.stock ?? 0,
});
const submitForm = () => {
if (isEditMode.value) {
form.put(route('products.update', props.product.id), {
preserveScroll: true,
});
} else {
form.post(route('products.store'), {
onSuccess: () => form.reset(),
});
}
};
</script>
<template>
<Head :title="isEditMode ? 'Edit Produk' : 'Tambah Produk Baru'" />
<div style="max-width: 640px; margin: 40px auto; padding: 24px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h2 style="font-size: 1.35rem; font-weight: 700; margin-top: 0; margin-bottom: 20px;">
{{ isEditMode ? `Edit Produk: ${props.product.name}` : 'Tambah Produk Baru' }}
</h2>
<form @submit.prevent="submitForm">
<div style="margin-bottom: 16px;">
<label style="display: block; font-weight: 600; margin-bottom: 6px;">Kode SKU</label>
<input
v-model="form.sku"
type="text"
placeholder="Contoh: SRV-NVME-01"
style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; box-sizing: border-box;"
/>
<p v-if="form.errors.sku" style="color: #dc2626; font-size: 0.875rem; margin-top: 4px;">
{{ form.errors.sku }}
</p>
</div>
<div style="margin-bottom: 16px;">
<label style="display: block; font-weight: 600; margin-bottom: 6px;">Nama Produk</label>
<input
v-model="form.name"
type="text"
placeholder="Masukkan nama produk lengkap"
style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; box-sizing: border-box;"
/>
<p v-if="form.errors.name" style="color: #dc2626; font-size: 0.875rem; margin-top: 4px;">
{{ form.errors.name }}
</p>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px;">
<div>
<label style="display: block; font-weight: 600; margin-bottom: 6px;">Harga (IDR)</label>
<input
v-model="form.price"
type="number"
style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; box-sizing: border-box;"
/>
<p v-if="form.errors.price" style="color: #dc2626; font-size: 0.875rem; margin-top: 4px;">
{{ form.errors.price }}
</p>
</div>
<div>
<label style="display: block; font-weight: 600; margin-bottom: 6px;">Stok Tersedia</label>
<input
v-model="form.stock"
type="number"
style="width: 100%; padding: 10px; border: 1px solid #cbd5e1; border-radius: 6px; box-sizing: border-box;"
/>
<p v-if="form.errors.stock" style="color: #dc2626; font-size: 0.875rem; margin-top: 4px;">
{{ form.errors.stock }}
</p>
</div>
</div>
<div style="display: flex; justify-content: flex-end; gap: 12px;">
<Link
:href="route('products.index')"
style="padding: 10px 16px; border: 1px solid #cbd5e1; border-radius: 6px; text-decoration: none; color: #334155;"
>
Batal
</Link>
<button
type="submit"
:disabled="form.processing"
style="background-color: #4f46e5; color: #ffffff; padding: 10px 20px; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;"
>
{{ form.processing ? 'Menyimpan...' : (isEditMode ? 'Perbarui Data' : 'Simpan Produk') }}
</button>
</div>
</form>
</div>
</template>
⚠️ Troubleshooting & Skenario Error Handling di Lapangan:
1. Kasus Input Kosong Saat Update Berkas/Gambar (Multipart/Form-Data Bug): Jika Anda menambahkan kolom upload gambar (
input type="file") dan mengirimkannya lewat metodeform.put(), seluruh payload akan terbaca kosong di Laravel dan memicu galat validasiThe name field is required! Hal ini disebabkan oleh keterbatasan standar mesin PHP yang tidak mampu mengurai multipart/form-data pada metode HTTPPUTatauPATCH. Solusinya: Ganti metode pengiriman menggunakanform.post(route('products.update', id))dan tambahkan properti method spoofing_method: 'put'di dalam pendeklarasian objekuseForm({ _method: 'put', ... }).2. Galat "419 Page Expired" atau "Ziggy Error: route is not defined": Jika muncul galat 419 Page Expired saat submit form di server produksi berarsitektur Load Balancer / Cloudflare, pastikan Anda telah mengatur
trustProxies(at: '*')padabootstrap/app.phpagar cookieXSRF-TOKENterbaca di bawah protokol HTTPS. Sementara jika muncul pesanReferenceError: route is not defineddi konsol browser, pastikan Anda sudah menyisipkan direktif@routestepat di atas tag@vite(['resources/js/app.js'])pada berkasresources/views/app.blade.php.
6. FAQ: Pertanyaan Teknis Seputar Laravel, Inertia.js & Vue 3
1. Apakah aplikasi berbasis Laravel Inertia Vue 3 ramah terhadap indeksasi SEO Google?
Secara bawaan, Inertia merender tampilan di sisi peramban (Client-Side Rendering). Namun, jika Anda membangun halaman publik seperti portal berita atau katalog e-commerce yang menargetkan peringkat organik mesin pencari, Inertia menyediakan fitur Server-Side Rendering (SSR) bawaan yang berjalan di atas daemon Node.js untuk menghasilkan HTML utuh bagi crawler mesin pencari.
2. Apakah saya masih membutuhkan pustaka Axios atau Pinia saat menggunakan Inertia.js?
Untuk 95% siklus operasi CRUD standar, Anda tidak lagi membutuhkan Axios maupun Pinia. Pengiriman data ditangani sepenuhnya oleh objek router dan useForm milik Inertia, sedangkan penyimpanan state lintas komponen diambil langsung dari shared data server melalui usePage().props.
3. Bagaimana cara mempertahankan posisi scroll halaman dan isi filter pencarian saat berpindah pagination?
Di sisi server Laravel, tambahkan metode rantai ->withQueryString() setelah memanggil fungsi paginate(). Sedangkan di sisi komponen Vue 3, tambahkan atribut properti preserve-scroll dan preserve-state pada komponen navigasi <Link> atau saat memanggil fungsi router.get().
4. Bisakah saya menggabungkan endpoint API Mobile (Sanctum) di dalam proyek Inertia yang sama?
Sangat bisa. Arsitektur Inertia hanya menempati rute di dalam berkas routes/web.php. Apabila perusahaan Anda nantinya mengembangkan aplikasi mobile iOS atau Android dengan Flutter atau React Native, Anda tetap bebas membuat rute REST API terpisah di dalam berkas routes/api.php yang berbagi lapisan Model dan Service yang sama.
Diskusi & Komentar
0Sampaikan pertanyaan teknis, masukan, atau feedback Anda seputar artikel ini.
Belum Ada Komentar
Jadilah pembaca pertama yang memulai diskusi menarik atau bertanya seputar materi artikel ini!