Pertemuan 06: Pengelolaan Katalog Buku Fisik, Kategori, dan Manajemen Upload Berkas
1. Pengantar: Membangun Inti Data Koleksi Perpustakaan
Deskripsi Singkat:
Katalog buku adalah entitas paling vital dalam sistem perpustakaan. Pada pertemuan ini, kita membangun modul CRUD (Create, Read, Update, Delete) buku fisik, validasi formulir berlapis menggunakan Form Request mandiri, mekanisme unggah berkas (file upload) cover buku ke disk publik, pembersihan berkas lama saat dihapus/diperbarui, serta proteksi hak akses Role-Based Access Control (RBAC) untuk manajemen kategori buku.
2. Keamanan Penyimpanan File: Disk Publik vs Disk Privat
Deskripsi Singkat:
Banyak developer pemula melakukan kesalahan fatal dengan menyimpan seluruh berkas unggahan langsung di direktori web publik (public/uploads). Hal ini berbahaya untuk berkas digital berhak cipta (seperti PDF e-book) karena URL berkas dapat ditebak dan diunduh siapa saja tanpa izin.
Laravel menyediakan abstraksi filesystem modern melalui config/filesystems.php:
Disk Publik (
public):Lokasi fisik:
storage/app/public/covers/.Digunakan untuk: Gambar cover buku, avatar pengguna, atau logo perpustakaan yang memang boleh dilihat siapa saja di web browser.
Cara akses: Dihubungkan ke folder web publik via symbolic link:
php artisan storage:linkLalu diakses di Blade via accessor
$book->cover_urlatau helperasset('storage/' . $book->cover_image).
Disk Privat / Lokal (
private):Lokasi fisik:
storage/app/private/ebooks/.Digunakan untuk: Dokumen PDF e-book asli berhak cipta.
Cara akses: Tidak boleh ada link langsung dari web publik. Berkas hanya dapat dibaca melalui streaming Controller khusus yang mengecek otorisasi login dan hak akses pengguna (dipelajari tuntas pada Pertemuan 07).
3. Proteksi Hak Akses Kategori dengan Spatie RBAC
Deskripsi Singkat:
Kategori buku berfungsi mengelompokkan koleksi perpustakaan. Seluruh pengguna terdaftar (anggota, pustakawan, dan admin) berhak melihat daftar kategori (categories.view), namun hanya Admin dan Pustakawan yang memiliki izin untuk menambah atau menghapus kategori (categories.manage).
A. Daftarkan Permission pada database/seeders/RoleAndPermissionSeeder.php:
Tambahkan 'categories.view' dan 'categories.manage' ke dalam array $permissions:
$permissions = [
'dashboard.view',
'dashboard-user.view',
'landing.view',
'categories.view',
'categories.manage',
'books.view',
'books.manage',
'ebooks.view',
'ebooks.read',
'ebooks.manage',
'loans.view-own',
'loans.manage',
'inventory.stock-opname',
'memberships.manage',
'reports.view',
'settings.manage',
'roles.manage',
];
Dan kaitkan ke Role terkait:
// Admin: Mendapatkan seluruh permissions
$adminRole = Role::findOrCreate('admin', 'web');
$adminRole->syncPermissions(Permission::all());
// Librarian (Pustakawan)
$librarianRole = Role::findOrCreate('librarian', 'web');
$librarianRole->syncPermissions([
'dashboard.view',
'landing.view',
'categories.view',
'categories.manage',
'books.view',
'books.manage',
'ebooks.view',
'ebooks.read',
'ebooks.manage',
'loans.manage',
'inventory.stock-opname',
'memberships.manage',
'reports.view',
]);
// User (Anggota / Pembaca)
$userRole = Role::findOrCreate('user', 'web');
$userRole->syncPermissions([
'dashboard-user.view',
'landing.view',
'categories.view',
'books.view',
'ebooks.view',
'ebooks.read',
'loans.view-own',
]);
Jalankan seeder untuk memperbarui izin:
php artisan db:seed --class=RoleAndPermissionSeeder
4. Validasi Form Request Mandiri (app/Http/Requests/BookRequest.php)
Deskripsi Singkat:
Alih-alih menumpuk logika validasi yang panjang di dalam Controller, kita memindahkannya ke class Form Request tersendiri. Ini membuat kode Controller tetap bersih (clean code) dan mudah dirawat.
Perintah Artisan:
php artisan make:request BookRequest
Salin kode berikut ke app/Http/Requests/BookRequest.php:
<?php
namespace App\Http\Requests;
use App\Models\Book;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class BookRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Dikelola oleh middleware permission di rute
}
public function rules(): array
{
$book = $this->route('book');
$bookId = $book instanceof Book ? $book->id : $book;
return [
'category_id' => ['required', 'exists:categories,id'],
'title' => ['required', 'string', 'max:255'],
'author' => ['required', 'string', 'max:150'],
'publisher' => ['required', 'string', 'max:150'],
'published_year' => ['required', 'integer', 'min:1900', 'max:' . (date('Y') + 1)],
'isbn' => ['required', 'string', 'max:30', Rule::unique('books', 'isbn')->ignore($bookId)],
'rack_location' => ['required', 'string', 'max:50'],
'total_stock' => ['nullable', 'integer', 'min:0'],
'stock' => ['nullable', 'integer', 'min:0'],
'description' => ['nullable', 'string'],
'cover_image' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp', 'max:2048'], // Maksimal 2MB
];
}
public function attributes(): array
{
return [
'category_id' => 'kategori',
'title' => 'judul buku',
'author' => 'penulis',
'publisher' => 'penerbit',
'published_year' => 'tahun terbit',
'isbn' => 'ISBN',
'rack_location' => 'lokasi rak',
'total_stock' => 'jumlah eksemplar',
'cover_image' => 'sampul buku',
'description' => 'deskripsi',
];
}
}
5. Controller Buku Fisik (app/Http/Controllers/BookController.php)
Deskripsi Singkat:
Controller ini menangani logika pencarian katalog, filter kategori, pengurutan data, upload file gambar cover ke disk public, sinkronisasi stok eksemplar, serta penghapusan berkas cover lama dari storage saat buku diperbarui atau dihapus.
Salin kode berikut ke app/Http/Controllers/BookController.php:
<?php
namespace App\Http\Controllers;
use App\Http\Requests\BookRequest;
use App\Models\Book;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
class BookController extends Controller
{
public function index(Request $request): View
{
$query = Book::with('category')->latest();
// 1. Pencarian Realtime (Judul, Penulis, ISBN)
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('author', 'like', "%{$search}%")
->orWhere('isbn', 'like', "%{$search}%");
});
}
// 2. Filter Kategori
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
} elseif ($request->filled('category')) {
$query->where('category_id', $request->category);
}
// 3. Sorting / Pengurutan
$sort = $request->input('sort', 'latest');
match ($sort) {
'popular' => $query->orderByDesc('views_count'),
'title' => $query->orderBy('title'),
'stock' => $query->orderByDesc('total_stock'),
default => $query->latest(),
};
$books = $query->paginate(12)->withQueryString();
$categories = Category::orderBy('name')->get();
return view('books.index', compact('books', 'categories'));
}
public function create(): View
{
$categories = Category::orderBy('name')->get();
return view('books.create', compact('categories'));
}
public function store(BookRequest $request): RedirectResponse
{
$validated = $request->validated();
$stockValue = $validated['total_stock'] ?? $validated['stock'] ?? 0;
$coverImagePath = null;
if ($request->hasFile('cover_image')) {
$coverImagePath = $request->file('cover_image')->store('covers', 'public');
}
Book::create([
'category_id' => $validated['category_id'],
'isbn' => $validated['isbn'],
'title' => $validated['title'],
'slug' => Str::slug($validated['title']) . '-' . Str::random(5),
'author' => $validated['author'],
'publisher' => $validated['publisher'],
'published_year' => $validated['published_year'],
'rack_location' => $validated['rack_location'],
'stock' => $stockValue,
'total_stock' => $stockValue,
'available_stock' => $stockValue,
'cover_image' => $coverImagePath,
'description' => $validated['description'] ?? null,
]);
return redirect()->route('books.index')->with('success', 'Buku baru berhasil ditambahkan ke katalog.');
}
public function show(Book $book): View
{
$book->load('category');
$book->increment('views_count');
return view('books.show', compact('book'));
}
public function edit(Book $book): View
{
$categories = Category::orderBy('name')->get();
return view('books.edit', compact('book', 'categories'));
}
public function update(BookRequest $request, Book $book): RedirectResponse
{
$validated = $request->validated();
$newStock = $validated['total_stock'] ?? $validated['stock'] ?? $book->total_stock;
if ($request->hasFile('cover_image')) {
// Hapus file cover lama jika ada di disk public
if ($book->cover_image && Storage::disk('public')->exists($book->cover_image)) {
Storage::disk('public')->delete($book->cover_image);
}
$validated['cover_image'] = $request->file('cover_image')->store('covers', 'public');
}
$stockDiff = $newStock - $book->total_stock;
$validated['stock'] = $newStock;
$validated['total_stock'] = $newStock;
$validated['available_stock'] = max(0, $book->available_stock + $stockDiff);
$book->update($validated);
return redirect()->route('books.index')->with('success', 'Informasi buku berhasil diperbarui.');
}
public function destroy(Book $book): RedirectResponse
{
if ($book->cover_image && Storage::disk('public')->exists($book->cover_image)) {
Storage::disk('public')->delete($book->cover_image);
}
$book->delete();
return redirect()->route('books.index')->with('success', 'Buku berhasil dihapus dari katalog.');
}
}
6. Antarmuka CRUD Buku Tailwind CSS v4 (resources/views/books/)
Deskripsi Singkat:
Seluruh antarmuka modul buku menggunakan styling murni Tailwind CSS v4 dengan layout modern, penanganan error @error, indikator badge ketersediaan stok, tata letak kartu grid/table responsif, serta pratinjau gambar cover interaktif via JavaScript.
[!NOTE]
Pada branch startermain, folderresources/views/books/sengaja dikosongkan agar peserta dapat mempraktikkan proses pembuatan tampilan secara bertahap dan memahami anatomi setiap komponen Blade.
Langkah 6.0: Menyiapkan Direktori View Buku
Buka terminal Anda di direktori proyek dan buat folder baru untuk menyimpan tampilan modul buku:
mkdir -p resources/views/books
Langkah 6.1: Formulir Tambah Buku Fisik (resources/views/books/create.blade.php)
Tujuan Tampilan:
Menyediakan formulir pendaftaran buku baru ke sistem lengkap dengan informasi bibliografi (ISBN, Judul, Penulis, Penerbit, Tahun Terbit, Kategori, Lokasi Rak, Stok) serta berkas gambar cover fisik.
Poin Kunci Anatomi Blade & Tailwind CSS v4:
enctype="multipart/form-data": Atribut wajib pada tag<form>saat formulir menyertakan upload file (gambar cover). Jika lupa menyertakan atribut ini, input tipe file hanya akan mengirimkan nama file sebagai string teks biasa, bukan data biner berkas ke backend.@csrf: Token keamanan wajib Laravel untuk melindungi form dari serangan Cross-Site Request Forgery.@error('field') ... @enderror: Memeriksa apakah terjadi kesalahan validasi dariBookRequestuntuk field tersebut, lalu menampilkan pesan error berwarna merah dan menambahkan border merah pada input terkait.old('field'): Menjaga nilai input pengguna agar tidak hilang saat validasi form gagal dikembalikan oleh server.Pratinjau Cover Interaktif (JavaScript FileReader): Script JavaScript di bagian
@push('scripts')atau sebelum penutup@endsectionmembaca data lokal gambar pengguna via APIFileReader(readAsDataURL) dan langsung menampilkannya pada bingkai gambar pratinjau sebelum form di-submit.
Buat berkas baru di resources/views/books/create.blade.php lalu salin kode lengkap berikut:
@extends('layouts.app')
@section('content')
<div class="max-w-4xl mx-auto space-y-6">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-xs text-slate-500 mb-1">
<a href="{{ route('books.index') }}" class="hover:text-blue-600 transition-colors">Katalog Buku Fisik</a>
<span>/</span>
<span class="text-slate-700 font-medium">Tambah Baru</span>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 tracking-tight font-serif">Tambah Buku Perpustakaan</h1>
<p class="text-slate-500 text-xs sm:text-sm mt-0.5">Daftarkan koleksi buku fisik baru ke dalam katalog perpustakaan.</p>
</div>
<a href="{{ route('books.index') }}" class="btn btn-outline-secondary flex items-center gap-1.5 text-xs py-2 px-3.5 rounded-xl shadow-2xs">
<i class="bi bi-arrow-left"></i>
<span>Kembali ke Daftar</span>
</a>
</div>
<!-- Form Card -->
<div class="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 sm:p-8">
<form action="{{ route('books.store') }}" method="POST" enctype="multipart/form-data" class="space-y-6">
@csrf
<!-- Baris 1: ISBN & Kategori -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label for="isbn" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Nomor ISBN <span class="text-rose-500">*</span>
</label>
<input type="text"
name="isbn"
id="isbn"
value="{{ old('isbn') }}"
placeholder="Contoh: 978-602-0001"
class="w-full px-3.5 py-2.5 rounded-xl border @error('isbn') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm font-mono focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('isbn')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="category_id" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Kategori Buku <span class="text-rose-500">*</span>
</label>
<select name="category_id"
id="category_id"
class="w-full px-3.5 py-2.5 rounded-xl border @error('category_id') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
<option value="">Pilih Kategori</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
</div>
<!-- Baris 2: Judul Buku -->
<div>
<label for="title" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Judul Lengkap Buku <span class="text-rose-500">*</span>
</label>
<input type="text"
name="title"
id="title"
value="{{ old('title') }}"
placeholder="Masukkan judul buku secara lengkap..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('title') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('title')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<!-- Baris 3: Penulis & Penerbit -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label for="author" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Penulis / Pengarang <span class="text-rose-500">*</span>
</label>
<input type="text"
name="author"
id="author"
value="{{ old('author') }}"
placeholder="Nama penulis buku..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('author') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('author')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="publisher" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Penerbit <span class="text-rose-500">*</span>
</label>
<input type="text"
name="publisher"
id="publisher"
value="{{ old('publisher') }}"
placeholder="Nama instansi penerbit..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('publisher') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('publisher')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
</div>
<!-- Baris 4: Tahun Terbit, Lokasi Rak, & Jumlah Eksemplar -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div>
<label for="published_year" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Tahun Terbit <span class="text-rose-500">*</span>
</label>
<input type="number"
name="published_year"
id="published_year"
value="{{ old('published_year', date('Y')) }}"
min="1900"
max="{{ date('Y') + 1 }}"
class="w-full px-3.5 py-2.5 rounded-xl border @error('published_year') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('published_year')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="rack_location" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Lokasi Rak <span class="text-rose-500">*</span>
</label>
<input type="text"
name="rack_location"
id="rack_location"
value="{{ old('rack_location') }}"
placeholder="Contoh: RAK-B-03"
class="w-full px-3.5 py-2.5 rounded-xl border @error('rack_location') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('rack_location')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="total_stock" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Jumlah Eksemplar <span class="text-rose-500">*</span>
</label>
<input type="number"
name="total_stock"
id="total_stock"
value="{{ old('total_stock', 5) }}"
min="0"
class="w-full px-3.5 py-2.5 rounded-xl border @error('total_stock') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('total_stock')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
</div>
<!-- Baris 5: Sampul Cover Buku -->
<div>
<label for="cover_image" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Foto Sampul Buku <span class="text-slate-400 font-normal lowercase">(opsional)</span>
</label>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div class="flex-1 w-full">
<input type="file"
name="cover_image"
id="cover_image"
accept="image/jpeg,image/png,image/webp"
onchange="previewCoverImage(this)"
class="w-full px-3 py-2 text-xs rounded-xl border border-slate-200 bg-slate-50 text-slate-700 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 transition cursor-pointer">
<p class="text-slate-400 text-xs mt-1.5">Format JPG, PNG, atau WebP (Maksimal 2 MB).</p>
</div>
<div id="coverPreviewWrapper" class="hidden shrink-0">
<img id="coverPreviewImg" src="#" alt="Preview Cover" class="w-16 h-22 object-cover rounded-lg border border-slate-200 shadow-xs">
</div>
</div>
@error('cover_image')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<!-- Baris 6: Sinopsis / Deskripsi -->
<div>
<label for="description" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Deskripsi / Sinopsis Buku <span class="text-slate-400 font-normal lowercase">(opsional)</span>
</label>
<textarea name="description"
id="description"
rows="4"
placeholder="Tuliskan ringkasan, sinopsis, atau catatan penting mengenai buku ini..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('description') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition">{{ old('description') }}</textarea>
@error('description')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-slate-100">
<a href="{{ route('books.index') }}" class="btn btn-light border text-xs py-2.5 px-4 rounded-xl">
Batal
</a>
<button type="submit" class="btn btn-primary shadow-xs text-xs py-2.5 px-6 rounded-xl flex items-center gap-2">
<i class="bi bi-check2"></i>
<span>Simpan Buku</span>
</button>
</div>
</form>
</div>
</div>
<script>
function previewCoverImage(input) {
const previewWrapper = document.getElementById('coverPreviewWrapper');
const previewImg = document.getElementById('coverPreviewImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
previewImg.src = e.target.result;
previewWrapper.classList.remove('hidden');
};
reader.readAsDataURL(input.files[0]);
}
}
</script>
@endsection
Langkah 6.2: Halaman Daftar Katalog Buku (resources/views/books/index.blade.php)
Tujuan Tampilan:
Menampilkan etalase seluruh koleksi buku fisik yang terdaftar dalam perpustakaan. Dilengkapi dengan pencarian judul/penulis, penyaringan kategori dinamis, peralihan mode tampilan (Grid visual vs Tabel ringkas), serta tindakan cepat (lihat detail, ubah, atau hapus bagi pengelola).
Poin Kunci Anatomi Blade & Tailwind CSS v4:
Otorisasi Aksi
@can('books.manage'): Tombol "Tambah Buku Baru", tombol ubah data, dan tombol hapus dibungkus dengan directive@can('books.manage'). Dengan demikian, anggota biasa (user) hanya melihat tombol "Lihat Detail", sedangkan admin/pustakawan mendapatkan akses penuh ke tombol manajemen.Formulir Pencarian HTTP
GET: Menggunakan<form method="GET">agar kata kunci pencarian dan filter kategori tersimpan di URL query string (?search=Laravel&category_id=1). Hal ini memungkinkan tautan hasil pencarian dibagikan (shareable) atau di-refresh tanpa kehilangan status filter.Peralihan Mode Tampilan (Grid Mode vs Table Mode): Parameter
view=gridatauview=tableditangani langsung oleh Blade untuk merender kartu visual bergambar (cocok untuk eksplorasi koleksi) atau tabel data terstruktur (cocok untuk audit inventaris cepat).Pagination Presisi
{{ $books->links() }}: Memanggil generator tautan pagination bawaan Laravel yang otomatis menjaga parameter filter pencarian aktif berkat methodwithQueryString()di controller.
Buat berkas baru di resources/views/books/index.blade.php lalu salin kode lengkap berikut:
@extends('layouts.app')
@section('content')
<div class="space-y-6">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<div class="flex items-center gap-3">
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 tracking-tight font-serif">Daftar Buku Fisik</h1>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200/60 tabular-nums">
{{ $books->total() }} Judul
</span>
</div>
<p class="text-slate-500 text-xs sm:text-sm mt-1">Kelola dan telusuri inventaris koleksi buku fisik, lokasi rak, dan ketersediaan eksemplar.</p>
</div>
@can('books.manage')
<a href="{{ route('books.create') }}" class="btn btn-primary shadow-xs flex items-center gap-2">
<i class="bi bi-plus-lg"></i>
<span>Tambah Buku Baru</span>
</a>
@endcan
</div>
<!-- Search & Filter Controls -->
<div class="bg-white rounded-2xl border border-slate-200/80 p-4 sm:p-5 shadow-xs">
<form action="{{ route('books.index') }}" method="GET" class="space-y-4">
@if(request('view'))
<input type="hidden" name="view" value="{{ request('view') }}">
@endif
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-12 gap-3 items-center">
<!-- Search Input -->
<div class="lg:col-span-5 relative">
<i class="bi bi-search text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2 text-sm pointer-events-none"></i>
<input type="text"
name="search"
value="{{ request('search') }}"
placeholder="Cari judul, penulis, atau ISBN..."
class="w-full bg-slate-50 hover:bg-slate-100/80 focus:bg-white text-slate-900 text-sm rounded-xl pl-10 pr-10 py-2.5 border border-slate-200 focus:border-blue-500 focus:ring-3 focus:ring-blue-100 transition-all outline-hidden">
@if(request('search'))
<a href="{{ route('books.index', request()->except('search')) }}" class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
<i class="bi bi-x-circle-fill text-xs"></i>
</a>
@endif
</div>
<!-- Category Dropdown -->
<div class="lg:col-span-3">
<select name="category_id"
onchange="this.form.submit()"
class="w-full bg-slate-50 hover:bg-slate-100/80 focus:bg-white text-slate-800 text-sm rounded-xl px-3.5 py-2.5 border border-slate-200 focus:border-blue-500 focus:ring-3 focus:ring-blue-100 transition-all outline-hidden">
<option value="">Semua Kategori</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ request('category_id') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
<!-- Sorting Dropdown -->
<div class="lg:col-span-2">
<select name="sort"
onchange="this.form.submit()"
class="w-full bg-slate-50 hover:bg-slate-100/80 focus:bg-white text-slate-800 text-sm rounded-xl px-3.5 py-2.5 border border-slate-200 focus:border-blue-500 focus:ring-3 focus:ring-blue-100 transition-all outline-hidden">
<option value="latest" {{ request('sort') == 'latest' ? 'selected' : '' }}>Terbaru</option>
<option value="popular" {{ request('sort') == 'popular' ? 'selected' : '' }}>Terpopuler</option>
<option value="title" {{ request('sort') == 'title' ? 'selected' : '' }}>Judul (A-Z)</option>
<option value="stock" {{ request('sort') == 'stock' ? 'selected' : '' }}>Stok Terbanyak</option>
</select>
</div>
<!-- Action Buttons & Reset -->
<div class="lg:col-span-2 flex items-center gap-2">
<button type="submit" class="btn btn-primary flex-1 text-xs sm:text-sm py-2.5">
Filter
</button>
@if(request()->hasAny(['search', 'category_id', 'sort']))
<a href="{{ route('books.index', ['view' => request('view')]) }}" class="btn btn-outline-secondary text-xs sm:text-sm py-2.5" title="Reset Filter">
<i class="bi bi-arrow-counterclockwise"></i>
</a>
@endif
</div>
</div>
<!-- View Mode Switcher -->
<div class="flex items-center justify-between pt-3 border-t border-slate-100 text-xs">
<div class="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
<span class="text-slate-400 shrink-0 font-medium">Kategori Populer:</span>
@foreach($categories->take(6) as $cat)
<a href="{{ route('books.index', array_merge(request()->except(['category_id', 'page']), ['category_id' => $cat->id])) }}"
class="shrink-0 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors {{ request('category_id') == $cat->id ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200' }}">
{{ $cat->name }}
</a>
@endforeach
</div>
<div class="flex items-center gap-1 shrink-0 bg-slate-100 p-1 rounded-xl">
@php $currentView = request('view', 'grid'); @endphp
<a href="{{ route('books.index', array_merge(request()->except('view'), ['view' => 'grid'])) }}"
class="px-2.5 py-1 rounded-lg font-medium text-xs flex items-center gap-1.5 transition-all {{ $currentView === 'grid' ? 'bg-white text-slate-900 shadow-xs' : 'text-slate-500 hover:text-slate-900' }}"
title="Tampilan Grid Kartu">
<i class="bi bi-grid-fill"></i>
<span class="hidden sm:inline">Grid</span>
</a>
<a href="{{ route('books.index', array_merge(request()->except('view'), ['view' => 'table'])) }}"
class="px-2.5 py-1 rounded-lg font-medium text-xs flex items-center gap-1.5 transition-all {{ $currentView === 'table' ? 'bg-white text-slate-900 shadow-xs' : 'text-slate-500 hover:text-slate-900' }}"
title="Tampilan Tabel Inventaris">
<i class="bi bi-list-ul"></i>
<span class="hidden sm:inline">Tabel</span>
</a>
</div>
</div>
</form>
</div>
<!-- Books Display (Grid or Table) -->
@if(request('view') === 'table')
<!-- Tabular View -->
<div class="bg-white rounded-2xl border border-slate-200/80 shadow-xs overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left text-xs sm:text-sm">
<thead class="bg-slate-50 border-b border-slate-200/80 text-slate-600 font-semibold uppercase text-[11px] tracking-wider">
<tr>
<th class="px-5 py-3.5">ISBN</th>
<th class="px-5 py-3.5">Judul & Penulis</th>
<th class="px-5 py-3.5">Kategori</th>
<th class="px-5 py-3.5">Lokasi Rak</th>
<th class="px-5 py-3.5">Ketersediaan</th>
<th class="px-5 py-3.5">Dilihat</th>
<th class="px-5 py-3.5 text-right">Aksi</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 text-slate-700">
@forelse($books as $book)
<tr class="hover:bg-slate-50/80 transition-colors">
<td class="px-5 py-4 font-mono font-medium text-slate-900 whitespace-nowrap">{{ $book->isbn }}</td>
<td class="px-5 py-4">
<div class="flex items-center gap-3">
<img src="{{ $book->cover_url }}" alt="{{ $book->title }}" class="w-10 h-14 object-cover rounded-lg shrink-0 shadow-2xs border border-slate-200">
<div class="min-w-0">
<a href="{{ route('books.show', $book) }}" class="font-bold text-slate-900 hover:text-blue-600 transition-colors block truncate max-w-xs sm:max-w-sm">
{{ $book->title }}
</a>
<span class="text-xs text-slate-500 block truncate mt-0.5"><i class="bi bi-person me-1"></i>{{ $book->author }}</span>
</div>
</div>
</td>
<td class="px-5 py-4 whitespace-nowrap">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-sky-50 text-sky-700 border border-sky-100">
{{ $book->category->name }}
</span>
</td>
<td class="px-5 py-4 whitespace-nowrap font-mono text-xs text-slate-600">
<i class="bi bi-geo-alt text-rose-500 me-1"></i>Rak {{ $book->rack_location }}
</td>
<td class="px-5 py-4 whitespace-nowrap">
@if($book->available_stock > 0)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-700 border border-emerald-200">
{{ $book->available_stock }} / {{ $book->total_stock }} Tersedia
</span>
@else
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold bg-rose-50 text-rose-700 border border-rose-200">
Dipinjam (Habis)
</span>
@endif
</td>
<td class="px-5 py-4 whitespace-nowrap text-xs text-slate-500 tabular-nums">
<i class="bi bi-eye text-slate-400 me-1"></i>{{ number_format($book->views_count) }}
</td>
<td class="px-5 py-4 text-right whitespace-nowrap">
<div class="inline-flex items-center gap-1">
<a href="{{ route('books.show', $book) }}" class="p-1.5 rounded-lg text-slate-600 hover:bg-slate-100 transition" title="Lihat Detail">
<i class="bi bi-eye"></i>
</a>
@can('books.manage')
<a href="{{ route('books.edit', $book) }}" class="p-1.5 rounded-lg text-blue-600 hover:bg-blue-50 transition" title="Ubah Data">
<i class="bi bi-pencil"></i>
</a>
<form action="{{ route('books.destroy', $book) }}" method="POST" class="inline" onsubmit="return confirm('Apakah Anda yakin ingin menghapus buku ini?');">
@csrf
@method('DELETE')
<button type="submit" class="p-1.5 rounded-lg text-rose-600 hover:bg-rose-50 transition" title="Hapus Buku">
<i class="bi bi-trash"></i>
</button>
</form>
@endcan
</div>
</td>
</tr>
@empty
<tr>
<td colspan="7" class="px-5 py-12 text-center text-slate-400">
<i class="bi bi-journal-x text-3xl d-block mb-2 text-slate-300"></i>
Tidak ada buku yang sesuai dengan kriteria pencarian.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@else
<!-- Grid View (Modern Visual Cards Matching Landing Design) -->
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-x-4 gap-y-7 sm:gap-x-6 sm:gap-y-9">
@forelse($books as $book)
<div class="group flex flex-col items-center text-center transition-all duration-300">
<a href="{{ route('books.show', $book) }}" class="block w-full group">
<!-- Cover Card -->
<div class="relative w-full aspect-[3/4.2] rounded-2xl overflow-hidden bg-slate-100 shadow-xs group-hover:shadow-lg transition-all duration-300 border border-slate-200/70">
<img src="{{ $book->cover_url }}"
alt="{{ $book->title }}"
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500">
<div class="absolute top-2.5 right-2.5">
@if($book->available_stock > 0)
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-600/90 backdrop-blur-xs text-white shadow-xs">
{{ $book->available_stock }} Stok
</span>
@else
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-600/90 backdrop-blur-xs text-white shadow-xs">
Dipinjam
</span>
@endif
</div>
<div class="absolute bottom-2 left-2 right-2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono bg-slate-900/80 backdrop-blur-xs text-slate-200">
<i class="bi bi-geo-alt text-rose-400"></i> Rak {{ $book->rack_location }}
</span>
</div>
</div>
<!-- Title -->
<h3 class="font-semibold text-slate-800 text-xs sm:text-[13px] leading-snug line-clamp-2 mt-3 group-hover:text-blue-600 transition-colors px-1" title="{{ $book->title }}">
{{ $book->title }}
</h3>
</a>
<!-- Views Count -->
<div class="flex items-center justify-center gap-1 text-slate-500 text-[11px] sm:text-xs mt-1.5 font-sans">
<i class="bi bi-eye text-slate-400"></i>
<span>{{ number_format($book->views_count) }} x dilihat</span>
</div>
<!-- Category Badge Pill -->
<div class="mt-2">
<span class="inline-flex items-center px-3 py-0.5 rounded-full text-xs font-semibold bg-sky-50 text-sky-600 border border-sky-100 hover:bg-sky-100 transition-colors">
{{ $book->category->name }}
</span>
</div>
</div>
@empty
<div class="col-span-full py-16 text-center">
<div class="w-16 h-16 rounded-full bg-slate-100 text-slate-400 flex items-center justify-center mx-auto mb-3 text-2xl">
<i class="bi bi-journal-x"></i>
</div>
<p class="text-slate-700 font-bold text-base">Buku tidak ditemukan</p>
<p class="text-slate-400 text-xs mt-1">Coba gunakan kata kunci pencarian atau kategori lain.</p>
</div>
@endforelse
</div>
@endif
<!-- Pagination -->
@if($books->hasPages())
<div class="mt-8 flex justify-center">
{{ $books->links() }}
</div>
@endif
</div>
@endsection
Langkah 6.3: Formulir Ubah Data Buku Fisik (resources/views/books/edit.blade.php)
Tujuan Tampilan:
Memungkinkan pengelola (admin/pustakawan) memperbarui data buku yang sudah ada di basis data (seperti perbaikan judul, perubahan lokasi rak, atau penambahan stok) serta mengganti berkas gambar cover fisik bila diperlukan.
Poin Kunci Anatomi Blade & Tailwind CSS v4:
Method Spoofing
@method('PUT'): Mengingat form HTML secara spesifikasi hanya mendukung method GET dan POST, directive@method('PUT')menyisipkan input tersembunyi_method="PUT"sehingga Laravel Router memetakannya secara tepat ke methodBookController@update.Pola Nilai Default
old('field', $book->field): Pola ini memastikan kenyamanan pengisian formulir. Nilai awal diambil dari model Eloquent$book->field. Namun jika form di-submit dan gagal validasi, input akan mempertahankan nilai ketikan baru pengguna dari sesiold('field').Tampilan Pratinjau Cover Eksisting: Formulir menampilkan kartu preview berkas gambar cover yang sedang aktif dari storage publik (
Storage::url($book->cover_image)). Pengguna cukup membiarkan input file kosong jika tidak ingin mengubah cover, dan sistem akan mempertahankan berkas yang lama.
Buat berkas baru di resources/views/books/edit.blade.php lalu salin kode lengkap berikut:
@extends('layouts.app')
@section('content')
<div class="max-w-4xl mx-auto space-y-6">
<!-- Header Section -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-xs text-slate-500 mb-1">
<a href="{{ route('books.index') }}" class="hover:text-blue-600 transition-colors">Katalog Buku Fisik</a>
<span>/</span>
<a href="{{ route('books.show', $book) }}" class="hover:text-blue-600 transition-colors truncate max-w-xs">{{ $book->title }}</a>
<span>/</span>
<span class="text-slate-700 font-medium">Edit</span>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 tracking-tight font-serif">Perbarui Koleksi Buku</h1>
<p class="text-slate-500 text-xs sm:text-sm mt-0.5">Ubah rincian informasi dan stok eksemplar buku perpustakaan.</p>
</div>
<div class="flex items-center gap-2">
<a href="{{ route('books.show', $book) }}" class="btn btn-outline-secondary flex items-center gap-1.5 text-xs py-2 px-3.5 rounded-xl shadow-2xs">
<i class="bi bi-eye"></i>
<span>Lihat Detail</span>
</a>
<a href="{{ route('books.index') }}" class="btn btn-light border flex items-center gap-1.5 text-xs py-2 px-3.5 rounded-xl shadow-2xs">
<i class="bi bi-arrow-left"></i>
<span>Kembali</span>
</a>
</div>
</div>
<!-- Form Card -->
<div class="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 sm:p-8">
<form action="{{ route('books.update', $book) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
@csrf
@method('PUT')
<!-- Baris 1: ISBN & Kategori -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label for="isbn" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Nomor ISBN <span class="text-rose-500">*</span>
</label>
<input type="text"
name="isbn"
id="isbn"
value="{{ old('isbn', $book->isbn) }}"
placeholder="Contoh: 978-602-0001"
class="w-full px-3.5 py-2.5 rounded-xl border @error('isbn') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm font-mono focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('isbn')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="category_id" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Kategori Buku <span class="text-rose-500">*</span>
</label>
<select name="category_id"
id="category_id"
class="w-full px-3.5 py-2.5 rounded-xl border @error('category_id') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
<option value="">Pilih Kategori</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id', $book->category_id) == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
</div>
<!-- Baris 2: Judul Buku -->
<div>
<label for="title" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Judul Lengkap Buku <span class="text-rose-500">*</span>
</label>
<input type="text"
name="title"
id="title"
value="{{ old('title', $book->title) }}"
placeholder="Masukkan judul buku secara lengkap..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('title') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('title')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<!-- Baris 3: Penulis & Penerbit -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label for="author" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Penulis / Pengarang <span class="text-rose-500">*</span>
</label>
<input type="text"
name="author"
id="author"
value="{{ old('author', $book->author) }}"
placeholder="Nama penulis buku..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('author') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('author')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="publisher" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Penerbit <span class="text-rose-500">*</span>
</label>
<input type="text"
name="publisher"
id="publisher"
value="{{ old('publisher', $book->publisher) }}"
placeholder="Nama instansi penerbit..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('publisher') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('publisher')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
</div>
<!-- Baris 4: Tahun Terbit, Lokasi Rak, & Jumlah Eksemplar -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-5">
<div>
<label for="published_year" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Tahun Terbit <span class="text-rose-500">*</span>
</label>
<input type="number"
name="published_year"
id="published_year"
value="{{ old('published_year', $book->published_year) }}"
min="1900"
max="{{ date('Y') + 1 }}"
class="w-full px-3.5 py-2.5 rounded-xl border @error('published_year') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('published_year')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="rack_location" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Lokasi Rak <span class="text-rose-500">*</span>
</label>
<input type="text"
name="rack_location"
id="rack_location"
value="{{ old('rack_location', $book->rack_location) }}"
placeholder="Contoh: RAK-B-03"
class="w-full px-3.5 py-2.5 rounded-xl border @error('rack_location') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
@error('rack_location')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<div>
<label for="total_stock" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Jumlah Eksemplar Total <span class="text-rose-500">*</span>
</label>
<input type="number"
name="total_stock"
id="total_stock"
value="{{ old('total_stock', $book->total_stock) }}"
min="0"
class="w-full px-3.5 py-2.5 rounded-xl border @error('total_stock') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition"
required>
<p class="text-slate-400 text-[11px] mt-1">Stok tersedia saat ini: <strong class="text-slate-700">{{ $book->available_stock }}</strong> eksemplar.</p>
@error('total_stock')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
</div>
<!-- Baris 5: Sampul Cover Buku Saat Ini & Ganti Cover -->
<div>
<label for="cover_image" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Sampul Cover Buku
</label>
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-5 p-4 rounded-xl border border-slate-100 bg-slate-50/50">
<div class="shrink-0 flex items-center gap-3">
<img id="currentCoverImg" src="{{ $book->cover_url }}" alt="{{ $book->title }}" class="w-16 h-22 object-cover rounded-lg border border-slate-200 shadow-xs">
<div class="text-xs text-slate-500">
<div class="font-medium text-slate-700">Sampul Aktif</div>
<div class="text-[11px] text-slate-400">{{ $book->cover_image ? 'Berkas Kustom' : 'Sampul Otomatis' }}</div>
</div>
</div>
<div class="flex-1 w-full border-t sm:border-t-0 sm:border-l border-slate-200/80 sm:pl-5 pt-3 sm:pt-0">
<label for="cover_image" class="block text-xs font-medium text-slate-700 mb-1">Ganti Foto Sampul (Opsional):</label>
<input type="file"
name="cover_image"
id="cover_image"
accept="image/jpeg,image/png,image/webp"
onchange="previewCoverImage(this)"
class="w-full px-3 py-1.5 text-xs rounded-xl border border-slate-200 bg-white text-slate-700 file:mr-3 file:py-1 file:px-3 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 transition cursor-pointer">
<p class="text-slate-400 text-[11px] mt-1">Biarkan kosong jika tidak ingin mengubah sampul saat ini.</p>
</div>
</div>
@error('cover_image')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<!-- Baris 6: Sinopsis / Deskripsi -->
<div>
<label for="description" class="block text-xs font-semibold text-slate-700 uppercase tracking-wider mb-1.5">
Deskripsi / Sinopsis Buku <span class="text-slate-400 font-normal lowercase">(opsional)</span>
</label>
<textarea name="description"
id="description"
rows="4"
placeholder="Tuliskan ringkasan, sinopsis, atau catatan penting mengenai buku ini..."
class="w-full px-3.5 py-2.5 rounded-xl border @error('description') border-rose-300 bg-rose-50/30 text-rose-900 @else border-slate-300 bg-white text-slate-900 @enderror text-sm focus:border-blue-600 focus:ring-2 focus:ring-blue-500/20 outline-none transition">{{ old('description', $book->description) }}</textarea>
@error('description')
<p class="text-rose-500 text-xs mt-1.5 flex items-center gap-1">
<i class="bi bi-exclamation-circle"></i> {{ $message }}
</p>
@enderror
</div>
<!-- Action Buttons -->
<div class="flex items-center justify-end gap-3 pt-4 border-t border-slate-100">
<a href="{{ route('books.index') }}" class="btn btn-light border text-xs py-2.5 px-4 rounded-xl">
Batal
</a>
<button type="submit" class="btn btn-primary shadow-xs text-xs py-2.5 px-6 rounded-xl flex items-center gap-2">
<i class="bi bi-check2"></i>
<span>Simpan Perubahan</span>
</button>
</div>
</form>
</div>
</div>
<script>
function previewCoverImage(input) {
const currentCover = document.getElementById('currentCoverImg');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
currentCover.src = e.target.result;
};
reader.readAsDataURL(input.files[0]);
}
}
</script>
@endsection
Langkah 6.4: Halaman Rincian Detail Buku Fisik (resources/views/books/show.blade.php)
Tujuan Tampilan:
Menampilkan profil menyeluruh dari satu buku fisik tertentu: gambar cover ukuran besar, status ketersediaan eksemplar real-time, nomor identifikasi ISBN, penempatan rak perpustakaan, sinopsis isi, dan tombol interaksi kontekstual (Edit/Hapus bagi pengelola, atau formulir pengajuan reservasi peminjaman bagi anggota).
Poin Kunci Anatomi Blade & Tailwind CSS v4:
Badge Ketersediaan Stok Real-Time: Memberikan umpan balik visual cepat kepada pembaca—badge hijau bila buku siap dipinjam (
$book->available_stock > 0) atau badge abu-abu/merah bila seluruh eksemplar sedang dipinjam.Kartu Metadata Berstruktur Grid: Menyajikan atribut buku (Penerbit, Tahun Terbit, Kategori, Lokasi Rak Fisik) dalam tata letak kartu yang rapi dan konsisten dengan Tailwind CSS.
Penanganan Gambar Cover Fallback: Menggunakan pengecekan
Storage::disk('public')->exists($book->cover_image)agar jika sebuah buku belum memiliki gambar cover, antarmuka tetap tampil elegan dengan placeholder ikon buku bergradien abu-abu lembut tanpa tautan gambar rusak (broken image).Otorisasi Interaksi Anggota vs Pengelola: Memisahkan secara tegas aksi administratif (tombol Edit dan Hapus yang dikawal
@can('books.manage')) dari aksi sirkulasi anggota (tombol pinjam/reservasi buku fisik).
Buat berkas baru di resources/views/books/show.blade.php lalu salin kode lengkap berikut:
@extends('layouts.app')
@section('content')
<div class="max-w-6xl mx-auto space-y-6">
<!-- Breadcrumb & Top Bar -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div class="flex items-center gap-2 text-xs text-slate-500 mb-1">
<a href="{{ route('books.index') }}" class="hover:text-blue-600 transition-colors">Katalog Buku Fisik</a>
<span>/</span>
<span class="text-slate-700 font-medium truncate max-w-xs">{{ $book->title }}</span>
</div>
<h1 class="font-editorial text-2xl sm:text-3xl font-bold text-slate-900 tracking-tight">Rincian Buku Fisik</h1>
<p class="text-xs sm:text-sm text-slate-500 mt-0.5">Informasi koleksi, lokasi rak, panduan peminjaman fisik, dan lokasi perpustakaan.</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<a href="{{ route('books.index') }}" class="btn btn-outline-secondary px-3.5 py-2 text-xs sm:text-sm rounded-xl flex items-center gap-1.5 shadow-2xs">
<i class="bi bi-arrow-left"></i>
<span>Kembali ke Katalog</span>
</a>
@can('books.manage')
<a href="{{ route('books.edit', $book) }}" class="btn btn-primary px-3.5 py-2 text-xs sm:text-sm rounded-xl flex items-center gap-1.5 shadow-xs">
<i class="bi bi-pencil"></i>
<span>Edit Buku</span>
</a>
@endcan
</div>
</div>
@if(session('success'))
<div class="p-4 rounded-2xl bg-emerald-50 border border-emerald-200 text-emerald-900 text-sm flex items-start gap-3 shadow-2xs" data-alert-dismiss>
<i class="bi bi-check-circle-fill text-emerald-600 text-base mt-0.5"></i>
<div class="flex-1 font-medium">{{ session('success') }}</div>
<button type="button" class="text-emerald-500 hover:text-emerald-700" data-alert-dismiss><i class="bi bi-x-lg text-xs"></i></button>
</div>
@endif
@if(session('error'))
<div class="p-4 rounded-2xl bg-rose-50 border border-rose-200 text-rose-900 text-sm flex items-start gap-3 shadow-2xs" data-alert-dismiss>
<i class="bi bi-exclamation-triangle-fill text-rose-600 text-base mt-0.5"></i>
<div class="flex-1 font-medium">{{ session('error') }}</div>
<button type="button" class="text-rose-500 hover:text-rose-700" data-alert-dismiss><i class="bi bi-x-lg text-xs"></i></button>
</div>
@endif
<!-- Main Book Specification Grid -->
<div class="bg-white rounded-3xl border border-slate-200/90 shadow-xs overflow-hidden">
<div class="p-6 sm:p-8">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
<!-- Left: Book Cover & Quick Meta Box (4 Cols) -->
<div class="lg:col-span-4 flex flex-col items-center">
<div class="relative w-48 sm:w-56 aspect-[3/4] rounded-2xl overflow-hidden shadow-xl ring-1 ring-slate-200/70 bg-slate-100 flex items-center justify-center group mb-5">
<img src="{{ $book->cover_url }}"
alt="{{ $book->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
</div>
<!-- Shelf & Stock Card -->
<div class="w-full bg-slate-50/80 rounded-2xl border border-slate-200/80 p-4 space-y-3">
<div class="flex items-center justify-between pb-3 border-b border-slate-200/60 text-xs">
<span class="text-slate-500 font-medium">Ketersediaan Stok</span>
<span class="font-bold tabular-nums {{ $book->available_stock > 0 ? 'text-emerald-600' : 'text-rose-600' }}">
{{ $book->available_stock }} / {{ $book->total_stock }} Eksemplar
</span>
</div>
<div class="flex items-center justify-between pb-3 border-b border-slate-200/60 text-xs">
<span class="text-slate-500 font-medium">Lokasi Rak Fisik</span>
<span class="inline-flex items-center gap-1 font-bold text-rose-600 bg-rose-50 px-2 py-0.5 rounded-md border border-rose-200">
<i class="bi bi-geo-alt-fill text-[11px]"></i>
Rak {{ $book->rack_location }}
</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-slate-500 font-medium">Status Pinjam</span>
@if($book->available_stock > 0)
<span class="inline-flex items-center gap-1 font-semibold text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded-full border border-emerald-200">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
Tersedia
</span>
@else
<span class="inline-flex items-center gap-1 font-semibold text-rose-700 bg-rose-50 px-2 py-0.5 rounded-full border border-rose-200">
<span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
Habis Dipinjam
</span>
@endif
</div>
</div>
</div>
<!-- Right: Details, Borrow CTAs, & Metadata (8 Cols) -->
<div class="lg:col-span-8 space-y-5">
<div>
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-blue-50 text-blue-700 border border-blue-200/60 mb-2.5">
<i class="bi bi-tag-fill text-[10px]"></i>
{{ $book->category?->name ?? 'Umum' }}
</div>
<h2 class="font-editorial text-2xl sm:text-3xl font-bold text-slate-900 leading-snug">
{{ $book->title }}
</h2>
<p class="text-sm text-slate-600 mt-1 flex items-center gap-2">
<span>Ditulis oleh: <strong class="text-slate-800">{{ $book->author }}</strong></span>
<span>•</span>
<span class="text-slate-500">Dibaca {{ number_format($book->views_count) }} kali</span>
</p>
</div>
<!-- Key Metadata Grid -->
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 py-3 border-y border-slate-200/70 text-xs">
<div class="space-y-0.5">
<span class="text-slate-400 block font-medium">Nomor ISBN</span>
<span class="font-bold text-slate-800 tabular-nums">{{ $book->isbn }}</span>
</div>
<div class="space-y-0.5">
<span class="text-slate-400 block font-medium">Penerbit</span>
<span class="font-semibold text-slate-800 truncate block">{{ $book->publisher }}</span>
</div>
<div class="space-y-0.5">
<span class="text-slate-400 block font-medium">Tahun Terbit</span>
<span class="font-bold text-slate-800 tabular-nums">{{ $book->published_year }}</span>
</div>
</div>
<!-- Synopsis -->
<div class="space-y-1.5">
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400">Sinopsis Koleksi</h3>
<p class="text-slate-600 text-sm leading-relaxed whitespace-pre-line">
{{ $book->description ?: 'Belum ada ringkasan atau sinopsis deskriptif untuk buku fisik ini.' }}
</p>
</div>
<!-- Action Buttons Section -->
<div class="pt-3 flex flex-wrap items-center gap-3">
@auth
@php
$userPendingOrActive = class_exists(\App\Models\Loan::class)
? \App\Models\Loan::where('user_id', auth()->id())
->where('book_id', $book->id)
->whereIn('status', ['PENDING', 'BORROWED'])
->first()
: null;
@endphp
@if($userPendingOrActive)
<div class="w-full p-3.5 rounded-2xl bg-amber-50 border border-amber-200 text-amber-900 text-xs sm:text-sm flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<i class="bi bi-info-circle-fill text-amber-600 text-base"></i>
<span>
@if($userPendingOrActive->status === 'PENDING')
Anda telah mengajukan reservasi online untuk buku ini (<strong>{{ $userPendingOrActive->loan_code }}</strong>). Menunggu verifikasi petugas.
@else
Buku ini sedang Anda pinjam secara fisik (Jatuh tempo: {{ $userPendingOrActive->due_date->format('d/m/Y') }}).
@endif
</span>
</div>
<a href="{{ url('/my-loans') }}" class="btn btn-sm btn-outline-secondary shrink-0 text-xs rounded-xl bg-white">
Cek Status Pinjam
</a>
</div>
@elseif($book->available_stock > 0)
@can('loans.view-own')
<form action="{{ url('/books/' . $book->id . '/reserve') }}" method="POST" onsubmit="return confirm('Ajukan peminjaman buku fisik ini secara online? Petugas akan memverifikasi dan menyiapkan buku untuk Anda ambil.');">
@csrf
<button type="submit" class="btn btn-primary px-5 py-2.5 text-xs sm:text-sm font-semibold rounded-xl flex items-center gap-2 shadow-xs">
<i class="bi bi-bookmark-plus"></i>
<span>Ajukan Peminjaman Online (Reservasi)</span>
</button>
</form>
@endcan
@else
<button type="button" class="btn btn-secondary px-5 py-2.5 text-xs sm:text-sm font-semibold rounded-xl cursor-not-allowed opacity-60" disabled>
<i class="bi bi-x-circle me-1"></i> Stok Fisik Sedang Habis
</button>
@endif
@can('loans.manage')
<a href="{{ url('/loans/create?book_id=' . $book->id) }}" class="btn btn-outline-secondary px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-xl flex items-center gap-2">
<i class="bi bi-journal-arrow-up"></i>
<span>Catat Peminjaman Sirkulasi (Petugas)</span>
</a>
@endcan
@else
<div class="flex flex-wrap items-center gap-2.5">
<a href="{{ url('/login') }}" class="btn btn-primary px-5 py-2.5 text-xs sm:text-sm font-semibold rounded-xl flex items-center gap-2 shadow-xs">
<i class="bi bi-box-arrow-in-right"></i>
<span>Masuk untuk Ajukan Pinjam Online</span>
</a>
<a href="{{ url('/register') }}" class="btn btn-outline-secondary px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-xl">
Daftar Anggota Gratis
</a>
</div>
@endauth
<!-- WhatsApp Direct Inquiry -->
@php
$waNumber = preg_replace('/[^0-9]/', '', $siteSetting->whatsapp ?? '6281234567890');
$waMessage = urlencode("Halo Admin {$webName}, saya ingin menanyakan informasi peminjaman buku fisik: '{$book->title}' (ISBN: {$book->isbn}, Lokasi: Rak {$book->rack_location}). Apakah buku ini siap dipinjam di tempat?");
@endphp
<a href="https://wa.me/{{ $waNumber }}?text={{ $waMessage }}"
target="_blank"
rel="noopener noreferrer"
class="btn bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2.5 text-xs sm:text-sm font-semibold rounded-xl flex items-center gap-2 shadow-xs transition-colors">
<i class="bi bi-whatsapp"></i>
<span>Tanya Admin via WhatsApp</span>
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Dual Borrowing Pathway Explanation Card -->
<div class="bg-white rounded-3xl border border-slate-200/90 shadow-xs p-6 sm:p-8 space-y-6">
<div>
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-amber-50 text-amber-800 border border-amber-200/60 mb-2">
<i class="bi bi-info-circle-fill text-[11px]"></i>
Panduan Peminjaman Fisik
</span>
<h3 class="font-editorial text-xl sm:text-2xl font-bold text-slate-900">
Cara Meminjam Buku Fisik Ini di Perpustakaan
</h3>
<p class="text-xs sm:text-sm text-slate-500 mt-1">
Peminjaman buku fisik memerlukan kehadiran Anda di lokasi perpustakaan untuk pengambilan buku. Anda dapat memilih salah satu dari 2 alur peminjaman berikut:
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Pathway 1: In-Person Direct -->
<div class="bg-slate-50/70 rounded-2xl border border-slate-200 p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-blue-600 text-white flex items-center justify-center font-bold text-base shadow-xs shrink-0">
1
</div>
<div>
<h4 class="font-bold text-slate-900 text-sm sm:text-base">Alur 1: Datang Langsung ke Lokasi Fisik</h4>
<span class="text-xs text-blue-600 font-medium">Tanpa Reservasi Online • Langsung di Meja Sirkulasi</span>
</div>
</div>
<ol class="space-y-2.5 text-xs sm:text-sm text-slate-600 list-decimal list-inside pl-1">
<li class="leading-relaxed">
<strong>Kunjungi lokasi fisik perpustakaan</strong> pada alamat yang tertera di peta bawah ini selama jam operasional.
</li>
<li class="leading-relaxed">
Cari buku langsung pada rak koleksi: <span class="inline-block bg-rose-50 text-rose-700 font-bold px-1.5 py-0.5 rounded border border-rose-200 text-xs">Rak {{ $book->rack_location }}</span>.
</li>
<li class="leading-relaxed">
Bawa buku fisik ke <strong>Meja Sirkulasi</strong> dan tanyakan kepada petugas apakah buku dapat dipinjam.
</li>
<li class="leading-relaxed">
Jika belum memiliki akun anggota, Anda <strong>wajib mendaftar mandiri atau didaftarkan langsung oleh petugas</strong> perpustakaan.
</li>
<li class="leading-relaxed">
Petugas memproses transaksi peminjaman fisik Anda dan menyerahkan buku.
</li>
</ol>
</div>
<!-- Pathway 2: Online Reservation -->
<div class="bg-slate-50/70 rounded-2xl border border-slate-200 p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-amber-600 text-white flex items-center justify-center font-bold text-base shadow-xs shrink-0">
2
</div>
<div>
<h4 class="font-bold text-slate-900 text-sm sm:text-base">Alur 2: Reservasi Online (Jemput di Lokasi)</h4>
<span class="text-xs text-amber-700 font-medium">Buku Disiapkan Lebih Awal • Menunggu Approval Petugas</span>
</div>
</div>
<ol class="space-y-2.5 text-xs sm:text-sm text-slate-600 list-decimal list-inside pl-1">
<li class="leading-relaxed">
Pastikan Anda telah <strong>masuk/login</strong> sebagai anggota perpustakaan yang aktif.
</li>
<li class="leading-relaxed">
Klik tombol <strong>"Ajukan Peminjaman Online (Reservasi)"</strong> di halaman ini. Satu eksemplar buku akan langsung ditahan untuk Anda.
</li>
<li class="leading-relaxed">
Status peminjaman berstatus <span class="bg-amber-100 text-amber-900 font-semibold px-1.5 py-0.5 rounded text-xs">PENDING</span> menunggu tinjauan dan persetujuan petugas.
</li>
<li class="leading-relaxed">
Setelah <strong>disetujui (Approved)</strong> oleh petugas/admin, notifikasi status akan berubah di menu <em>Riwayat Peminjaman Saya</em>.
</li>
<li class="leading-relaxed">
Tinggal <strong>datang ke meja sirkulasi</strong> perpustakaan dengan menunjukkan kartu anggota untuk mengambil buku fisik yang telah disiapkan!
</li>
</ol>
</div>
</div>
</div>
<!-- Library Location & Google Maps Card -->
<div class="bg-white rounded-3xl border border-slate-200/90 shadow-xs p-6 sm:p-8 space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-emerald-50 text-emerald-800 border border-emerald-200/60 mb-2">
<i class="bi bi-geo-alt-fill text-[11px]"></i>
Lokasi Fisik Perpustakaan
</span>
<h3 class="font-editorial text-xl sm:text-2xl font-bold text-slate-900">
Alamat & Kontak Meja Sirkulasi
</h3>
<p class="text-xs sm:text-sm text-slate-500 mt-0.5">
Silakan kunjungi lokasi perpustakaan kami untuk pengambilan atau pengembalian buku fisik.
</p>
</div>
<!-- Social Links -->
<div class="flex items-center gap-2">
@if(!empty($siteSetting->whatsapp))
<a href="https://wa.me/{{ $waNumber }}" target="_blank" class="w-9 h-9 rounded-xl bg-emerald-50 text-emerald-600 hover:bg-emerald-600 hover:text-white flex items-center justify-center transition-colors shadow-2xs" title="WhatsApp">
<i class="bi bi-whatsapp"></i>
</a>
@endif
@if(!empty($siteSetting->instagram))
<a href="https://instagram.com/{{ ltrim($siteSetting->instagram, '@') }}" target="_blank" class="w-9 h-9 rounded-xl bg-pink-50 text-pink-600 hover:bg-pink-600 hover:text-white flex items-center justify-center transition-colors shadow-2xs" title="Instagram">
<i class="bi bi-instagram"></i>
</a>
@endif
@if(!empty($siteSetting->phone))
<a href="tel:{{ $siteSetting->phone }}" class="w-9 h-9 rounded-xl bg-blue-50 text-blue-600 hover:bg-blue-600 hover:text-white flex items-center justify-center transition-colors shadow-2xs" title="Telepon">
<i class="bi bi-telephone-fill"></i>
</a>
@endif
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
<!-- Address & Info Details (5 Cols) -->
<div class="lg:col-span-5 bg-slate-50/80 rounded-2xl border border-slate-200 p-5 space-y-4">
<div class="space-y-1">
<span class="text-xs font-bold uppercase tracking-wider text-slate-400">Nama Lembaga / Pustaka</span>
<div class="font-bold text-slate-900 text-sm sm:text-base">{{ $webName }}</div>
</div>
<div class="space-y-1">
<span class="text-xs font-bold uppercase tracking-wider text-slate-400">Alamat Lengkap</span>
<p class="text-xs sm:text-sm text-slate-700 leading-relaxed">
{{ $siteSetting->address ?? 'Gedung Perpustakaan Utama, Jl. Literasi No. 1, Kota Perpustakaan' }}
</p>
</div>
<div class="grid grid-cols-2 gap-3 pt-2 border-t border-slate-200/80 text-xs">
<div>
<span class="text-slate-400 block font-medium">Jam Layanan</span>
<span class="font-semibold text-slate-800">Senin - Sabtu</span>
<span class="text-slate-500 block">08.00 - 16.30 WIB</span>
</div>
<div>
<span class="text-slate-400 block font-medium">Kontak Meja Sirkulasi</span>
<span class="font-semibold text-slate-800 block truncate">{{ $siteSetting->phone ?? '+62 812-3456-7890' }}</span>
<span class="text-slate-500 block truncate">{{ $siteSetting->email ?? 'info@perpustakaan.id' }}</span>
</div>
</div>
<div class="pt-2">
<a href="https://wa.me/{{ $waNumber }}?text={{ $waMessage }}"
target="_blank"
class="btn bg-emerald-600 hover:bg-emerald-700 text-white w-full py-2.5 text-xs font-semibold rounded-xl flex items-center justify-center gap-2 shadow-xs transition-colors">
<i class="bi bi-chat-dots-fill"></i>
<span>Konfirmasi Ketersediaan via WhatsApp</span>
</a>
</div>
</div>
<!-- Google Maps Embed Container (7 Cols) -->
<div class="lg:col-span-7 rounded-2xl overflow-hidden border border-slate-200 shadow-xs bg-slate-100 min-h-[260px] flex items-center justify-center relative">
@if(!empty($siteSetting->maps_iframe))
<div class="w-full h-full min-h-[280px] aspect-video [&>iframe]:w-full [&>iframe]:h-full [&>iframe]:border-0 [&>iframe]:rounded-2xl">
{!! $siteSetting->maps_iframe !!}
</div>
@else
<div class="p-8 text-center space-y-3">
<div class="w-12 h-12 rounded-2xl bg-blue-100 text-blue-600 flex items-center justify-center text-xl mx-auto">
<i class="bi bi-map"></i>
</div>
<div>
<h5 class="font-bold text-slate-800 text-sm">Peta Lokasi Perpustakaan</h5>
<p class="text-xs text-slate-500 max-w-sm mx-auto mt-1">
{{ $siteSetting->address ?? 'Kunjungi alamat perpustakaan kami pada jam operasional untuk melakukan peminjaman buku secara langsung.' }}
</p>
</div>
<a href="https://maps.google.com/?q={{ urlencode($siteSetting->address ?? $webName) }}"
target="_blank"
class="btn btn-outline-secondary btn-sm px-3.5 py-1.5 text-xs rounded-xl inline-flex items-center gap-1.5">
<i class="bi bi-box-arrow-up-right"></i>
<span>Buka di Google Maps</span>
</a>
</div>
@endif
</div>
</div>
</div>
<!-- Active Circulation on this Book (For Librarians/Admins) -->
@can('loans.manage')
<div class="bg-white rounded-3xl border border-slate-200/90 shadow-xs overflow-hidden">
<div class="px-6 py-4 border-b border-slate-200/80 flex items-center justify-between">
<div>
<h3 class="font-editorial text-base sm:text-lg font-bold text-slate-900">Riwayat Peminjaman Buku Ini</h3>
<p class="text-xs text-slate-500">Daftar transaksi peminjaman fisik terakhir untuk katalog ini.</p>
</div>
<span class="chip-stamped chip-stamped-shelf">Khusus Petugas</span>
</div>
<div class="p-0 overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="bg-slate-50/80 text-slate-500 border-b border-slate-200">
<tr>
<th class="py-3 px-5 font-semibold uppercase tracking-wider text-[11px]">Kode Transaksi</th>
<th class="py-3 px-5 font-semibold uppercase tracking-wider text-[11px]">Peminjam</th>
<th class="py-3 px-5 font-semibold uppercase tracking-wider text-[11px]">Tgl Pinjam</th>
<th class="py-3 px-5 font-semibold uppercase tracking-wider text-[11px]">Jatuh Tempo</th>
<th class="py-3 px-5 font-semibold uppercase tracking-wider text-[11px]">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse($book->loans()->latest()->take(5)->get() as $loan)
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="py-3 px-5 font-mono font-bold text-slate-800 tabular-nums">{{ $loan->loan_code }}</td>
<td class="py-3 px-5 font-medium text-slate-900">{{ $loan->user?->name ?? 'Pengguna' }}</td>
<td class="py-3 px-5 text-slate-500 tabular-nums">{{ $loan->loan_date->format('d/m/Y') }}</td>
<td class="py-3 px-5 text-slate-500 tabular-nums">{{ $loan->due_date->format('d/m/Y') }}</td>
<td class="py-3 px-5">
@if($loan->status === 'PENDING')
<span class="chip-stamped bg-amber-50 text-amber-800 border-amber-300">Menunggu Persetujuan</span>
@elseif($loan->status === 'BORROWED')
<span class="chip-stamped chip-stamped-borrowed">Sedang Dipinjam</span>
@elseif($loan->status === 'RETURNED')
<span class="chip-stamped chip-stamped-available">Dikembalikan</span>
@elseif($loan->status === 'REJECTED')
<span class="chip-stamped bg-slate-100 text-slate-600 border-slate-300">Ditolak</span>
@else
<span class="chip-stamped chip-stamped-overdue">Terlambat</span>
@endif
</td>
</tr>
@empty
<tr>
<td colspan="5" class="py-6 text-center text-slate-400">Belum ada riwayat peminjaman untuk buku fisik ini.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endcan
</div>
@endsection
7. Registrasi Rute pada routes/web.php
Deskripsi Singkat:
Daftarkan rute Kategori dan Buku Fisik di dalam grup middleware auth:
use App\Http\Controllers\BookController;
use App\Http\Controllers\CategoryController;
Route::middleware(['auth'])->group(function () {
// 1. Kategori Buku (RBAC Spatie)
Route::get('/categories', [CategoryController::class, 'index'])
->middleware('permission:categories.view')
->name('categories.index');
Route::get('/categories/create', [CategoryController::class, 'create'])
->middleware('permission:categories.manage')
->name('categories.create');
Route::post('/categories', [CategoryController::class, 'store'])
->middleware('permission:categories.manage')
->name('categories.store');
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])
->middleware('permission:categories.manage')
->name('categories.destroy');
// 2. Katalog Buku Fisik
Route::get('/books', [BookController::class, 'index'])
->middleware('permission:books.view')
->name('books.index');
Route::get('/books/create', [BookController::class, 'create'])
->middleware('permission:books.manage')
->name('books.create');
Route::post('/books', [BookController::class, 'store'])
->middleware('permission:books.manage')
->name('books.store');
Route::get('/books/{book}', [BookController::class, 'show'])
->middleware('permission:books.view')
->name('books.show');
Route::get('/books/{book}/edit', [BookController::class, 'edit'])
->middleware('permission:books.manage')
->name('books.edit');
Route::match(['put', 'patch'], '/books/{book}', [BookController::class, 'update'])
->middleware('permission:books.manage')
->name('books.update');
Route::delete('/books/{book}', [BookController::class, 'destroy'])
->middleware('permission:books.manage')
->name('books.destroy');
});
8. Menghubungkan Katalog Riil ke Landing Page (LandingController.php)
Deskripsi Singkat:
Setelah buku tersimpan di database, perbarui LandingController.php agar membaca data riil:
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use App\Models\Category;
use Illuminate\Http\Request;
use Illuminate\View\View;
class LandingController extends Controller
{
public function index(Request $request): View
{
$query = Book::with('category')->latest();
if ($request->filled('category')) {
$query->whereHas('category', function ($q) use ($request) {
$q->where('slug', $request->category);
});
}
if ($request->filled('search')) {
$query->where('title', 'like', '%' . $request->search . '%');
}
$books = $query->paginate(12)->withQueryString();
$categories = Category::withCount('books')->get();
return view('landing', compact('books', 'categories'));
}
}
9. Pengujian Otomatis
A. Menjalankan PHPUnit / Pest Feature Test:
php artisan test tests/Feature/CategoryCrudTest.php
php artisan test tests/Feature/BookCatalogTest.php
B. Menjalankan Pengujian Browser Nyata (Playwright E2E):
npx playwright test tests/e2e/pertemuan-06.spec.ts
10. FAQ & Pemahaman Logika Teknis Arsitektur
Pertanyaan: Mengapa validasi formulir dipisahkan ke class
BookRequestalih-alih ditulis langsung di method Controller?Kunci Jawaban: Menerapkan prinsip Single Responsibility Principle (SRP) pada Clean Architecture. Controller bertugas mengorkestrasi alur aplikasi (menerima input, memanggil model, mengembalikan view/redirect), bukan memvalidasi rincian aturan data. Dengan
FormRequest, aturan validasi dapat digunakan kembali dan controller terbebas dari tumpukan kodevalidate().
Pertanyaan: Apa fungsi method
Rule::unique('books', 'isbn')->ignore($bookId)pada form request update?Kunci Jawaban: Nomor ISBN harus bersifat unik di seluruh katalog. Saat admin mengedit informasi buku (misal hanya mengedit nama penerbit tanpa mengganti nomor ISBN), validasi unik bawaan akan menolak karena ISBN tersebut sudah terdaftar di database. Method
ignore($bookId)memberitahu validator untuk mengecualikan baris ID buku yang sedang diedit agar tidak terjadi false duplicate error.
Pertanyaan: Mengapa file cover buku disimpan di disk
public(storage/app/public/covers/), bukan langsung di folderpublic/covers/?Kunci Jawaban: Melalui abstraksi Filesystem Laravel, konfigurasi driver penyimpanan dapat dipindahkan kapan saja (misalnya ke cloud storage Amazon S3 atau Google Cloud Storage) tanpa mengubah satu baris pun kode di controller. Perintah
php artisan storage:linkmembuat pintasan simbolik (symlink) aman daristorage/app/publickepublic/storage.
Pertanyaan: Bagaimana cara sistem mencegah penumpukan file cover sampah (file leak) saat buku diperbarui atau dihapus?
Kunci Jawaban: Pada method
update()dandestroy(), sistem memeriksa apakah atributcover_imageada dan berkasnya benar-benar ada di storage viaStorage::disk('public')->exists($path). Jika ada, sistem mengeksekusiStorage::disk('public')->delete($path)sebelum menyimpan cover baru atau menghapus record buku dari database.
Pertanyaan: Mengapa rute modul Kategori perlu dipisah menjadi
categories.viewdancategories.manage?Kunci Jawaban: Untuk memisahkan hak baca dan hak modifikasi berdasarkan hak akses Spatie RBAC. Anggota biasa (
user) perlu membaca daftar kategori untuk mencari buku di katalog (categories.view), namun tidak boleh menambah atau menghapus kategori (categories.manage). Jika anggota mencoba mengakses URL/categories/createsecara langsung, middleware Spatie akan memblokirnya dengan respons HTTP 403 Forbidden.
