Studi Kasus #19: Aplikasi Wisata Indonesia – Membuat Halaman Admin (CRUD) Sederhana
Halo, calon full-stack developer! 👋
Di Studi Kasus #18, kita telah menambahkan fitur pencarian di halaman utama. Sekarang saatnya kita membuat halaman admin untuk mengelola data destinasi, penginapan, transportasi, dan paket. Halaman ini hanya bisa diakses oleh pengguna yang sudah login (admin). Kita akan membuat:
- Halaman login (sudah ada di #10, tapi kita akan integrasikan).
- Halaman dashboard admin dengan navigasi ke masing-masing entitas.
- Halaman daftar item (dengan tombol edit dan hapus).
- Halaman form untuk menambah dan mengedit item, termasuk upload gambar cover.
- Fungsi hapus dengan konfirmasi.
Kita akan menggunakan komponen React, state, dan service yang sudah dibuat. Token JWT akan dikirim secara otomatis melalui interceptor axios. Mari kita mulai!
Langkah 1: Membuat Protected Route (PrivateRoute)
Pertama, kita perlu melindungi rute admin agar hanya bisa diakses jika pengguna sudah login. Buat komponen PrivateRoute.jsx di folder src/components:
import { Navigate } from 'react-router-dom';
const PrivateRoute = ({ children }) => {
const token = localStorage.getItem('token');
return token ? children : <Navigate to="/login" />;
};
export default PrivateRoute;
Komponen ini memeriksa token di localStorage. Jika ada, render children (halaman admin). Jika tidak, redirect ke halaman login.
Buat juga halaman login sederhana jika belum ada. Kita akan gunakan komponen Login.jsx yang sudah dibuat di #10 (atau buat ulang).
Langkah 2: Halaman Login
Buat file src/pages/Login.jsx (jika belum) dengan form email dan password. Setelah login berhasil, simpan token di localStorage dan redirect ke admin dashboard.
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import API from '../services/api';
import './Login.css';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await API.post('/auth/login', { email, password });
localStorage.setItem('token', response.data.token);
navigate('/admin/destinations'); // redirect ke halaman admin
} catch (err) {
setError('Email atau password salah');
}
};
return (
<div className="login-container">
<h2>Login Admin</h2>
{error & <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required />
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<button type="submit">Login</button>
</form>
</div>
);
};
export default Login;
CSS sederhana bisa ditambahkan nanti.
Langkah 3: Layout Admin dan Routing
Buat layout khusus admin dengan sidebar navigasi. Buat file src/layouts/AdminLayout.jsx:
import { Outlet, Link } from 'react-router-dom';
import './AdminLayout.css';
const AdminLayout = () => {
return (
<div className="admin-layout">
<aside className="sidebar">
<h3>Admin Panel</h3>
<nav>
<ul>
<li><Link to="/admin/destinations">Destinasi</Link></li>
<li><Link to="/admin/accommodations">Penginapan</Link></li>
<li><Link to="/admin/transportations">Transportasi</Link></li>
<li><Link to="/admin/packages">Paket</Link></li>
</ul>
</nav>
</aside>
<main className="admin-content">
<Outlet />
</main>
</div>
);
};
export default AdminLayout;
CSS (AdminLayout.css):
.admin-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 250px;
background: #2c3e50;
color: white;
padding: 20px;
}
.sidebar h3 {
margin-top: 0;
}
.sidebar ul {
list-style: none;
padding: 0;
}
.sidebar li {
margin: 15px 0;
}
.sidebar a {
color: white;
text-decoration: none;
}
.sidebar a:hover {
text-decoration: underline;
}
.admin-content {
flex: 1;
padding: 20px;
background: #ecf0f1;
}
Sekarang kita atur routing di App.jsx. Tambahkan rute baru di bawah MainLayout:
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import MainLayout from './layouts/MainLayout';
import AdminLayout from './layouts/AdminLayout';
import PrivateRoute from './components/PrivateRoute';
import Home from './pages/Home';
import Login from './pages/Login';
// Halaman admin (akan kita buat)
import AdminDestinations from './pages/Admin/AdminDestinations';
import AdminDestinationForm from './pages/Admin/AdminDestinationForm';
// ... lainnya
const router = createBrowserRouter([
// Rute publik dengan MainLayout
{
element: <MainLayout />,
children: [ ... ],
},
// Rute login tanpa layout (atau bisa pakai layout kosong)
{
path: '/login',
element: <Login />,
},
// Rute admin dilindungi
{
path: '/admin',
element: (
<PrivateRoute>
<AdminLayout />
</PrivateRoute>
),
children: [
{ path: 'destinations', element: <AdminDestinations /> },
{ path: 'destinations/new', element: <AdminDestinationForm /> },
{ path: 'destinations/edit/:id', element: <AdminDestinationForm /> },
// nanti tambahkan untuk accommodations, transportations, packages
],
},
]);
Langkah 4: Halaman Daftar Destinasi (Admin)
Buat file src/pages/Admin/AdminDestinations.jsx. Halaman ini akan menampilkan tabel destinasi dengan tombol edit dan hapus.
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getDestinations, deleteDestination } from '../../services/destinationService';
import './Admin.css';
const AdminDestinations = () => {
const [destinations, setDestinations] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchDestinations();
}, []);
const fetchDestinations = async () => {
try {
const data = await getDestinations();
setDestinations(data);
} catch (err) {
setError('Gagal memuat data');
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Yakin ingin menghapus?')) return;
try {
await deleteDestination(id);
// refresh data setelah hapus
setDestinations(destinations.filter(d => d.id !== id));
} catch (err) {
alert('Gagal menghapus');
}
};
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="admin-page">
<div className="admin-header">
<h2>Kelola Destinasi</h2>
<Link to="new" className="btn btn-primary">Tambah Destinasi</Link>
</div>
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Nama</th>
<th>Lokasi</th>
<th>Kategori</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
{destinations.map(d => (
<tr key={d.id}>
<td>{d.id}</td>
<td>{d.name}</td>
<td>{d.location}</td>
<td>{d.category_name}</td>
<td>
<Link to={`edit/${d.id}`} className="btn-edit">Edit</Link>
<button onClick={() => handleDelete(d.id)} className="btn-delete">Hapus</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default AdminDestinations;
Jangan lupa menambahkan fungsi deleteDestination di service. Di src/services/destinationService.js:
export const deleteDestination = async (id) => {
await API.delete(`/destinations/${id}`);
};
CSS untuk admin (Admin.css):
.admin-page {
background: white;
padding: 20px;
border-radius: 8px;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
color: white;
}
.btn-primary {
background: #e67e22;
}
.btn-edit, .btn-delete {
padding: 4px 8px;
margin: 0 4px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-edit {
background: #3498db;
color: white;
}
.btn-delete {
background: #e74c3c;
color: white;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th, .admin-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.admin-table th {
background: #f2f2f2;
}
Langkah 5: Form Tambah/Edit Destinasi
Buat file src/pages/Admin/AdminDestinationForm.jsx. Form ini akan digunakan untuk menambah dan mengedit destinasi. Kita akan menggunakan FormData untuk mengirim data termasuk gambar.
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { getDestinationById, createDestination, updateDestination } from '../../services/destinationService';
import { getCategories } from '../../services/categoryService';
import './AdminForm.css';
const AdminDestinationForm = () => {
const { id } = useParams(); // jika ada id, berarti mode edit
const navigate = useNavigate();
const [formData, setFormData] = useState({
name: '',
slug: '',
location: '',
description: '',
facilities: '',
category_id: '',
cover_image: null, // untuk file
});
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
// Ambil daftar kategori untuk dropdown
const fetchCategories = async () => {
try {
const data = await getCategories();
setCategories(data);
} catch (err) {
setError('Gagal mengambil kategori');
}
};
fetchCategories();
// Jika mode edit, ambil data destinasi
if (id) {
const fetchDestination = async () => {
try {
const data = await getDestinationById(id);
setFormData({
name: data.name,
slug: data.slug,
location: data.location,
description: data.description || '',
facilities: data.facilities || '',
category_id: data.category_id,
cover_image: null, // tidak bisa prepopulate file
});
} catch (err) {
setError('Gagal mengambil data destinasi');
}
};
fetchDestination();
}
}, [id]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleFileChange = (e) => {
setFormData(prev => ({ ...prev, cover_image: e.target.files[0] }));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
const data = new FormData();
data.append('name', formData.name);
data.append('slug', formData.slug);
data.append('location', formData.location);
data.append('description', formData.description);
data.append('facilities', formData.facilities);
data.append('category_id', formData.category_id);
if (formData.cover_image) {
data.append('cover_image', formData.cover_image);
}
try {
if (id) {
await updateDestination(id, data);
} else {
await createDestination(data);
}
navigate('/admin/destinations'); // kembali ke daftar
} catch (err) {
setError('Gagal menyimpan data');
} finally {
setLoading(false);
}
};
return (
<div className="admin-form">
<h2>{id ? 'Edit' : 'Tambah'} Destinasi</h2>
{error & <p className="error">{error}</p>}
<form onSubmit={handleSubmit}>
<div>
<label>Nama</label>
<input type="text" name="name" value={formData.name} onChange={handleChange} required />
</div>
<div>
<label>Slug</label>
<input type="text" name="slug" value={formData.slug} onChange={handleChange} required />
</div>
<div>
<label>Lokasi</label>
<input type="text" name="location" value={formData.location} onChange={handleChange} required />
</div>
<div>
<label>Deskripsi</label>
<textarea name="description" value={formData.description} onChange={handleChange} rows="4"></textarea>
</div>
<div>
<label>Fasilitas</label>
<textarea name="facilities" value={formData.facilities} onChange={handleChange} rows="4"></textarea>
</div>
<div>
<label>Kategori</label>
<select name="category_id" value={formData.category_id} onChange={handleChange} required>
<option value="">-- Pilih Kategori --</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div>
<label>Gambar Cover</label>
<input type="file" name="cover_image" onChange={handleFileChange} accept="image/*" />
{id & <small>Kosongkan jika tidak ingin mengubah</small>}
</div>
<button type="submit" disabled={loading}>
{loading ? 'Menyimpan...' : 'Simpan'}
</button>
</form>
</div>
);
};
export default AdminDestinationForm;
Jangan lupa untuk menambahkan fungsi createDestination dan updateDestination di service yang sudah mendukung FormData. Pastikan service menggunakan axios dengan header multipart/form-data. Contoh di destinationService.js:
export const createDestination = async (formData) => {
const response = await API.post('/destinations', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data;
};
export const updateDestination = async (id, formData) => {
const response = await API.put(`/destinations/${id}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data;
};
CSS untuk form (AdminForm.css):
.admin-form {
background: white;
padding: 20px;
border-radius: 8px;
max-width: 600px;
}
.admin-form form div {
margin-bottom: 15px;
}
.admin-form label {
display: block;
font-weight: bold;
margin-bottom: 5px;
}
.admin-form input[type="text"],
.admin-form input[type="number"],
.admin-form select,
.admin-form textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.admin-form input[type="file"] {
padding: 8px 0;
}
.admin-form button {
background: #e67e22;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
.admin-form button:disabled {
background: #ccc;
cursor: not-allowed;
}
.error {
color: #e74c3c;
margin-bottom: 15px;
}
Langkah 6: Menambahkan CRUD untuk Entitas Lain
Pola yang sama dapat diterapkan untuk penginapan, transportasi, dan paket. Untuk paket, formnya lebih sederhana (tanpa gambar cover). Kita bisa membuat komponen serupa dengan menyesuaikan field dan service yang dipanggil. Buat halaman AdminAccommodations, AdminAccommodationForm, AdminTransportations, AdminTransportationForm, AdminPackages, AdminPackageForm. Karena panjang, tidak kita tulis semua di sini, tapi prinsipnya sama.
Untuk paket, field yang diperlukan: entity_type, entity_id, name, description, price. Kita perlu dropdown untuk memilih entitas (destinasi/penginapan/transportasi) dan ID. Ini bisa menjadi tantangan tersendiri, tapi bisa disederhanakan dengan memilih entitas terlebih dahulu.
Sebagai contoh, kita buat halaman AdminPackages dengan tabel dan form yang sesuai. Kita tidak akan membahas detail di sini, tapi pembaca diharapkan bisa mengembangkannya sendiri.
Langkah 7: Menguji Halaman Admin
Pastikan backend berjalan dan token sudah ada. Coba akses /admin/destinations. Jika belum login, akan diarahkan ke halaman login. Setelah login, kamu bisa melihat daftar destinasi, menambah, mengedit, dan menghapus. Coba upload gambar saat menambah destinasi baru.
Periksa juga apakah token sudah dikirim dengan benar. Buka tab Network di devtools, lihat header Authorization pada request ke API.
Langkah Selanjutnya
Di Studi Kasus #20, kita akan menyempurnakan halaman admin dengan menambahkan fitur untuk mengelola galeri foto dan paket harga. Kita juga akan membahas cara mengintegrasikan paket dengan entitas terkait.
Pastikan semua fungsi CRUD berjalan dengan baik. Jika ada kendala, periksa konsol browser dan response dari server.
Sampai jumpa di tutorial berikutnya!
Ditulis dengan ❤️ untuk para pengembang yang ingin membangun aplikasi wisata.