Logo
© 2019 - 2026 SantriKoding.

On This Page

Mobile navigation

Laravel 13 AI #4 Struktur Aplikasi & UI

like emoticon 0
love emoticon 0
insightful emoticon 0
fire emoticon 0
cheer emoticon 0
celebrate emoticon 0
Laravel 13 AI #4 Struktur Aplikasi & UI

Halo semuanya! 👋

Di Part 4 ini, kita akan membangun fondasi UI yang sangat profesional. Kita akan membuat halaman Riwayat Skripsi dan Halaman Unggah dengan desain Elite Dark yang seragam dengan dashboard utama.


Step 1 - Membangun PdfService (Clean Code)

Sebelum membuat Job, kita buat sebuah Service agar logika ekstraksi PDF bisa digunakan berulang kali dengan rapi.

app/Services/PdfService.php

namespace App\Services;

use Smalot\PdfParser\Parser;

class PdfService
{
    public function extractText(string $filePath): string
    {
        $parser = new Parser();
        $pdf = $parser->parseFile(storage_path('app/private/' . $filePath));
        return trim($pdf->getText());
    }
}

Step 2 - Background Job (Ekstraksi PDF)

Agar proses ekstraksi teks tidak membebani server, kita akan menggunakan Queue Job yang memanggil PdfService.

app/Jobs/ProcessThesisJob.php

namespace App\Jobs;

use App\Models\Thesis;
use App\Models\ThesisSection;
use App\Services\PdfService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ProcessThesisJob implements ShouldQueue
{
    use Queueable;

    public function __construct(public Thesis $thesis) {}

    public function handle(PdfService $pdfService): void
    {
        try {
            $this->thesis->update(['status' => 'processing']);

            $text = $pdfService->extractText($this->thesis->file_path);

            // Simpan chunk teks untuk pencarian nanti
            $chunks = str_split($text, 2000);
            foreach ($chunks as $index => $chunk) {
                $this->thesis->sections()->create([
                    'section_name' => "Bagian " . ($index + 1),
                    'content' => $chunk,
                    'order' => $index,
                ]);
            }

            $this->thesis->update(['status' => 'completed']);
        } catch (\Exception $e) {
            $this->thesis->update(['status' => 'failed']);
        }
    }
}

Step 2 - Controller & Routing

Kita gunakan helper to_route() untuk navigasi yang lebih modern.

app/Http/Controllers/DocumentController.php

use Illuminate\Support\Facades\Storage;

public function index()
{
    $theses = auth()->user()->theses()->latest()->get();
    return Inertia::render('Documents/Index', ['theses' => $theses]);
}

public function create()
{
    return Inertia::render('Documents/Create');
}

public function store(Request $request)
{
    $request->validate([
        'title' => 'required',
        'section' => 'required',
        'file' => 'required|mimes:pdf|max:10000',
    ]);

    $path = $request->file('file')->store('theses');

    $thesis = Thesis::create([
        'user_id' => auth()->id(),
        'title' => $request->title,
        'section' => $request->section,
        'file_path' => $path,
        'status' => 'pending',
    ]);

    ProcessThesisJob::dispatch($thesis);

    return to_route('documents.show', $thesis->id);
}

public function show(Thesis $thesis)
{
    return Inertia::render('Documents/Show', [
        'thesis' => $thesis->load('analyses')
    ]);
}

public function file(Thesis $thesis)
{
    // Pastikan hanya pemilik yang bisa melihat file ini
    if ($thesis->user_id !== auth()->id()) {
        abort(403);
    }

    // Ambil file dari storage privat dan stream ke browser
    return Storage::response($thesis->file_path);
}

[!NOTE] Kita menggunakan route file karena file skripsi disimpan di folder privat (storage/app/private/theses). File di folder ini tidak bisa diakses langsung via URL browser demi keamanan.


Step 3 - UI Premium: Riwayat Skripsi (Index.vue)

Gunakan desain tabel yang bersih dan konsisten dengan halaman Manajemen User.

resources/js/Pages/Documents/Index.vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, Link } from '@inertiajs/vue3';
import { Card } from '@/Components/ui/card';
import { Badge } from '@/Components/ui/badge';
import { Button } from '@/Components/ui/button';
import { FileText, Plus, FileSearch, Eye } from 'lucide-vue-next';

const props = defineProps({
    theses: Array,
});

const getStatusVariant = (status) => {
    switch (status) {
        case 'completed': return 'success';
        case 'processing': return 'warning';
        case 'failed': return 'destructive';
        default: return 'secondary';
    }
};

const getStatusLabel = (status) => {
    switch (status) {
        case 'completed': return 'Selesai';
        case 'processing': return 'Diproses';
        case 'failed': return 'Gagal';
        case 'pending': return 'Menunggu';
        default: return status;
    }
};
</script>

<template>
    <Head title="Riwayat Skripsi" />

    <AuthenticatedLayout>
        <div class="py-8 px-4 md:px-8 max-w-7xl mx-auto space-y-6">
            <!-- Header Section -->
            <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
                <div>
                    <h1 class="text-2xl font-bold text-slate-900 dark:text-white">Riwayat Skripsi</h1>
                    <p class="text-slate-500 dark:text-slate-400 text-sm mt-1">Kelola dan tinjau hasil analisis AI untuk setiap draf Anda.</p>
                </div>
                <Link :href="route('documents.create')">
                    <Button class="gap-2 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-800 dark:bg-indigo-500/10 dark:text-indigo-400 dark:hover:bg-indigo-500/20 border-none shadow-none">
                        <Plus class="h-4 w-4" />
                        Unggah Baru
                    </Button>
                </Link>
            </div>

            <!-- Table Section -->
            <Card class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-xl overflow-hidden">
                <div class="overflow-x-auto">
                    <table class="w-full text-left text-sm">
                        <thead class="bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-300">
                            <tr>
                                <th class="px-6 py-4 font-semibold">Dokumen</th>
                                <th class="px-6 py-4 font-semibold">Tanggal</th>
                                <th class="px-6 py-4 font-semibold">Status</th>
                                <th class="px-6 py-4 font-semibold text-right">Aksi</th>
                            </tr>
                        </thead>
                        <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
                            <tr v-for="thesis in theses" :key="thesis.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-800/50 transition-colors">
                                <td class="px-6 py-4">
                                    <div class="flex items-center gap-3">
                                        <div class="h-10 w-10 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-600 dark:text-slate-400 flex-shrink-0">
                                            <FileText class="h-4 w-4" />
                                        </div>
                                        <div class="flex flex-col max-w-[200px] sm:max-w-xs md:max-w-md">
                                            <span class="font-medium text-slate-900 dark:text-white truncate" :title="thesis.title">{{ thesis.title }}</span>
                                            <span class="text-xs text-slate-500 dark:text-slate-400 uppercase">{{ thesis.section }}</span>
                                        </div>
                                    </div>
                                </td>
                                <td class="px-6 py-4 text-slate-600 dark:text-slate-400 whitespace-nowrap">
                                    {{ new Date(thesis.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' }) }}
                                </td>
                                <td class="px-6 py-4 whitespace-nowrap">
                                    <Badge :variant="getStatusVariant(thesis.status)" class="px-2.5 py-0.5 font-medium uppercase text-[10px]">
                                        {{ getStatusLabel(thesis.status) }}
                                    </Badge>
                                </td>
                                <td class="px-6 py-4 text-right">
                                    <Link :href="route('documents.show', thesis.id)">
                                        <Button variant="ghost" size="icon" class="h-8 w-8 text-slate-500 hover:text-indigo-600">
                                            <Eye class="h-4 w-4" />
                                        </Button>
                                    </Link>
                                </td>
                            </tr>
                            <tr v-if="theses.length === 0">
                                <td colspan="4" class="px-6 py-12 text-center text-slate-500 dark:text-slate-400">
                                    <div class="flex flex-col items-center justify-center space-y-3">
                                        <div class="h-12 w-12 rounded-full bg-slate-100 dark:bg-slate-800 flex items-center justify-center">
                                            <FileSearch class="h-6 w-6 text-slate-400 dark:text-slate-500" />
                                        </div>
                                        <p>Belum ada dokumen.</p>
                                        <Link :href="route('documents.create')">
                                            <Button variant="outline" size="sm" class="mt-2 dark:border-slate-800 dark:hover:bg-slate-800">Unggah Sekarang</Button>
                                        </Link>
                                    </div>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </div>
            </Card>
        </div>
    </AuthenticatedLayout>
</template>

Step 4 - UI Premium: Unggah Skripsi (Create.vue)

Gunakan desain form dalam satu kartu terpusat (single-card layout) yang bersih dan profesional, senada dengan form tambah user.

resources/js/Pages/Documents/Create.vue

<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, useForm, Link } from '@inertiajs/vue3';
import { ref } from 'vue';
import { Card } from '@/Components/ui/card';
import { Input } from '@/Components/ui/input';
import { Textarea } from '@/Components/ui/textarea';
import { Label } from '@/Components/ui/label';
import { Button } from '@/Components/ui/button';
import { 
    Select,
    SelectContent,
    SelectItem,
    SelectTrigger,
    SelectValue,
} from '@/Components/ui/select';
import { ArrowLeft, FileText, CheckCircle2 } from 'lucide-vue-next';

const form = useForm({
    title: '',
    section: 'keseluruhan',
    file: null,
});

const isDragging = ref(false);

const sections = [
    { label: 'Keseluruhan Skripsi', value: 'keseluruhan' },
    { label: 'Bab 1: Pendahuluan', value: 'bab 1' },
    { label: 'Bab 2: Tinjauan Pustaka', value: 'bab 2' },
    { label: 'Bab 3: Metodologi', value: 'bab 3' },
    { label: 'Bab 4: Hasil & Pembahasan', value: 'bab 4' },
    { label: 'Bab 5: Kesimpulan', value: 'bab 5' },
];

const handleDrop = (e) => {
    isDragging.value = false;
    const files = e.dataTransfer.files;
    if (files.length > 0 && files[0].type === 'application/pdf') {
        form.file = files[0];
    }
};

const submit = () => {
    form.post(route('documents.store'));
};
</script>

<template>
    <Head title="Unggah Skripsi" />

    <AuthenticatedLayout>
        <div class="py-8 px-4 md:px-8 max-w-3xl mx-auto space-y-6">
            <div class="flex items-center gap-4">
                <Link :href="route('documents.index')">
                    <Button variant="ghost" size="icon" class="h-8 w-8">
                        <ArrowLeft class="h-4 w-4" />
                    </Button>
                </Link>
                <div>
                    <h1 class="text-2xl font-bold text-slate-900 dark:text-white">Unggah Skripsi</h1>
                    <p class="text-slate-500 dark:text-slate-400 text-sm mt-1">Pilih dokumen PDF skripsi untuk dianalisis oleh AI.</p>
                </div>
            </div>

            <Card class="bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 shadow-sm rounded-xl">
                <form @submit.prevent="submit" class="p-6 md:p-8 space-y-6">
                    <div class="space-y-4">
                        <div class="space-y-2">
                            <Label for="title">Judul Penelitian</Label>
                            <Textarea 
                                id="title" 
                                v-model="form.title" 
                                placeholder="Masukkan judul skripsi atau penelitian" 
                                required 
                                rows="3"
                            />
                            <div v-if="form.errors.title" class="text-xs text-red-600">{{ form.errors.title }}</div>
                        </div>

                        <div class="space-y-2">
                            <Label for="section">Bab yang Ingin Dianalisis</Label>
                            <Select v-model="form.section">
                                <SelectTrigger class="w-full">
                                    <SelectValue placeholder="Pilih bab skripsi" />
                                </SelectTrigger>
                                <SelectContent>
                                    <SelectItem v-for="s in sections" :key="s.value" :value="s.value">
                                        {{ s.label }}
                                    </SelectItem>
                                </SelectContent>
                            </Select>
                            <div v-if="form.errors.section" class="text-xs text-red-600">{{ form.errors.section }}</div>
                        </div>

                        <div class="space-y-2">
                            <Label for="file">Dokumen PDF</Label>
                            <div 
                                @dragover.prevent="isDragging = true"
                                @dragleave.prevent="isDragging = false"
                                @drop.prevent="handleDrop"
                                :class="[
                                    isDragging ? 'border-indigo-400 bg-indigo-50/50 dark:bg-indigo-500/10' : 'border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50',
                                    'group relative border-2 border-dashed rounded-xl p-8 text-center transition-all cursor-pointer hover:border-indigo-300 dark:hover:border-indigo-500 hover:bg-indigo-50/30 dark:hover:bg-indigo-500/5'
                                ]"
                            >
                                <input type="file" id="file" class="hidden" @input="form.file = $event.target.files[0]" accept=".pdf" required />
                                <label for="file" class="cursor-pointer flex flex-col items-center gap-3">
                                    <div class="p-3 bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-slate-100 dark:border-slate-700 group-hover:scale-110 transition-transform">
                                        <FileText v-if="!form.file" class="h-6 w-6 text-slate-400 group-hover:text-indigo-500 transition-colors" />
                                        <CheckCircle2 v-else class="h-6 w-6 text-green-500" />
                                    </div>
                                    <div class="space-y-1">
                                        <span class="text-sm font-medium text-slate-900 dark:text-white block">
                                            {{ form.file ? form.file.name : 'Klik atau tarik file PDF ke sini' }}
                                        </span>
                                        <p v-if="!form.file" class="text-xs text-slate-500 dark:text-slate-400">Maksimal 10MB</p>
                                    </div>
                                </label>
                            </div>
                            <div v-if="form.errors.file" class="text-xs text-red-600">{{ form.errors.file }}</div>
                        </div>
                    </div>

                    <div class="flex justify-end gap-3 pt-4 border-t border-slate-100 dark:border-slate-800">
                        <Link :href="route('documents.index')">
                            <Button type="button" variant="outline" class="dark:border-slate-800 dark:hover:bg-slate-800">Batal</Button>
                        </Link>
                        <Button type="submit" :disabled="form.processing" class="bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-800 dark:bg-indigo-500/10 dark:text-indigo-400 dark:hover:bg-indigo-500/20 shadow-none">
                            <template v-if="form.processing">
                                Menyimpan...
                            </template>
                            <template v-else>
                                Simpan & Analisis
                            </template>
                        </Button>
                    </div>
                </form>
            </Card>
        </div>
    </AuthenticatedLayout>
</template>

Kesimpulan

Aplikasi Anda kini memiliki fondasi UI yang sangat kuat. Pada Part 5, kita akan menyuntikkan "Otak AI" untuk menganalisis dokumen tersebut secara otomatis!

Artikel ini dibaca sebanyak 103 kali

Syahrizal AS
Back End Developer

Suka dengan tulisan di SantriKoding? Kamu bisa memberikan dukungan dengan berdonasi atau bagikan konten ini di sosial media. Terima kasih atas dukungan Anda!

KEBIJAKAN KOMENTAR

Saat memberikan komentar silahkan memberikan informasi lengkap tentang error, seperti: screenshot, link kode, dll. Baca aturan komentar kami

Memuat komentar...

0
0
0
SHARE