First commit
This commit is contained in:
11
resources/css/app.css
Normal file
11
resources/css/app.css
Normal file
@@ -0,0 +1,11 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
1
resources/js/app.js
Normal file
1
resources/js/app.js
Normal file
@@ -0,0 +1 @@
|
||||
import './bootstrap';
|
||||
4
resources/js/bootstrap.js
vendored
Normal file
4
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
100
resources/views/admin/audit-logs/index.blade.php
Normal file
100
resources/views/admin/audit-logs/index.blade.php
Normal file
@@ -0,0 +1,100 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Log Audit')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item active">Log Audit</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h4 class="mb-0 fw-bold">Log Audit Sistem</h4>
|
||||
<small class="text-muted">Log tidak boleh diubah atau dipadam</small>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<select name="event" class="form-select form-select-sm">
|
||||
<option value="">Semua Event</option>
|
||||
@foreach($eventTypes as $event)
|
||||
<option value="{{ $event }}" {{ request('event') == $event ? 'selected' : '' }}>
|
||||
{{ $event }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="date_from" class="form-control form-control-sm"
|
||||
value="{{ request('date_from') }}" placeholder="Dari">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="date" name="date_to" class="form-control form-control-sm"
|
||||
value="{{ request('date_to') }}" placeholder="Hingga">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-search me-1"></i>Tapis
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Event</th>
|
||||
<th>Deskripsi</th>
|
||||
<th>Pengguna</th>
|
||||
<th>IP</th>
|
||||
<th>Tarikh</th>
|
||||
<th class="text-end pe-3">Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($logs as $log)
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<code class="small" style="font-size:.75rem">{{ $log->event }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ $log->description ?? '—' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ $log->user?->name ?? 'Sistem' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ $log->ip_address ?? '—' }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted" title="{{ $log->created_at->format('d/m/Y H:i:s') }}">
|
||||
{{ $log->created_at->diffForHumans() }}
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="{{ route('admin.audit-logs.show', $log) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-5">Tiada log ditemui.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($logs->hasPages())
|
||||
<div class="card-footer bg-white border-top py-3">
|
||||
{{ $logs->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
57
resources/views/admin/audit-logs/show.blade.php
Normal file
57
resources/views/admin/audit-logs/show.blade.php
Normal file
@@ -0,0 +1,57 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Log Audit Detail')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.audit-logs.index') }}">Log Audit</a></li>
|
||||
<li class="breadcrumb-item active">#{{ $auditLog->id }}</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h6 class="mb-0 fw-semibold">Log Audit #{{ $auditLog->id }}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-3 text-muted">Event</dt>
|
||||
<dd class="col-9"><code>{{ $auditLog->event }}</code></dd>
|
||||
|
||||
<dt class="col-3 text-muted">Deskripsi</dt>
|
||||
<dd class="col-9">{{ $auditLog->description ?? '—' }}</dd>
|
||||
|
||||
<dt class="col-3 text-muted">Pengguna</dt>
|
||||
<dd class="col-9">{{ $auditLog->user?->name ?? 'Sistem' }}</dd>
|
||||
|
||||
<dt class="col-3 text-muted">IP</dt>
|
||||
<dd class="col-9">{{ $auditLog->ip_address ?? '—' }}</dd>
|
||||
|
||||
<dt class="col-3 text-muted">Tarikh</dt>
|
||||
<dd class="col-9">{{ $auditLog->created_at->format('d/m/Y H:i:s') }}</dd>
|
||||
|
||||
@if($auditLog->auditable_type)
|
||||
<dt class="col-3 text-muted">Model</dt>
|
||||
<dd class="col-9"><code>{{ class_basename($auditLog->auditable_type) }} #{{ $auditLog->auditable_id }}</code></dd>
|
||||
@endif
|
||||
</dl>
|
||||
|
||||
@if($auditLog->old_values)
|
||||
<div class="mt-3">
|
||||
<label class="text-muted small fw-semibold text-uppercase" style="font-size:.7rem">Nilai Lama</label>
|
||||
<pre class="bg-light rounded p-2 mt-1 small">{{ json_encode($auditLog->old_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($auditLog->new_values)
|
||||
<div class="mt-3">
|
||||
<label class="text-muted small fw-semibold text-uppercase" style="font-size:.7rem">Nilai Baru</label>
|
||||
<pre class="bg-light rounded p-2 mt-1 small">{{ json_encode($auditLog->new_values, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) }}</pre>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
59
resources/views/admin/categories/_form.blade.php
Normal file
59
resources/views/admin/categories/_form.blade.php
Normal file
@@ -0,0 +1,59 @@
|
||||
{{-- Partial form untuk create dan edit kategori --}}
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Nama Kategori <span class="text-danger">*</span></label>
|
||||
<input type="text" name="name" class="form-control @error('name') is-invalid @enderror"
|
||||
value="{{ old('name', $category->name ?? '') }}" required
|
||||
placeholder="Contoh: Pelesenan">
|
||||
@error('name')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Slug</label>
|
||||
<input type="text" name="slug" class="form-control @error('slug') is-invalid @enderror"
|
||||
value="{{ old('slug', $category->slug ?? '') }}"
|
||||
placeholder="Auto-generated jika kosong (contoh: pelesenan)">
|
||||
<div class="form-text">Huruf kecil, angka, dan tanda (-) sahaja. Digunakan untuk URL.</div>
|
||||
@error('slug')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Deskripsi</label>
|
||||
<textarea name="description" class="form-control" rows="2"
|
||||
placeholder="Huraian ringkas tentang kategori ini...">{{ old('description', $category->description ?? '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Warna</label>
|
||||
<div class="input-group">
|
||||
<input type="color" name="color" class="form-control form-control-color"
|
||||
value="{{ old('color', $category->color ?? '#6c757d') }}" style="width:50px">
|
||||
<input type="text" class="form-control" id="colorText"
|
||||
value="{{ old('color', $category->color ?? '#6c757d') }}"
|
||||
placeholder="#6c757d" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Urutan Paparan</label>
|
||||
<input type="number" name="sort_order" class="form-control"
|
||||
value="{{ old('sort_order', $category->sort_order ?? 0) }}" min="0">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||
id="isActive" {{ old('is_active', $category->is_active ?? true) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="isActive">Kategori Aktif</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
$('input[type="color"]').on('input', function() {
|
||||
$('#colorText').val($(this).val());
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
32
resources/views/admin/categories/create.blade.php
Normal file
32
resources/views/admin/categories/create.blade.php
Normal file
@@ -0,0 +1,32 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Tambah Kategori')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.categories.index') }}">Kategori</a></li>
|
||||
<li class="breadcrumb-item active">Tambah</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0 fw-bold"><i class="bi bi-folder-plus me-2"></i>Tambah Kategori Baru</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ route('admin.categories.store') }}">
|
||||
@csrf
|
||||
@include('admin.categories._form')
|
||||
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>Simpan
|
||||
</button>
|
||||
<a href="{{ route('admin.categories.index') }}" class="btn btn-outline-secondary">Batal</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
32
resources/views/admin/categories/edit.blade.php
Normal file
32
resources/views/admin/categories/edit.blade.php
Normal file
@@ -0,0 +1,32 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Edit Kategori')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.categories.index') }}">Kategori</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0 fw-bold"><i class="bi bi-pencil me-2"></i>Edit Kategori: {{ $category->name }}</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ route('admin.categories.update', $category) }}">
|
||||
@csrf @method('PUT')
|
||||
@include('admin.categories._form')
|
||||
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>Kemaskini
|
||||
</button>
|
||||
<a href="{{ route('admin.categories.index') }}" class="btn btn-outline-secondary">Batal</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
101
resources/views/admin/categories/index.blade.php
Normal file
101
resources/views/admin/categories/index.blade.php
Normal file
@@ -0,0 +1,101 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Kategori')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item active">Kategori</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h4 class="mb-0 fw-bold">Pengurusan Kategori</h4>
|
||||
<a href="{{ route('admin.categories.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>Tambah Kategori
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Nama</th>
|
||||
<th>Slug</th>
|
||||
<th>Dokumen</th>
|
||||
<th>Knowledge Items</th>
|
||||
<th>Status</th>
|
||||
<th>Urutan</th>
|
||||
<th class="text-end pe-3">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($categories as $cat)
|
||||
<tr class="{{ $cat->trashed() ? 'opacity-50' : '' }}">
|
||||
<td class="ps-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="d-inline-block rounded-circle"
|
||||
style="width:12px;height:12px;background:{{ $cat->color ?? '#6c757d' }}"></span>
|
||||
<span class="fw-semibold">{{ $cat->name }}</span>
|
||||
</div>
|
||||
@if($cat->description)
|
||||
<small class="text-muted">{{ Str::limit($cat->description, 60) }}</small>
|
||||
@endif
|
||||
</td>
|
||||
<td><code class="small">{{ $cat->slug }}</code></td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ $cat->total_documents ?? 0 }}</span>
|
||||
<small class="text-muted ms-1">({{ $cat->active_documents ?? 0 }} aktif)</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">{{ $cat->total_knowledge_items ?? 0 }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@if($cat->trashed())
|
||||
<span class="badge bg-danger">Dipadam</span>
|
||||
@elseif($cat->is_active)
|
||||
<span class="badge bg-success">Aktif</span>
|
||||
@else
|
||||
<span class="badge bg-secondary">Tidak Aktif</span>
|
||||
@endif
|
||||
</td>
|
||||
<td><small>{{ $cat->sort_order }}</small></td>
|
||||
<td class="text-end pe-3">
|
||||
@unless($cat->trashed())
|
||||
<a href="{{ route('admin.categories.edit', $cat) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('admin.categories.toggle-status', $cat) }}" class="d-inline">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="btn btn-sm {{ $cat->is_active ? 'btn-outline-warning' : 'btn-outline-success' }}"
|
||||
title="{{ $cat->is_active ? 'Nyahaktifkan' : 'Aktifkan' }}">
|
||||
<i class="bi {{ $cat->is_active ? 'bi-toggle-on' : 'bi-toggle-off' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('admin.categories.destroy', $cat) }}" class="d-inline"
|
||||
onsubmit="return confirm('Padam kategori ini? Dokumen aktif tidak boleh dipadam.')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endunless
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-5">
|
||||
Tiada kategori. <a href="{{ route('admin.categories.create') }}">Tambah kategori pertama.</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($categories->hasPages())
|
||||
<div class="card-footer bg-white border-top py-3">
|
||||
{{ $categories->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
164
resources/views/admin/chat-feedback/index.blade.php
Normal file
164
resources/views/admin/chat-feedback/index.blade.php
Normal file
@@ -0,0 +1,164 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Semakan Chat')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item active">Semakan Chat</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h4 class="mb-0 fw-bold">Semakan Log Chat</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ route('admin.chat-feedback.index', ['is_flagged' => 1]) }}"
|
||||
class="btn btn-sm {{ request('is_flagged') ? 'btn-warning' : 'btn-outline-warning' }}">
|
||||
<i class="bi bi-flag me-1"></i>Ditanda
|
||||
</a>
|
||||
<a href="{{ route('admin.chat-feedback.index', ['has_answer' => 0]) }}"
|
||||
class="btn btn-sm {{ request('has_answer') === '0' ? 'btn-secondary' : 'btn-outline-secondary' }}">
|
||||
<i class="bi bi-question-circle me-1"></i>Tidak Terjawab
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<select name="category_id" class="form-select form-select-sm">
|
||||
<option value="">Semua Kategori</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ request('category_id') == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="rating" class="form-select form-select-sm">
|
||||
<option value="">Semua Rating</option>
|
||||
<option value="helpful" {{ request('rating') == 'helpful' ? 'selected' : '' }}>Membantu</option>
|
||||
<option value="partially_helpful" {{ request('rating') == 'partially_helpful' ? 'selected' : '' }}>Separa Membantu</option>
|
||||
<option value="not_helpful" {{ request('rating') == 'not_helpful' ? 'selected' : '' }}>Tidak Membantu</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-search me-1"></i>Tapis
|
||||
</button>
|
||||
@if(request()->hasAny(['category_id', 'rating', 'is_flagged', 'has_answer']))
|
||||
<a href="{{ route('admin.chat-feedback.index') }}" class="btn btn-sm btn-link text-muted">Reset</a>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Soalan</th>
|
||||
<th>Kategori</th>
|
||||
<th>Jawapan</th>
|
||||
<th>Feedback</th>
|
||||
<th>Masa</th>
|
||||
<th class="text-end pe-3">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($logs as $log)
|
||||
<tr class="{{ $log->is_flagged ? 'table-warning' : '' }}">
|
||||
<td class="ps-3" style="max-width:250px">
|
||||
<div class="small fw-semibold text-truncate">{{ $log->question }}</div>
|
||||
@if($log->is_flagged)
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning" style="font-size:.6rem">
|
||||
<i class="bi bi-flag-fill me-1"></i>Ditanda
|
||||
</span>
|
||||
@endif
|
||||
@if(!$log->has_answer)
|
||||
<span class="badge bg-secondary-subtle text-secondary border" style="font-size:.6rem">
|
||||
Tidak Terjawab
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($log->category)
|
||||
<span class="badge" style="background:{{ $log->category->color ?? '#6c757d' }}">
|
||||
{{ $log->category->name }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-muted small">Semua</span>
|
||||
@endif
|
||||
</td>
|
||||
<td style="max-width:200px">
|
||||
<small class="text-muted text-truncate d-block">{{ Str::limit($log->answer, 80) }}</small>
|
||||
@if($log->sources_used)
|
||||
<small class="text-success">
|
||||
<i class="bi bi-book me-1"></i>{{ count($log->sources_used) }} sumber
|
||||
</small>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($log->feedback)
|
||||
@php
|
||||
$ratingMap = [
|
||||
'helpful' => ['bg-success', 'Membantu', 'bi-hand-thumbs-up'],
|
||||
'partially_helpful' => ['bg-warning', 'Separa', 'bi-hand-thumbs-up'],
|
||||
'not_helpful' => ['bg-danger', 'Tidak Membantu', 'bi-hand-thumbs-down'],
|
||||
];
|
||||
$r = $ratingMap[$log->feedback->rating] ?? ['bg-secondary', $log->feedback->rating, 'bi-dash'];
|
||||
@endphp
|
||||
<span class="badge {{ $r[0] }}">
|
||||
<i class="bi {{ $r[2] }} me-1"></i>{{ $r[1] }}
|
||||
</span>
|
||||
@if($log->feedback->converted_to_faq)
|
||||
<br><span class="badge bg-success-subtle text-success border border-success mt-1" style="font-size:.6rem">
|
||||
<i class="bi bi-check2 me-1"></i>Dijadikan FAQ
|
||||
</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-muted small">—</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ $log->created_at->format('d/m/Y H:i') }}</small>
|
||||
@if($log->response_time)
|
||||
<br><small class="text-muted">{{ $log->response_time }}s</small>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="{{ route('admin.chat-feedback.show', $log) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('admin.chat-feedback.toggle-flag', $log) }}" class="d-inline">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="btn btn-sm {{ $log->is_flagged ? 'btn-warning' : 'btn-outline-warning' }}"
|
||||
title="{{ $log->is_flagged ? 'Buang Flag' : 'Tandakan' }}">
|
||||
<i class="bi bi-flag"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-5">
|
||||
<i class="bi bi-chat-dots fs-2 d-block mb-2 opacity-25"></i>
|
||||
Tiada log chat ditemui.
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($logs->hasPages())
|
||||
<div class="card-footer bg-white border-top py-3">
|
||||
{{ $logs->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
161
resources/views/admin/chat-feedback/show.blade.php
Normal file
161
resources/views/admin/chat-feedback/show.blade.php
Normal file
@@ -0,0 +1,161 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Butiran Log Chat')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.chat-feedback.index') }}">Semakan Chat</a></li>
|
||||
<li class="breadcrumb-item active">Log #{{ $chatLog->id }}</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
{{-- Soalan & Jawapan --}}
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0 fw-semibold">Perbualan</h6>
|
||||
<div class="d-flex gap-2">
|
||||
@if($chatLog->category)
|
||||
<span class="badge" style="background:{{ $chatLog->category->color ?? '#6c757d' }}">
|
||||
{{ $chatLog->category->name }}
|
||||
</span>
|
||||
@endif
|
||||
@if(!$chatLog->has_answer)
|
||||
<span class="badge bg-warning">Tidak Terjawab</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small fw-semibold text-uppercase" style="font-size:.7rem">Soalan User</label>
|
||||
<div class="p-3 bg-light rounded mt-1">{{ $chatLog->question }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small fw-semibold text-uppercase" style="font-size:.7rem">Jawapan AI</label>
|
||||
<div class="p-3 bg-white border rounded mt-1" style="white-space:pre-wrap">{{ $chatLog->answer }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Sumber --}}
|
||||
@if($chatLog->sources_used)
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h6 class="mb-0 fw-semibold">Sumber Digunakan ({{ count($chatLog->sources_used) }})</h6>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach($chatLog->sources_used as $i => $source)
|
||||
<div class="list-group-item py-2 px-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="badge {{ $source['type'] === 'pdf' ? 'bg-danger-subtle text-danger' : 'bg-primary-subtle text-primary' }}">
|
||||
<i class="bi {{ $source['type'] === 'pdf' ? 'bi-file-pdf' : 'bi-lightbulb' }} me-1"></i>
|
||||
{{ strtoupper($source['type'] ?? 'unknown') }}
|
||||
</span>
|
||||
<span class="fw-semibold small">{{ $source['title'] }}</span>
|
||||
@if(isset($source['page_number']))
|
||||
<span class="badge bg-light text-dark border small">ms. {{ $source['page_number'] }}</span>
|
||||
@endif
|
||||
<span class="badge bg-light text-dark border small">{{ $source['category'] }}</span>
|
||||
<span class="text-muted small ms-auto">Skor: {{ $source['score'] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Feedback --}}
|
||||
@if($chatLog->feedback)
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom"><h6 class="mb-0 fw-semibold">Feedback Pengguna</h6></div>
|
||||
<div class="card-body">
|
||||
@php
|
||||
$ratingLabels = ['helpful' => 'Membantu', 'not_helpful' => 'Tidak Membantu', 'partially_helpful' => 'Separa Membantu'];
|
||||
@endphp
|
||||
<p><strong>Rating:</strong> {{ $ratingLabels[$chatLog->feedback->rating] ?? $chatLog->feedback->rating }}</p>
|
||||
@if($chatLog->feedback->comment)
|
||||
<p><strong>Komen:</strong> {{ $chatLog->feedback->comment }}</p>
|
||||
@endif
|
||||
@if($chatLog->feedback->correct_answer)
|
||||
<div>
|
||||
<strong>Jawapan Betul (dari user):</strong>
|
||||
<div class="p-2 bg-light rounded mt-1 small">{{ $chatLog->feedback->correct_answer }}</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Convert ke FAQ --}}
|
||||
@if(!($chatLog->feedback && $chatLog->feedback->converted_to_faq))
|
||||
<div class="card border-warning shadow-sm">
|
||||
<div class="card-header bg-warning-subtle border-warning">
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-lightbulb me-2"></i>Convert kepada FAQ Rasmi</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-3">Gunakan soalan ini sebagai asas FAQ baru yang akan dimasukkan ke dalam knowledge base.</p>
|
||||
<form method="POST" action="{{ route('admin.chat-feedback.convert-to-faq', $chatLog) }}">
|
||||
@csrf
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold">Kategori</label>
|
||||
<select name="category_id" class="form-select form-select-sm" required>
|
||||
@foreach(\App\Models\Category::active()->ordered()->get() as $cat)
|
||||
<option value="{{ $cat->id }}" {{ optional($chatLog->category)->id == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-semibold">Soalan (Tajuk FAQ)</label>
|
||||
<input type="text" name="title" class="form-control form-control-sm"
|
||||
value="{{ $chatLog->question }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Jawapan Rasmi</label>
|
||||
<textarea name="content" class="form-control form-control-sm" rows="5" required>{{ $chatLog->feedback?->correct_answer ?? $chatLog->answer }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-warning btn-sm">
|
||||
<i class="bi bi-check-circle me-1"></i>Cipta FAQ dari Log Ini
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="alert alert-success">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
Log ini telah dijadikan FAQ.
|
||||
<a href="{{ route('admin.knowledge-items.show', $chatLog->feedback->convertedFaq) }}">Lihat FAQ</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Meta Info --}}
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom"><h6 class="mb-0 fw-semibold">Maklumat</h6></div>
|
||||
<div class="card-body small">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted">Model AI</dt>
|
||||
<dd class="col-7">{{ $chatLog->model_used ?? '—' }}</dd>
|
||||
<dt class="col-5 text-muted">Masa Respons</dt>
|
||||
<dd class="col-7">{{ $chatLog->response_time ? $chatLog->response_time . 's' : '—' }}</dd>
|
||||
<dt class="col-5 text-muted">Token</dt>
|
||||
<dd class="col-7">{{ $chatLog->tokens_used ? number_format($chatLog->tokens_used) : '—' }}</dd>
|
||||
<dt class="col-5 text-muted">Tarikh</dt>
|
||||
<dd class="col-7">{{ $chatLog->created_at->format('d/m/Y H:i') }}</dd>
|
||||
<dt class="col-5 text-muted">Flag</dt>
|
||||
<dd class="col-7">
|
||||
<form method="POST" action="{{ route('admin.chat-feedback.toggle-flag', $chatLog) }}">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="btn btn-sm {{ $chatLog->is_flagged ? 'btn-warning' : 'btn-outline-warning' }}">
|
||||
<i class="bi bi-flag me-1"></i>{{ $chatLog->is_flagged ? 'Buang Flag' : 'Tandakan' }}
|
||||
</button>
|
||||
</form>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
474
resources/views/admin/chunks/show.blade.php
Normal file
474
resources/views/admin/chunks/show.blade.php
Normal file
@@ -0,0 +1,474 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Chunk #' . ($chunk->chunk_index + 1) . ' — ' . Str::limit($chunk->document->title, 30))
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ route('admin.documents.show', $chunk->document) }}">{{ Str::limit($chunk->document->title, 25) }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ route('admin.documents.chunks', ['document' => $chunk->document, 'version' => $chunk->documentVersion]) }}">
|
||||
Chunks v{{ $chunk->documentVersion->version_number }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active">Chunk #{{ $chunk->chunk_index + 1 }}</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- Flash Messages --}}
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show py-2">
|
||||
<i class="bi bi-check-circle me-1"></i>{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show py-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Header Row --}}
|
||||
<div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
|
||||
<h4 class="mb-0 fw-bold">Chunk #{{ $chunk->chunk_index + 1 }}</h4>
|
||||
<span class="badge {{ $chunk->getStatusBadgeClass() }}">{{ $chunk->getStatusLabel() }}</span>
|
||||
@if($chunk->is_edited)
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary">
|
||||
<i class="bi bi-pencil me-1"></i>Telah Diedit
|
||||
</span>
|
||||
@endif
|
||||
@if($chunk->parent_chunk_id)
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning">
|
||||
<i class="bi bi-scissors me-1"></i>Hasil Split
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ $chunk->document->title }} — v{{ $chunk->documentVersion->version_number }}
|
||||
@if($chunk->page_number) · ms. {{ $chunk->page_number }} @endif
|
||||
@if($chunk->section_heading) · {{ $chunk->section_heading }} @endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{-- Action buttons --}}
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="{{ route('admin.documents.chunks', ['document' => $chunk->document, 'version' => $chunk->documentVersion]) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Senarai Chunk
|
||||
</a>
|
||||
|
||||
@if(!$chunk->isSuperseded())
|
||||
<a href="{{ route('admin.chunks.split', $chunk) }}"
|
||||
class="btn btn-sm btn-warning">
|
||||
<i class="bi bi-scissors me-1"></i>Split Chunk
|
||||
</a>
|
||||
|
||||
@if($chunk->exclude_from_index)
|
||||
<form method="POST" action="{{ route('admin.chunks.include', $chunk) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-success"
|
||||
onclick="return confirm('Kembalikan chunk ini ke indexing?')">
|
||||
<i class="bi bi-check-circle me-1"></i>Include
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<form method="POST" action="{{ route('admin.chunks.exclude', $chunk) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary"
|
||||
onclick="return confirm('Kecualikan chunk ini dari indexing?')">
|
||||
<i class="bi bi-slash-circle me-1"></i>Exclude
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if($chunk->isIndexable())
|
||||
<form method="POST" action="{{ route('admin.chunks.reindex', $chunk) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-outline-info"
|
||||
onclick="return confirm('Trigger reindex untuk chunk ini?')">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Reindex
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Metadata Cards --}}
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-light h-100">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="text-muted mb-0" style="font-size:.7rem">CHUNK INDEX</p>
|
||||
<p class="mb-0 fw-bold">#{{ $chunk->chunk_index + 1 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-light h-100">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="text-muted mb-0" style="font-size:.7rem">QDRANT POINT ID</p>
|
||||
<p class="mb-0 fw-bold" style="font-size:.75rem;word-break:break-all">
|
||||
{{ $chunk->qdrant_point_id ?? '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-light h-100">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="text-muted mb-0" style="font-size:.7rem">LAST EMBEDDED</p>
|
||||
<p class="mb-0 fw-bold" style="font-size:.82rem">
|
||||
{{ $chunk->last_embedded_at?->format('d/m/Y H:i') ?? '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-light h-100">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="text-muted mb-0" style="font-size:.7rem">DIEDIT OLEH</p>
|
||||
<p class="mb-0 fw-bold" style="font-size:.82rem">
|
||||
{{ $chunk->editor?->name ?? '—' }}
|
||||
@if($chunk->edited_at)
|
||||
<br><span class="text-muted fw-normal" style="font-size:.7rem">{{ $chunk->edited_at->format('d/m/Y H:i') }}</span>
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Parent Chunk Info (jika ini adalah hasil split) --}}
|
||||
@if($chunk->parentChunk)
|
||||
<div class="alert alert-warning py-2 mb-3">
|
||||
<i class="bi bi-scissors me-1"></i>
|
||||
Chunk ini adalah hasil split daripada
|
||||
<a href="{{ route('admin.chunks.show', $chunk->parentChunk) }}" class="alert-link">
|
||||
Chunk #{{ $chunk->parentChunk->chunk_index + 1 }}
|
||||
</a>
|
||||
({{ $chunk->parentChunk->getStatusLabel() }}).
|
||||
Urutan dalam split: {{ $chunk->split_order + 1 }} / dalam kumpulan yang sama.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Superseded Warning --}}
|
||||
@if($chunk->isSuperseded())
|
||||
<div class="alert alert-dark py-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Chunk ini telah <strong>digantikan</strong> oleh chunk-chunk baharu hasil split. Ia tidak lagi digunakan dalam Qdrant.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ===================================================================== --}}
|
||||
{{-- TEXT PREVIEW — 3 Panel --}}
|
||||
{{-- ===================================================================== --}}
|
||||
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="textTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {{ !$chunk->final_text && !$chunk->cleaned_text ? 'active' : '' }}"
|
||||
id="raw-tab" data-bs-toggle="tab" data-bs-target="#raw-panel"
|
||||
type="button" role="tab">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>
|
||||
raw_text
|
||||
<span class="badge bg-light text-dark border ms-1" style="font-size:.65rem">
|
||||
{{ number_format(str_word_count($chunk->content)) }} patah
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {{ $chunk->cleaned_text && !$chunk->final_text ? 'active' : '' }}"
|
||||
id="cleaned-tab" data-bs-toggle="tab" data-bs-target="#cleaned-panel"
|
||||
type="button" role="tab">
|
||||
<i class="bi bi-magic me-1"></i>
|
||||
cleaned_text
|
||||
@if(!$chunk->cleaned_text)
|
||||
<span class="badge bg-light text-muted border ms-1" style="font-size:.65rem">null</span>
|
||||
@endif
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {{ $chunk->final_text ? 'active' : '' }}"
|
||||
id="final-tab" data-bs-toggle="tab" data-bs-target="#final-panel"
|
||||
type="button" role="tab">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
final_text
|
||||
@if($chunk->final_text)
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary ms-1" style="font-size:.65rem">
|
||||
Aktif
|
||||
</span>
|
||||
@else
|
||||
<span class="badge bg-light text-muted border ms-1" style="font-size:.65rem">null</span>
|
||||
@endif
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="tab-content" id="textTabsContent">
|
||||
|
||||
{{-- raw_text --}}
|
||||
<div class="tab-pane fade {{ !$chunk->final_text && !$chunk->cleaned_text ? 'show active' : '' }}"
|
||||
id="raw-panel" role="tabpanel">
|
||||
<div class="p-3">
|
||||
<p class="text-muted small mb-2">
|
||||
Teks asal hasil extraction PDF. <strong>Tidak boleh diubah.</strong>
|
||||
Ini adalah source of truth untuk chunk ini.
|
||||
</p>
|
||||
<pre class="mb-0 p-3 bg-light rounded" style="white-space:pre-wrap;max-height:300px;overflow-y:auto;font-family:inherit;font-size:.82rem">{{ $chunk->content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- cleaned_text --}}
|
||||
<div class="tab-pane fade {{ $chunk->cleaned_text && !$chunk->final_text ? 'show active' : '' }}"
|
||||
id="cleaned-panel" role="tabpanel">
|
||||
<div class="p-3">
|
||||
@if($chunk->cleaned_text)
|
||||
<p class="text-muted small mb-2">
|
||||
Teks selepas auto cleanup. Jika final_text tidak ditetapkan, ini digunakan untuk embedding.
|
||||
</p>
|
||||
<pre class="mb-0 p-3 bg-light rounded" style="white-space:pre-wrap;max-height:300px;overflow-y:auto;font-family:inherit;font-size:.82rem">{{ $chunk->cleaned_text }}</pre>
|
||||
@else
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-dash-circle display-6 d-block mb-2"></i>
|
||||
<p class="mb-0">cleaned_text tidak ditetapkan.</p>
|
||||
<p class="small">raw_text (content) digunakan untuk embedding.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- final_text --}}
|
||||
<div class="tab-pane fade {{ $chunk->final_text ? 'show active' : '' }}"
|
||||
id="final-panel" role="tabpanel">
|
||||
<div class="p-3">
|
||||
@if($chunk->final_text)
|
||||
<p class="text-muted small mb-2">
|
||||
Teks yang <strong>sebenarnya dihantar ke Qdrant</strong> untuk embedding.
|
||||
Diedit oleh admin.
|
||||
</p>
|
||||
<pre class="mb-0 p-3 bg-success-subtle rounded" style="white-space:pre-wrap;max-height:300px;overflow-y:auto;font-family:inherit;font-size:.82rem">{{ $chunk->final_text }}</pre>
|
||||
@else
|
||||
<div class="text-center py-4 text-muted">
|
||||
<i class="bi bi-dash-circle display-6 d-block mb-2"></i>
|
||||
<p class="mb-0">final_text tidak ditetapkan.</p>
|
||||
<p class="small">Sistem menggunakan cleaned_text atau raw_text untuk embedding.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ===================================================================== --}}
|
||||
{{-- EDIT FINAL TEXT FORM --}}
|
||||
{{-- ===================================================================== --}}
|
||||
|
||||
@if(!$chunk->isSuperseded())
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-pencil-square me-2"></i>Edit final_text
|
||||
</h6>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Edit teks yang akan dihantar untuk embedding. raw_text tidak akan diubah.
|
||||
Selepas simpan, reindex akan diantrikan secara automatik.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('admin.chunks.update', $chunk) }}" id="editForm">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
|
||||
@error('final_text')
|
||||
<div class="alert alert-danger py-2 mb-3">{{ $message }}</div>
|
||||
@enderror
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="final_text" class="form-label fw-semibold">
|
||||
final_text
|
||||
<small class="text-muted fw-normal">
|
||||
(kosong = guna {{ $chunk->cleaned_text ? 'cleaned_text' : 'raw_text' }})
|
||||
</small>
|
||||
</label>
|
||||
<textarea name="final_text" id="final_text" rows="10"
|
||||
class="form-control font-monospace @error('final_text') is-invalid @enderror"
|
||||
style="font-size:.82rem;line-height:1.5"
|
||||
placeholder="Masukkan final_text yang telah dibersihkan...">{{ old('final_text', $chunk->final_text ?? $chunk->getEmbeddableText()) }}</textarea>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted">Minimum 20 aksara, maksimum 10,000 aksara.</small>
|
||||
<small class="text-muted" id="charCount">0 aksara</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Quick-fill dari raw_text --}}
|
||||
<div class="mb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="btnFillRaw">
|
||||
<i class="bi bi-arrow-down me-1"></i>Isi dari raw_text
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-1" id="btnClearFinal">
|
||||
<i class="bi bi-x me-1"></i>Kosongkan
|
||||
</button>
|
||||
<span class="text-muted ms-2" style="font-size:.75rem">
|
||||
raw_text: {{ number_format(mb_strlen($chunk->content)) }} aksara,
|
||||
{{ number_format(str_word_count($chunk->content)) }} patah
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">Nota perubahan <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<input type="text" name="notes" id="notes" class="form-control"
|
||||
value="{{ old('notes') }}"
|
||||
placeholder="Contoh: Buang header halaman, nombor muka surat...">
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>Simpan & Queue Reindex
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="history.back()">
|
||||
Batal
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ===================================================================== --}}
|
||||
{{-- CHILD CHUNKS (jika chunk ini pernah di-split) --}}
|
||||
{{-- ===================================================================== --}}
|
||||
|
||||
@if($chunk->childChunks->count() > 0)
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-diagram-2 me-2"></i>
|
||||
Chunk Baharu Hasil Split
|
||||
<span class="badge bg-secondary ms-1">{{ $chunk->childChunks->count() }}</span>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@foreach($chunk->childChunks as $child)
|
||||
<div class="d-flex align-items-center gap-3 px-3 py-2 border-bottom">
|
||||
<span class="badge {{ $child->getStatusBadgeClass() }}" style="font-size:.7rem">
|
||||
{{ $child->getStatusLabel() }}
|
||||
</span>
|
||||
<span class="text-muted" style="font-size:.75rem">#{{ $child->chunk_index + 1 }}</span>
|
||||
<p class="mb-0 text-truncate flex-grow-1" style="font-size:.82rem">
|
||||
{{ Str::limit($child->getEmbeddableText(), 100) }}
|
||||
</p>
|
||||
<a href="{{ route('admin.chunks.show', $child) }}"
|
||||
class="btn btn-sm btn-outline-primary flex-shrink-0">
|
||||
Lihat
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- ===================================================================== --}}
|
||||
{{-- AUDIT TRAIL --}}
|
||||
{{-- ===================================================================== --}}
|
||||
|
||||
@if($chunk->audits->count() > 0)
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-clock-history me-2"></i>Audit Trail
|
||||
<span class="badge bg-light text-dark border ms-1">{{ $chunk->audits->count() }} rekod</span>
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@foreach($chunk->audits as $audit)
|
||||
<div class="px-3 py-2 border-bottom">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-1">
|
||||
<span class="badge {{ $audit->getOperationBadgeClass() }}" style="font-size:.7rem">
|
||||
{{ $audit->getOperationLabel() }}
|
||||
</span>
|
||||
@if($audit->old_status && $audit->new_status)
|
||||
<span class="text-muted" style="font-size:.75rem">
|
||||
{{ $audit->old_status ?? '—' }} → {{ $audit->new_status }}
|
||||
</span>
|
||||
@endif
|
||||
<span class="ms-auto text-muted" style="font-size:.72rem">
|
||||
{{ $audit->user?->name ?? 'System' }}
|
||||
· {{ $audit->created_at->format('d/m/Y H:i') }}
|
||||
</span>
|
||||
</div>
|
||||
@if($audit->notes)
|
||||
<p class="mb-0 text-muted" style="font-size:.75rem">
|
||||
<i class="bi bi-chat-left-text me-1"></i>{{ $audit->notes }}
|
||||
</p>
|
||||
@endif
|
||||
@if($audit->operation === \App\Models\ChunkAudit::OP_EDIT_FINAL_TEXT && $audit->old_final_text)
|
||||
<details class="mt-1">
|
||||
<summary class="text-muted" style="font-size:.72rem;cursor:pointer">
|
||||
Lihat perubahan teks
|
||||
</summary>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-6">
|
||||
<p class="text-muted mb-1" style="font-size:.68rem">SEBELUM</p>
|
||||
<pre class="bg-danger-subtle p-2 rounded mb-0" style="font-size:.72rem;white-space:pre-wrap;max-height:100px;overflow-y:auto">{{ Str::limit($audit->old_final_text, 300) }}</pre>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p class="text-muted mb-1" style="font-size:.68rem">SELEPAS</p>
|
||||
<pre class="bg-success-subtle p-2 rounded mb-0" style="font-size:.72rem;white-space:pre-wrap;max-height:100px;overflow-y:auto">{{ Str::limit($audit->new_final_text, 300) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// ── Char counter ──────────────────────────────────────────────────────────────
|
||||
const textarea = document.getElementById('final_text');
|
||||
const charCount = document.getElementById('charCount');
|
||||
|
||||
function updateCount() {
|
||||
if (textarea && charCount) {
|
||||
charCount.textContent = textarea.value.length.toLocaleString() + ' aksara';
|
||||
}
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.addEventListener('input', updateCount);
|
||||
updateCount();
|
||||
}
|
||||
|
||||
// ── Quick-fill dari raw_text ──────────────────────────────────────────────────
|
||||
const rawText = @json($chunk->content);
|
||||
|
||||
document.getElementById('btnFillRaw')?.addEventListener('click', function() {
|
||||
if (textarea.value.trim() !== '' && !confirm('Gantikan teks sedia ada dengan raw_text?')) {
|
||||
return;
|
||||
}
|
||||
textarea.value = rawText;
|
||||
updateCount();
|
||||
textarea.focus();
|
||||
});
|
||||
|
||||
document.getElementById('btnClearFinal')?.addEventListener('click', function() {
|
||||
if (!confirm('Kosongkan final_text? Sistem akan guna raw_text untuk embedding.')) return;
|
||||
textarea.value = '';
|
||||
updateCount();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
434
resources/views/admin/chunks/split.blade.php
Normal file
434
resources/views/admin/chunks/split.blade.php
Normal file
@@ -0,0 +1,434 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Split Chunk #' . ($chunk->chunk_index + 1))
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ route('admin.documents.show', $chunk->document) }}">{{ Str::limit($chunk->document->title, 25) }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ route('admin.documents.chunks', ['document' => $chunk->document, 'version' => $chunk->documentVersion]) }}">
|
||||
Chunks v{{ $chunk->documentVersion->version_number }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ route('admin.chunks.show', $chunk) }}">Chunk #{{ $chunk->chunk_index + 1 }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active">Split</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- Flash / Validation errors --}}
|
||||
@if($errors->any())
|
||||
<div class="alert alert-danger alert-dismissible fade show py-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Ralat:</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
@foreach($errors->all() as $err)
|
||||
<li>{{ $err }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0 fw-bold">
|
||||
<i class="bi bi-scissors me-2 text-warning"></i>Split Chunk
|
||||
</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
Chunk #{{ $chunk->chunk_index + 1 }} —
|
||||
{{ $chunk->document->title }} v{{ $chunk->documentVersion->version_number }}
|
||||
@if($chunk->page_number) · ms. {{ $chunk->page_number }} @endif
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.chunks.show', $chunk) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Kembali
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Warning panel --}}
|
||||
<div class="alert alert-warning py-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<strong>Perhatian:</strong> Selepas split, chunk asal akan ditandakan sebagai
|
||||
<strong>Superseded</strong> dan tidak lagi digunakan dalam Qdrant.
|
||||
Chunk-chunk baharu akan di-embed secara automatik. Tindakan ini <strong>tidak boleh diundo</strong>.
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
|
||||
{{-- ── Kolum kiri: Teks asal (rujukan) ─────────────────────────────── --}}
|
||||
<div class="col-lg-5">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white border-bottom py-2">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>Teks Asal (Rujukan)
|
||||
</h6>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Salin bahagian yang diperlukan ke segmen di sebelah kanan.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{{-- Stats --}}
|
||||
<div class="d-flex gap-3 px-3 py-2 border-bottom bg-light">
|
||||
<span class="text-muted small">
|
||||
<strong id="origWordCount">{{ str_word_count($chunk->getEmbeddableText()) }}</strong> patah
|
||||
</span>
|
||||
<span class="text-muted small">
|
||||
<strong>{{ number_format(mb_strlen($chunk->getEmbeddableText())) }}</strong> aksara
|
||||
</span>
|
||||
@if($chunk->is_edited)
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary" style="font-size:.65rem">
|
||||
final_text
|
||||
</span>
|
||||
@else
|
||||
<span class="badge bg-light text-muted border" style="font-size:.65rem">raw_text</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Teks asal — selectable untuk copy --}}
|
||||
<textarea id="originalText" class="form-control border-0 rounded-0 font-monospace"
|
||||
rows="20" readonly
|
||||
style="font-size:.8rem;line-height:1.5;resize:none;background:#fafafa"
|
||||
>{{ $chunk->getEmbeddableText() }}</textarea>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top py-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary w-100" id="btnAutoSplit">
|
||||
<i class="bi bi-magic me-1"></i>Auto-split mengikut perenggan
|
||||
</button>
|
||||
<p class="text-muted mb-0 mt-1" style="font-size:.7rem">
|
||||
Pecahkan teks mengikut baris kosong berganda (\n\n) sebagai permulaan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Kolum kanan: Form segmen ─────────────────────────────────────── --}}
|
||||
<div class="col-lg-7">
|
||||
<form method="POST" action="{{ route('admin.chunks.do-split', $chunk) }}" id="splitForm">
|
||||
@csrf
|
||||
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom py-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-collection me-1"></i>Segmen Baharu
|
||||
<span class="badge bg-primary ms-1" id="segmentCount">2</span>
|
||||
</h6>
|
||||
<p class="text-muted small mb-0 mt-1">
|
||||
Minimum 2, maksimum 10 segmen. Setiap segmen akan menjadi satu chunk baharu.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="btnAddSegment">
|
||||
<i class="bi bi-plus-lg me-1"></i>Tambah
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" id="btnRemoveSegment">
|
||||
<i class="bi bi-dash-lg me-1"></i>Buang
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-3" id="segmentsContainer">
|
||||
|
||||
{{-- Segmen 1 (default) --}}
|
||||
<div class="segment-item mb-3" data-index="0">
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<label class="form-label fw-semibold mb-0 small">
|
||||
<i class="bi bi-1-circle me-1 text-primary"></i>Segmen 1
|
||||
</label>
|
||||
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
|
||||
</div>
|
||||
<textarea name="segments[]"
|
||||
class="form-control font-monospace segment-textarea @error('segments.0') is-invalid @enderror"
|
||||
rows="6"
|
||||
placeholder="Teks untuk segmen 1..."
|
||||
style="font-size:.8rem;line-height:1.5">{{ old('segments.0') }}</textarea>
|
||||
@error('segments.0')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Segmen 2 (default) --}}
|
||||
<div class="segment-item mb-3" data-index="1">
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<label class="form-label fw-semibold mb-0 small">
|
||||
<i class="bi bi-2-circle me-1 text-primary"></i>Segmen 2
|
||||
</label>
|
||||
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
|
||||
</div>
|
||||
<textarea name="segments[]"
|
||||
class="form-control font-monospace segment-textarea @error('segments.1') is-invalid @enderror"
|
||||
rows="6"
|
||||
placeholder="Teks untuk segmen 2..."
|
||||
style="font-size:.8rem;line-height:1.5">{{ old('segments.1') }}</textarea>
|
||||
@error('segments.1')
|
||||
<div class="invalid-feedback">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Summary bar --}}
|
||||
<div class="card border-0 bg-light mb-3">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex gap-3 flex-wrap align-items-center">
|
||||
<span class="small text-muted">
|
||||
Jumlah patah dalam segmen:
|
||||
<strong id="totalSegmentWords">0</strong>
|
||||
<span class="text-muted">/ {{ str_word_count($chunk->getEmbeddableText()) }}</span>
|
||||
</span>
|
||||
<span class="small text-muted">
|
||||
Jumlah aksara:
|
||||
<strong id="totalSegmentChars">0</strong>
|
||||
</span>
|
||||
<span class="ms-auto small" id="coverageIndicator"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Notes --}}
|
||||
<div class="mb-3">
|
||||
<label for="notes" class="form-label">
|
||||
Sebab split <span class="text-muted fw-normal">(optional)</span>
|
||||
</label>
|
||||
<input type="text" name="notes" id="notes" class="form-control"
|
||||
value="{{ old('notes') }}"
|
||||
placeholder="Contoh: Chunk terlalu panjang, gabungan dua topik berbeza...">
|
||||
</div>
|
||||
|
||||
{{-- Submit --}}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-warning fw-semibold" id="btnSubmit">
|
||||
<i class="bi bi-scissors me-1"></i>
|
||||
Jalankan Split
|
||||
<span class="badge bg-white text-warning ms-1" id="btnSegmentCount">2</span>
|
||||
Chunk Baharu
|
||||
</button>
|
||||
<a href="{{ route('admin.chunks.show', $chunk) }}" class="btn btn-outline-secondary">
|
||||
Batal
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="text-muted small mt-2">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Chunk asal akan ditandakan <strong>Superseded</strong>.
|
||||
Setiap segmen akan di-embed secara automatik dalam queue.
|
||||
</p>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────────
|
||||
const originalText = @json($chunk->getEmbeddableText());
|
||||
const origWordCount = {{ str_word_count($chunk->getEmbeddableText()) }};
|
||||
const MAX_SEGMENTS = 10;
|
||||
const MIN_SEGMENTS = 2;
|
||||
|
||||
// Icon numerals — Bootstrap Icons
|
||||
const icons = ['1-circle','2-circle','3-circle','4-circle','5-circle',
|
||||
'6-circle','7-circle','8-circle','9-circle','10-circle'];
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function countWords(str) {
|
||||
return str.trim() === '' ? 0 : str.trim().split(/\s+/).length;
|
||||
}
|
||||
|
||||
function updateSegmentStats(textarea) {
|
||||
const parent = textarea.closest('.segment-item');
|
||||
const stats = parent.querySelector('.segment-stats');
|
||||
const words = countWords(textarea.value);
|
||||
const chars = textarea.value.length;
|
||||
stats.textContent = words.toLocaleString() + ' patah · ' + chars.toLocaleString() + ' aksara';
|
||||
}
|
||||
|
||||
function updateGlobalStats() {
|
||||
const textareas = document.querySelectorAll('.segment-textarea');
|
||||
let totalWords = 0, totalChars = 0;
|
||||
|
||||
textareas.forEach(ta => {
|
||||
totalWords += countWords(ta.value);
|
||||
totalChars += ta.value.length;
|
||||
});
|
||||
|
||||
document.getElementById('totalSegmentWords').textContent = totalWords.toLocaleString();
|
||||
document.getElementById('totalSegmentChars').textContent = totalChars.toLocaleString();
|
||||
document.getElementById('segmentCount').textContent = textareas.length;
|
||||
document.getElementById('btnSegmentCount').textContent = textareas.length;
|
||||
|
||||
// Coverage indicator
|
||||
const indicator = document.getElementById('coverageIndicator');
|
||||
if (origWordCount > 0) {
|
||||
const pct = Math.round((totalWords / origWordCount) * 100);
|
||||
const cls = pct >= 90 ? 'text-success' : (pct >= 60 ? 'text-warning' : 'text-danger');
|
||||
indicator.innerHTML = `<span class="${cls}"><i class="bi bi-bar-chart me-1"></i>Liputan: ${pct}%</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function relabelAll() {
|
||||
const items = document.querySelectorAll('.segment-item');
|
||||
items.forEach((item, i) => {
|
||||
const label = item.querySelector('label');
|
||||
const iconClass = icons[i] || (i + 1) + '-circle';
|
||||
label.innerHTML = `<i class="bi bi-${iconClass} me-1 text-primary"></i>Segmen ${i + 1}`;
|
||||
item.dataset.index = i;
|
||||
|
||||
// Update error class name (not critical — server-side validates)
|
||||
const ta = item.querySelector('textarea');
|
||||
ta.placeholder = `Teks untuk segmen ${i + 1}...`;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Attach listeners kepada textarea ─────────────────────────────────────
|
||||
function attachListeners(textarea) {
|
||||
textarea.addEventListener('input', function () {
|
||||
updateSegmentStats(this);
|
||||
updateGlobalStats();
|
||||
});
|
||||
// Initial update
|
||||
updateSegmentStats(textarea);
|
||||
}
|
||||
|
||||
// Init existing textareas
|
||||
document.querySelectorAll('.segment-textarea').forEach(attachListeners);
|
||||
updateGlobalStats();
|
||||
|
||||
// ── Add segment ───────────────────────────────────────────────────────────
|
||||
document.getElementById('btnAddSegment').addEventListener('click', function () {
|
||||
const items = document.querySelectorAll('.segment-item');
|
||||
if (items.length >= MAX_SEGMENTS) {
|
||||
alert('Maksimum ' + MAX_SEGMENTS + ' segmen dibenarkan.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = items.length;
|
||||
const iconClass = icons[newIndex] || (newIndex + 1) + '-circle';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'segment-item mb-3';
|
||||
div.dataset.index = newIndex;
|
||||
div.innerHTML = `
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<label class="form-label fw-semibold mb-0 small">
|
||||
<i class="bi bi-${iconClass} me-1 text-primary"></i>Segmen ${newIndex + 1}
|
||||
</label>
|
||||
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
|
||||
</div>
|
||||
<textarea name="segments[]"
|
||||
class="form-control font-monospace segment-textarea"
|
||||
rows="6"
|
||||
placeholder="Teks untuk segmen ${newIndex + 1}..."
|
||||
style="font-size:.8rem;line-height:1.5"></textarea>
|
||||
`;
|
||||
|
||||
document.getElementById('segmentsContainer').appendChild(div);
|
||||
attachListeners(div.querySelector('textarea'));
|
||||
updateGlobalStats();
|
||||
div.querySelector('textarea').focus();
|
||||
});
|
||||
|
||||
// ── Remove last segment ───────────────────────────────────────────────────
|
||||
document.getElementById('btnRemoveSegment').addEventListener('click', function () {
|
||||
const items = document.querySelectorAll('.segment-item');
|
||||
if (items.length <= MIN_SEGMENTS) {
|
||||
alert('Minimum ' + MIN_SEGMENTS + ' segmen diperlukan.');
|
||||
return;
|
||||
}
|
||||
if (!confirm('Buang segmen terakhir?')) return;
|
||||
items[items.length - 1].remove();
|
||||
relabelAll();
|
||||
updateGlobalStats();
|
||||
});
|
||||
|
||||
// ── Auto-split mengikut perenggan ─────────────────────────────────────────
|
||||
document.getElementById('btnAutoSplit').addEventListener('click', function () {
|
||||
// Pecahkan teks asal mengikut baris kosong berganda
|
||||
const paragraphs = originalText
|
||||
.split(/\n\s*\n/)
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length >= 20);
|
||||
|
||||
if (paragraphs.length < 2) {
|
||||
alert('Teks asal tidak mempunyai cukup perenggan untuk auto-split (keperluan: sekurang-kurangnya 2 perenggan dengan 20+ aksara).');
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAllowed = Math.min(paragraphs.length, MAX_SEGMENTS);
|
||||
const msg = `Auto-split akan menghasilkan ${maxAllowed} segmen berdasarkan perenggan. Ini akan menggantikan semua segmen semasa. Teruskan?`;
|
||||
if (!confirm(msg)) return;
|
||||
|
||||
// Buang semua segmen sedia ada
|
||||
document.getElementById('segmentsContainer').innerHTML = '';
|
||||
|
||||
// Cipta segmen baru
|
||||
paragraphs.slice(0, MAX_SEGMENTS).forEach((para, i) => {
|
||||
const iconClass = icons[i] || (i + 1) + '-circle';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'segment-item mb-3';
|
||||
div.dataset.index = i;
|
||||
div.innerHTML = `
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<label class="form-label fw-semibold mb-0 small">
|
||||
<i class="bi bi-${iconClass} me-1 text-primary"></i>Segmen ${i + 1}
|
||||
</label>
|
||||
<span class="text-muted segment-stats" style="font-size:.72rem">0 patah · 0 aksara</span>
|
||||
</div>
|
||||
<textarea name="segments[]"
|
||||
class="form-control font-monospace segment-textarea"
|
||||
rows="5"
|
||||
placeholder="Teks untuk segmen ${i + 1}..."
|
||||
style="font-size:.8rem;line-height:1.5"></textarea>
|
||||
`;
|
||||
document.getElementById('segmentsContainer').appendChild(div);
|
||||
div.querySelector('textarea').value = para;
|
||||
attachListeners(div.querySelector('textarea'));
|
||||
updateSegmentStats(div.querySelector('textarea'));
|
||||
});
|
||||
|
||||
updateGlobalStats();
|
||||
});
|
||||
|
||||
// ── Confirm on submit ────────────────────────────────────────────────────
|
||||
document.getElementById('splitForm').addEventListener('submit', function (e) {
|
||||
const items = document.querySelectorAll('.segment-item');
|
||||
const count = items.length;
|
||||
|
||||
// Semak semua segmen tidak kosong
|
||||
let allFilled = true;
|
||||
items.forEach(item => {
|
||||
if (item.querySelector('textarea').value.trim() === '') {
|
||||
allFilled = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!allFilled) {
|
||||
e.preventDefault();
|
||||
alert('Semua segmen mesti diisi sebelum split boleh dijalankan.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(
|
||||
`Jalankan split chunk #{{ $chunk->chunk_index + 1 }} kepada ${count} chunk baharu?\n\n` +
|
||||
`Chunk asal akan ditandakan Superseded dan tidak lagi aktif dalam Qdrant.\n` +
|
||||
`Tindakan ini tidak boleh diundo.`
|
||||
)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
221
resources/views/admin/dashboard.blade.php
Normal file
221
resources/views/admin/dashboard.blade.php
Normal file
@@ -0,0 +1,221 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Dashboard')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item active">Dashboard</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h4 class="mb-0 fw-bold">Dashboard</h4>
|
||||
<span class="text-muted small">{{ now()->format('d M Y, H:i') }}</span>
|
||||
</div>
|
||||
|
||||
{{-- Status Perkhidmatan --}}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span class="small fw-semibold text-muted">Ollama</span>
|
||||
@if($health['ollama']['online'])
|
||||
<span class="badge bg-success"><i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>Online</span>
|
||||
@else
|
||||
<span class="badge bg-danger"><i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>Offline</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($health['ollama']['online'])
|
||||
<div class="d-flex gap-2 mt-1">
|
||||
<span class="badge {{ $health['ollama']['chat_model'] ? 'bg-success-subtle text-success' : 'bg-warning-subtle text-warning' }} small">
|
||||
Chat: {{ config('ollama.chat_model') }}
|
||||
</span>
|
||||
<span class="badge {{ $health['ollama']['embed_model'] ? 'bg-success-subtle text-success' : 'bg-warning-subtle text-warning' }} small">
|
||||
Embed: {{ config('ollama.embedding_model') }}
|
||||
</span>
|
||||
</div>
|
||||
@elseif($health['ollama']['error'])
|
||||
<small class="text-danger">{{ $health['ollama']['error'] }}</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span class="small fw-semibold text-muted">Qdrant</span>
|
||||
@if($health['qdrant']['online'])
|
||||
<span class="badge bg-success"><i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>Online</span>
|
||||
@else
|
||||
<span class="badge bg-danger"><i class="bi bi-circle-fill me-1" style="font-size:.5rem"></i>Offline</span>
|
||||
@endif
|
||||
</div>
|
||||
@if($health['qdrant']['online'])
|
||||
<div class="mt-1">
|
||||
<span class="badge bg-info-subtle text-info small">
|
||||
{{ number_format($health['qdrant']['points_count'] ?? 0) }} vectors
|
||||
</span>
|
||||
</div>
|
||||
@elseif($health['qdrant']['error'])
|
||||
<small class="text-danger">{{ $health['qdrant']['error'] }}</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<div class="text-muted small">Dokumen Aktif</div>
|
||||
<div class="fs-3 fw-bold text-primary">{{ $stats['active_documents'] }}</div>
|
||||
<div class="text-muted small">dari {{ $stats['total_documents'] }} dokumen</div>
|
||||
</div>
|
||||
<i class="bi bi-file-pdf fs-1 text-primary opacity-25"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<div class="text-muted small">FAQ & Pengetahuan</div>
|
||||
<div class="fs-3 fw-bold text-success">{{ $stats['total_knowledge_items'] }}</div>
|
||||
<div class="text-muted small">item aktif</div>
|
||||
</div>
|
||||
<i class="bi bi-lightbulb fs-1 text-success opacity-25"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<div class="text-muted small">Chat Hari Ini</div>
|
||||
<div class="fs-3 fw-bold text-info">{{ $stats['total_chats_today'] }}</div>
|
||||
<div class="text-muted small">pertanyaan</div>
|
||||
</div>
|
||||
<i class="bi bi-chat-dots fs-1 text-info opacity-25"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-xl-3">
|
||||
<div class="card border-0 shadow-sm {{ $stats['flagged_chats'] > 0 ? 'border-warning' : '' }}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<div class="text-muted small">Perlu Semakan</div>
|
||||
<div class="fs-3 fw-bold {{ $stats['flagged_chats'] > 0 ? 'text-warning' : 'text-muted' }}">
|
||||
{{ $stats['flagged_chats'] }}
|
||||
</div>
|
||||
<div class="text-muted small">log ditanda</div>
|
||||
</div>
|
||||
<i class="bi bi-flag fs-1 text-warning opacity-25"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Processing Status & Recent Activity --}}
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h6 class="mb-0 fw-semibold">Status Pemprosesan</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if($stats['processing_documents'] > 0)
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="small">Sedang diproses</span>
|
||||
<span class="badge bg-warning">{{ $stats['processing_documents'] }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if($stats['failed_documents'] > 0)
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="small text-danger">Gagal diproses</span>
|
||||
<a href="{{ route('admin.documents.index', ['status' => 'failed']) }}"
|
||||
class="badge bg-danger text-decoration-none">{{ $stats['failed_documents'] }}</a>
|
||||
</div>
|
||||
@endif
|
||||
@if($stats['unanswered_chats'] > 0)
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<span class="small">Soalan tidak terjawab</span>
|
||||
<a href="{{ route('admin.chat-feedback.index', ['has_answer' => 0]) }}"
|
||||
class="badge bg-secondary text-decoration-none">{{ $stats['unanswered_chats'] }}</a>
|
||||
</div>
|
||||
@endif
|
||||
@if($stats['processing_documents'] == 0 && $stats['failed_documents'] == 0)
|
||||
<p class="text-muted small mb-0"><i class="bi bi-check-circle text-success me-1"></i>Semua dokumen diproses.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mt-3">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h6 class="mb-0 fw-semibold">Chat Terkini</h6>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush">
|
||||
@forelse($recentChats as $chat)
|
||||
<li class="list-group-item py-2 px-3">
|
||||
<div class="text-truncate small fw-semibold">{{ $chat->question }}</div>
|
||||
<div class="d-flex align-items-center gap-2 mt-1">
|
||||
@if($chat->category)
|
||||
<span class="badge bg-light text-dark border" style="font-size:.65rem">{{ $chat->category->name }}</span>
|
||||
@endif
|
||||
@if(!$chat->has_answer)
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning" style="font-size:.65rem">Tidak Terjawab</span>
|
||||
@endif
|
||||
<span class="text-muted ms-auto" style="font-size:.65rem">{{ $chat->created_at->diffForHumans() }}</span>
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="list-group-item py-3 text-center text-muted small">Tiada chat lagi.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0 fw-semibold">Aktiviti Terkini</h6>
|
||||
<a href="{{ route('admin.audit-logs.index') }}" class="btn btn-sm btn-link p-0 text-muted">
|
||||
Lihat semua
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<tbody>
|
||||
@forelse($recentActivity as $log)
|
||||
<tr>
|
||||
<td class="ps-3 py-2">
|
||||
<div class="small fw-semibold">{{ $log->description ?? $log->event }}</div>
|
||||
@if($log->user)
|
||||
<small class="text-muted">{{ $log->user->name }}</small>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-end pe-3 py-2 text-muted small" style="white-space:nowrap">
|
||||
{{ $log->created_at->diffForHumans() }}
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="2" class="text-center text-muted py-4 small">Tiada aktiviti.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
253
resources/views/admin/documents/chunks.blade.php
Normal file
253
resources/views/admin/documents/chunks.blade.php
Normal file
@@ -0,0 +1,253 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Chunk Review — ' . Str::limit($document->title, 40))
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.show', $document) }}">{{ Str::limit($document->title, 30) }}</a></li>
|
||||
<li class="breadcrumb-item active">Chunk Review v{{ $version->version_number }}</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="d-flex align-items-start justify-content-between mb-3 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0 fw-bold">Chunk Review</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ $document->title }} — Versi {{ $version->version_number }}
|
||||
@if($version->page_count)
|
||||
· {{ $version->page_count }} muka surat
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.documents.show', $document) }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Kembali ke Dokumen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Flash Messages --}}
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show py-2" role="alert">
|
||||
<i class="bi bi-check-circle me-1"></i>{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Status Summary Pills --}}
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<a href="{{ route('admin.documents.chunks', ['document' => $document, 'version' => $version]) }}"
|
||||
class="btn btn-sm {{ !$statusFilter ? 'btn-dark' : 'btn-outline-dark' }}">
|
||||
Semua <span class="badge bg-secondary ms-1">{{ array_sum($statusCounts) }}</span>
|
||||
</a>
|
||||
|
||||
@php
|
||||
$statusDef = [
|
||||
\App\Models\DocumentChunk::STATUS_INDEXED => ['label' => 'Diindex', 'class' => 'btn-success'],
|
||||
\App\Models\DocumentChunk::STATUS_NEEDS_REINDEX => ['label' => 'Perlu Reindex', 'class' => 'btn-warning'],
|
||||
\App\Models\DocumentChunk::STATUS_NEEDS_REVIEW => ['label' => 'Perlu Semak', 'class' => 'btn-info'],
|
||||
\App\Models\DocumentChunk::STATUS_PENDING => ['label' => 'Menunggu', 'class' => 'btn-light border'],
|
||||
\App\Models\DocumentChunk::STATUS_EXCLUDED => ['label' => 'Dikecualikan', 'class' => 'btn-secondary'],
|
||||
\App\Models\DocumentChunk::STATUS_SUPERSEDED => ['label' => 'Digantikan', 'class' => 'btn-dark'],
|
||||
\App\Models\DocumentChunk::STATUS_FAILED_EMBEDDING => ['label' => 'Gagal Embed', 'class' => 'btn-danger'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
@foreach($statusDef as $statusKey => $def)
|
||||
@if(isset($statusCounts[$statusKey]) && $statusCounts[$statusKey] > 0)
|
||||
<a href="{{ route('admin.documents.chunks', ['document' => $document, 'version' => $version, 'status' => $statusKey]) }}"
|
||||
class="btn btn-sm {{ $statusFilter === $statusKey ? $def['class'] : 'btn-outline-' . str_replace('btn-', '', $def['class']) }}">
|
||||
{{ $def['label'] }}
|
||||
<span class="badge bg-white text-dark ms-1">{{ $statusCounts[$statusKey] }}</span>
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Chunk List --}}
|
||||
@forelse($chunks as $chunk)
|
||||
<div class="card border-0 shadow-sm mb-2 {{ $chunk->isSuperseded() ? 'opacity-50' : '' }}">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="row align-items-center g-2">
|
||||
|
||||
{{-- Metadata --}}
|
||||
<div class="col-12 col-md-7">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
{{-- Status badge --}}
|
||||
<span class="badge {{ $chunk->getStatusBadgeClass() }}" style="font-size:.7rem">
|
||||
{{ $chunk->getStatusLabel() }}
|
||||
</span>
|
||||
|
||||
{{-- Chunk number --}}
|
||||
<span class="badge bg-light text-dark border fw-normal" style="font-size:.7rem">
|
||||
#{{ $chunk->chunk_index + 1 }}
|
||||
</span>
|
||||
|
||||
{{-- Edited badge --}}
|
||||
@if($chunk->is_edited)
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary" style="font-size:.65rem">
|
||||
<i class="bi bi-pencil me-1"></i>Edited
|
||||
</span>
|
||||
@endif
|
||||
|
||||
{{-- Split badge --}}
|
||||
@if($chunk->parent_chunk_id)
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning" style="font-size:.65rem">
|
||||
<i class="bi bi-scissors me-1"></i>Split
|
||||
</span>
|
||||
@endif
|
||||
|
||||
@if($chunk->childChunks->count() > 0)
|
||||
<span class="badge bg-dark-subtle text-dark border" style="font-size:.65rem">
|
||||
<i class="bi bi-diagram-2 me-1"></i>{{ $chunk->childChunks->count() }} anak
|
||||
</span>
|
||||
@endif
|
||||
|
||||
{{-- Metadata kecil --}}
|
||||
@if($chunk->page_number)
|
||||
<span class="text-muted" style="font-size:.75rem">ms.{{ $chunk->page_number }}</span>
|
||||
@endif
|
||||
|
||||
@if($chunk->section_heading)
|
||||
<span class="text-muted text-truncate" style="font-size:.75rem;max-width:200px"
|
||||
title="{{ $chunk->section_heading }}">
|
||||
{{ Str::limit($chunk->section_heading, 40) }}
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Preview teks --}}
|
||||
<p class="text-muted mb-0 mt-1" style="font-size:.8rem;line-height:1.4">
|
||||
{{ Str::limit($chunk->getEmbeddableText(), 150) }}
|
||||
</p>
|
||||
|
||||
{{-- Editor info --}}
|
||||
@if($chunk->is_edited && $chunk->editor)
|
||||
<p class="mb-0 mt-1" style="font-size:.7rem;color:#aaa">
|
||||
Diedit oleh {{ $chunk->editor->name }}
|
||||
{{ $chunk->edited_at?->diffForHumans() }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Actions --}}
|
||||
<div class="col-12 col-md-5">
|
||||
<div class="d-flex gap-1 flex-wrap justify-content-md-end">
|
||||
|
||||
{{-- View detail --}}
|
||||
<a href="{{ route('admin.chunks.show', $chunk) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i> Detail
|
||||
</a>
|
||||
|
||||
@if(!$chunk->isSuperseded())
|
||||
|
||||
{{-- Split --}}
|
||||
<a href="{{ route('admin.chunks.split', $chunk) }}"
|
||||
class="btn btn-sm btn-outline-warning"
|
||||
title="Split chunk ini">
|
||||
<i class="bi bi-scissors"></i>
|
||||
</a>
|
||||
|
||||
{{-- Exclude / Include --}}
|
||||
@if($chunk->exclude_from_index)
|
||||
<form method="POST" action="{{ route('admin.chunks.include', $chunk) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-outline-success"
|
||||
title="Kembalikan ke indexing"
|
||||
onclick="return confirm('Kembalikan chunk ini ke indexing?')">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<form method="POST" action="{{ route('admin.chunks.exclude', $chunk) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary"
|
||||
title="Kecualikan dari indexing"
|
||||
onclick="return confirm('Kecualikan chunk #{{ $chunk->chunk_index + 1 }} dari indexing?')">
|
||||
<i class="bi bi-slash-circle"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- Reindex --}}
|
||||
@if($chunk->isIndexable())
|
||||
<form method="POST" action="{{ route('admin.chunks.reindex', $chunk) }}" class="d-inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-outline-info"
|
||||
title="Trigger reindex"
|
||||
onclick="return confirm('Reindex chunk #{{ $chunk->chunk_index + 1 }} sekarang?')">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@else
|
||||
{{-- Superseded — tunjukkan link ke children --}}
|
||||
@if($chunk->childChunks->count() > 0)
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-arrow-down-right me-1"></i>
|
||||
{{ $chunk->childChunks->count() }} chunk baharu
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Child chunks inline (jika chunk ini pernah di-split) --}}
|
||||
@if($chunk->childChunks->count() > 0)
|
||||
<div class="mt-2 ps-3 border-start border-2 border-warning">
|
||||
<p class="text-muted mb-1" style="font-size:.72rem">
|
||||
<i class="bi bi-diagram-2 me-1"></i>Dipecahkan kepada:
|
||||
</p>
|
||||
@foreach($chunk->childChunks as $child)
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="badge {{ $child->getStatusBadgeClass() }}" style="font-size:.65rem">
|
||||
{{ $child->getStatusLabel() }}
|
||||
</span>
|
||||
<span class="text-muted" style="font-size:.75rem">#{{ $child->chunk_index + 1 }}</span>
|
||||
<span class="text-muted text-truncate" style="font-size:.75rem">
|
||||
{{ Str::limit($child->getEmbeddableText(), 80) }}
|
||||
</span>
|
||||
<a href="{{ route('admin.chunks.show', $child) }}"
|
||||
class="btn btn-sm btn-link p-0 ms-auto" style="font-size:.72rem">
|
||||
Lihat →
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-collection display-6 d-block mb-2"></i>
|
||||
@if($statusFilter)
|
||||
Tiada chunk dengan status "{{ $statusFilter }}".
|
||||
<a href="{{ route('admin.documents.chunks', ['document' => $document, 'version' => $version]) }}">
|
||||
Lihat semua
|
||||
</a>
|
||||
@else
|
||||
Tiada chunk untuk versi ini.
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($chunks->hasPages())
|
||||
<div class="mt-3">
|
||||
{{ $chunks->links() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@endsection
|
||||
180
resources/views/admin/documents/create.blade.php
Normal file
180
resources/views/admin/documents/create.blade.php
Normal file
@@ -0,0 +1,180 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Upload Dokumen')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
|
||||
<li class="breadcrumb-item active">Upload Baru</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0 fw-bold"><i class="bi bi-upload me-2"></i>Upload Dokumen PDF</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ route('admin.documents.store') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Kategori <span class="text-danger">*</span></label>
|
||||
<select name="category_id" class="form-select @error('category_id') is-invalid @enderror" required>
|
||||
<option value="">— Pilih Kategori —</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ old('category_id') == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('category_id')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Tajuk Dokumen <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control @error('title') is-invalid @enderror"
|
||||
value="{{ old('title') }}" placeholder="Contoh: Garis Panduan Pelesenan Perniagaan 2024" required>
|
||||
@error('title')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Fail PDF <span class="text-danger">*</span></label>
|
||||
<input type="file" name="file" id="fileInput"
|
||||
class="form-control @error('file') is-invalid @enderror"
|
||||
accept=".pdf" required>
|
||||
<div class="form-text">
|
||||
Hanya fail PDF dibenarkan. Saiz maksimum:
|
||||
{{ round(config('knowledgebase.upload.max_file_size', 20480) / 1024) }}MB
|
||||
</div>
|
||||
@error('file')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
<div id="filePreview" class="mt-2 d-none">
|
||||
<div class="alert alert-info py-2 mb-0 small">
|
||||
<i class="bi bi-file-pdf me-1"></i>
|
||||
<span id="fileName"></span> —
|
||||
<span id="fileSize"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Deskripsi</label>
|
||||
<textarea name="description" class="form-control" rows="2"
|
||||
placeholder="Deskripsi ringkas dokumen ini...">{{ old('description') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tarikh Kuat Kuasa</label>
|
||||
<input type="date" name="effective_date" class="form-control @error('effective_date') is-invalid @enderror"
|
||||
value="{{ old('effective_date') }}">
|
||||
@error('effective_date')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tarikh Luput</label>
|
||||
<input type="date" name="expiry_date" class="form-control @error('expiry_date') is-invalid @enderror"
|
||||
value="{{ old('expiry_date') }}">
|
||||
@error('expiry_date')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Bahasa</label>
|
||||
<select name="language" class="form-select">
|
||||
<option value="ms" {{ old('language', 'ms') == 'ms' ? 'selected' : '' }}>Bahasa Melayu</option>
|
||||
<option value="en" {{ old('language') == 'en' ? 'selected' : '' }}>English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tag</label>
|
||||
<input type="text" id="tagsInput" class="form-control"
|
||||
placeholder="Taip dan tekan Enter..."
|
||||
value="{{ old('tags') ? implode(', ', old('tags')) : '' }}">
|
||||
<div id="tagsContainer" class="mt-1"></div>
|
||||
<input type="hidden" name="tags" id="tagsHidden">
|
||||
<div class="form-text">Tekan Enter atau koma untuk tambah tag.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Nota Upload</label>
|
||||
<input type="text" name="change_notes" class="form-control"
|
||||
value="{{ old('change_notes', 'Versi pertama.') }}"
|
||||
placeholder="Nota ringkas tentang upload ini...">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-upload me-1"></i>Upload & Proses
|
||||
</button>
|
||||
<a href="{{ route('admin.documents.index') }}" class="btn btn-outline-secondary">
|
||||
Batal
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light border mt-3 small">
|
||||
<i class="bi bi-info-circle me-2 text-info"></i>
|
||||
Dokumen akan diproses secara <strong>background</strong> selepas upload. Proses extraction, chunking dan embedding mungkin mengambil masa beberapa minit bergantung pada saiz fail.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Preview fail yang dipilih
|
||||
$('#fileInput').on('change', function() {
|
||||
const file = this.files[0];
|
||||
if (file) {
|
||||
const size = file.size < 1048576
|
||||
? (file.size / 1024).toFixed(1) + ' KB'
|
||||
: (file.size / 1048576).toFixed(1) + ' MB';
|
||||
$('#fileName').text(file.name);
|
||||
$('#fileSize').text(size);
|
||||
$('#filePreview').removeClass('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
// Tag input
|
||||
let tags = [];
|
||||
|
||||
function renderTags() {
|
||||
const container = $('#tagsContainer');
|
||||
container.empty();
|
||||
tags.forEach((tag, i) => {
|
||||
container.append(
|
||||
`<span class="badge bg-secondary me-1 mb-1">${tag}
|
||||
<i class="bi bi-x-circle ms-1" style="cursor:pointer" data-i="${i}"></i></span>`
|
||||
);
|
||||
});
|
||||
$('#tagsHidden').val(tags.map((t, i) => `tags[${i}]=${t}`).join('&'));
|
||||
// Rebuild hidden inputs properly
|
||||
$('[name^="tags["]').remove();
|
||||
tags.forEach((tag, i) => {
|
||||
$('form').append(`<input type="hidden" name="tags[${i}]" value="${tag}">`);
|
||||
});
|
||||
}
|
||||
|
||||
$('#tagsInput').on('keydown', function(e) {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
const val = $(this).val().trim().replace(/,$/, '');
|
||||
if (val && !tags.includes(val)) {
|
||||
tags.push(val);
|
||||
renderTags();
|
||||
}
|
||||
$(this).val('');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', '.bi-x-circle', function() {
|
||||
const i = $(this).data('i');
|
||||
tags.splice(i, 1);
|
||||
renderTags();
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
80
resources/views/admin/documents/edit.blade.php
Normal file
80
resources/views/admin/documents/edit.blade.php
Normal file
@@ -0,0 +1,80 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Edit Dokumen')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.show', $document) }}">{{ Str::limit($document->title, 30) }}</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0 fw-bold"><i class="bi bi-pencil me-2"></i>Edit Maklumat Dokumen</h5>
|
||||
<small class="text-muted">Untuk upload fail PDF baru, gunakan butang "Upload Versi Baru" pada halaman dokumen.</small>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ route('admin.documents.update', $document) }}">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Kategori <span class="text-danger">*</span></label>
|
||||
<select name="category_id" class="form-select" required>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}"
|
||||
{{ old('category_id', $document->category_id) == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Tajuk <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control @error('title') is-invalid @enderror"
|
||||
value="{{ old('title', $document->title) }}" required>
|
||||
@error('title')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Deskripsi</label>
|
||||
<textarea name="description" class="form-control" rows="2">{{ old('description', $document->description) }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tarikh Kuat Kuasa</label>
|
||||
<input type="date" name="effective_date" class="form-control"
|
||||
value="{{ old('effective_date', $document->effective_date?->toDateString()) }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tarikh Luput</label>
|
||||
<input type="date" name="expiry_date" class="form-control"
|
||||
value="{{ old('expiry_date', $document->expiry_date?->toDateString()) }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Bahasa</label>
|
||||
<select name="language" class="form-select">
|
||||
<option value="ms" {{ old('language', $document->language) == 'ms' ? 'selected' : '' }}>BM</option>
|
||||
<option value="en" {{ old('language', $document->language) == 'en' ? 'selected' : '' }}>EN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>Kemaskini
|
||||
</button>
|
||||
<a href="{{ route('admin.documents.show', $document) }}" class="btn btn-outline-secondary">Batal</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
153
resources/views/admin/documents/index.blade.php
Normal file
153
resources/views/admin/documents/index.blade.php
Normal file
@@ -0,0 +1,153 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Dokumen')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item active">Dokumen PDF</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h4 class="mb-0 fw-bold">Dokumen PDF</h4>
|
||||
<a href="{{ route('admin.documents.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-upload me-1"></i>Upload Dokumen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="search" class="form-control form-control-sm"
|
||||
placeholder="Cari tajuk..." value="{{ request('search') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="category_id" class="form-select form-select-sm">
|
||||
<option value="">Semua Kategori</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ request('category_id') == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">Semua Status</option>
|
||||
<option value="active" {{ request('status') == 'active' ? 'selected' : '' }}>Aktif</option>
|
||||
<option value="processing" {{ request('status') == 'processing' ? 'selected' : '' }}>Sedang Diproses</option>
|
||||
<option value="inactive" {{ request('status') == 'inactive' ? 'selected' : '' }}>Tidak Aktif</option>
|
||||
<option value="failed" {{ request('status') == 'failed' ? 'selected' : '' }}>Gagal</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-search me-1"></i>Tapis
|
||||
</button>
|
||||
@if(request()->hasAny(['search', 'category_id', 'status']))
|
||||
<a href="{{ route('admin.documents.index') }}" class="btn btn-sm btn-link text-muted">Reset</a>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Tajuk</th>
|
||||
<th>Kategori</th>
|
||||
<th>Versi</th>
|
||||
<th>Status</th>
|
||||
<th>Tarikh</th>
|
||||
<th class="text-end pe-3">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($documents as $doc)
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="fw-semibold">{{ $doc->title }}</div>
|
||||
@if($doc->description)
|
||||
<small class="text-muted text-truncate d-block" style="max-width:300px">{{ $doc->description }}</small>
|
||||
@endif
|
||||
@if($doc->tags)
|
||||
@foreach(array_slice($doc->tags, 0, 3) as $tag)
|
||||
<span class="badge bg-light text-dark border me-1" style="font-size:.65rem">{{ $tag }}</span>
|
||||
@endforeach
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background: {{ $doc->category->color ?? '#6c757d' }}">
|
||||
{{ $doc->category->name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-dark border">
|
||||
v{{ $doc->currentVersion?->version_number ?? '—' }}
|
||||
</span>
|
||||
@if($doc->versions_count > 1)
|
||||
<small class="text-muted">({{ $doc->versions_count }} versi)</small>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@php
|
||||
$statusMap = [
|
||||
'active' => ['class' => 'bg-success', 'label' => 'Aktif'],
|
||||
'inactive' => ['class' => 'bg-secondary', 'label' => 'Tidak Aktif'],
|
||||
'processing' => ['class' => 'badge-processing', 'label' => 'Diproses'],
|
||||
'draft' => ['class' => 'bg-light text-dark border', 'label' => 'Draf'],
|
||||
'failed' => ['class' => 'badge-failed', 'label' => 'Gagal'],
|
||||
];
|
||||
$s = $statusMap[$doc->status] ?? ['class' => 'bg-light', 'label' => $doc->status];
|
||||
@endphp
|
||||
<span class="badge {{ $s['class'] }}">{{ $s['label'] }}</span>
|
||||
@if($doc->currentVersion && $doc->currentVersion->processing_status !== 'indexed' && $doc->status == 'processing')
|
||||
<br><small class="text-muted" style="font-size:.65rem">{{ $doc->currentVersion->processing_status }}</small>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<small class="text-muted">{{ $doc->created_at->format('d/m/Y') }}</small>
|
||||
@if($doc->effective_date)
|
||||
<br><small class="text-muted">Kuat kuasa: {{ $doc->effective_date->format('d/m/Y') }}</small>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="{{ route('admin.documents.show', $doc) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
@if($doc->status !== 'processing')
|
||||
<form method="POST" action="{{ route('admin.documents.toggle-status', $doc) }}" class="d-inline">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="btn btn-sm {{ $doc->is_active ? 'btn-outline-warning' : 'btn-outline-success' }}"
|
||||
title="{{ $doc->is_active ? 'Nyahaktifkan' : 'Aktifkan' }}">
|
||||
<i class="bi {{ $doc->is_active ? 'bi-pause-circle' : 'bi-play-circle' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-5">
|
||||
<i class="bi bi-file-pdf fs-2 d-block mb-2 opacity-25"></i>
|
||||
Tiada dokumen ditemui.
|
||||
<a href="{{ route('admin.documents.create') }}">Upload yang pertama.</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($documents->hasPages())
|
||||
<div class="card-footer bg-white border-top py-3">
|
||||
{{ $documents->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
223
resources/views/admin/documents/show.blade.php
Normal file
223
resources/views/admin/documents/show.blade.php
Normal file
@@ -0,0 +1,223 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', $document->title)
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.documents.index') }}">Dokumen</a></li>
|
||||
<li class="breadcrumb-item active">{{ Str::limit($document->title, 40) }}</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-start justify-content-between mb-4">
|
||||
<div>
|
||||
<h4 class="mb-1 fw-bold">{{ $document->title }}</h4>
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<span class="badge" style="background:{{ $document->category->color }}">{{ $document->category->name }}</span>
|
||||
@php
|
||||
$statusMap = [
|
||||
'active' => 'bg-success',
|
||||
'inactive' => 'bg-secondary',
|
||||
'processing' => 'bg-warning',
|
||||
'failed' => 'bg-danger',
|
||||
];
|
||||
@endphp
|
||||
<span class="badge {{ $statusMap[$document->status] ?? 'bg-light text-dark' }}">{{ ucfirst($document->status) }}</span>
|
||||
@if($document->effective_date)
|
||||
<small class="text-muted">Kuat kuasa: {{ $document->effective_date->format('d/m/Y') }}</small>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ route('admin.documents.edit', $document) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
@if(auth()->user()->isAdmin())
|
||||
<form method="POST" action="{{ route('admin.documents.reindex', $document) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-outline-info btn-sm"
|
||||
onclick="return confirm('Adakah anda pasti untuk reindex dokumen ini?')">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Reindex
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('admin.documents.toggle-status', $document) }}">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="btn btn-sm {{ $document->is_active ? 'btn-warning' : 'btn-success' }}">
|
||||
@if($document->is_active)
|
||||
<i class="bi bi-pause-circle me-1"></i>Nyahaktif
|
||||
@else
|
||||
<i class="bi bi-play-circle me-1"></i>Aktifkan
|
||||
@endif
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
{{-- Info Dokumen --}}
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom"><h6 class="mb-0 fw-semibold">Maklumat Dokumen</h6></div>
|
||||
<div class="card-body small">
|
||||
@if($document->description)
|
||||
<p>{{ $document->description }}</p>
|
||||
@endif
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted">Bahasa</dt>
|
||||
<dd class="col-7">{{ $document->language == 'ms' ? 'Bahasa Melayu' : 'English' }}</dd>
|
||||
@if($document->expiry_date)
|
||||
<dt class="col-5 text-muted">Tarikh Luput</dt>
|
||||
<dd class="col-7">{{ $document->expiry_date->format('d/m/Y') }}</dd>
|
||||
@endif
|
||||
<dt class="col-5 text-muted">Dicipta</dt>
|
||||
<dd class="col-7">{{ $document->created_at->format('d/m/Y H:i') }}</dd>
|
||||
</dl>
|
||||
@if($document->tags)
|
||||
<div class="mt-2">
|
||||
@foreach($document->tags as $tag)
|
||||
<span class="badge bg-light text-dark border me-1">{{ $tag }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Upload versi baru --}}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h6 class="mb-0 fw-semibold">Upload Versi Baru</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ route('admin.documents.upload-version', $document) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="mb-2">
|
||||
<input type="file" name="file" class="form-control form-control-sm" accept=".pdf" required>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" name="change_notes" class="form-control form-control-sm"
|
||||
placeholder="Nota perubahan (optional)">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">
|
||||
<i class="bi bi-upload me-1"></i>Upload Versi Baru
|
||||
</button>
|
||||
</form>
|
||||
<small class="text-muted mt-1 d-block">Versi lama tidak akan dipadam.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Senarai Versi --}}
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h6 class="mb-0 fw-semibold">Sejarah Versi</h6>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Versi</th>
|
||||
<th>Fail</th>
|
||||
<th>Status</th>
|
||||
<th>Diupload</th>
|
||||
<th class="text-end pe-3">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($document->versions->sortByDesc('version_number') as $version)
|
||||
<tr class="{{ $version->is_current ? 'table-primary' : '' }}">
|
||||
<td class="ps-3">
|
||||
<span class="badge bg-light text-dark border">v{{ $version->version_number }}</span>
|
||||
@if($version->is_current)
|
||||
<span class="badge bg-success-subtle text-success border border-success ms-1">Semasa</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<div class="small">{{ $version->original_filename }}</div>
|
||||
<small class="text-muted">{{ $version->file_size_formatted }}</small>
|
||||
@if($version->page_count)
|
||||
<small class="text-muted"> · {{ $version->page_count }} ms.</small>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@php
|
||||
$ps = [
|
||||
'indexed' => ['bg-success', 'Selesai'],
|
||||
'processing' => ['bg-warning', 'Diproses'],
|
||||
'embedding' => ['bg-warning', 'Embedding'],
|
||||
'extraction_failed' => ['bg-danger', 'Gagal Extract'],
|
||||
'failed' => ['bg-danger', 'Gagal'],
|
||||
'pending' => ['bg-secondary', 'Menunggu'],
|
||||
];
|
||||
$pInfo = $ps[$version->processing_status] ?? ['bg-light text-dark', $version->processing_status];
|
||||
@endphp
|
||||
<span class="badge {{ $pInfo[0] }}">{{ $pInfo[1] }}</span>
|
||||
@if($version->hasFailed() && $version->processing_error)
|
||||
<br><small class="text-danger d-block" style="max-width:200px"
|
||||
title="{{ $version->processing_error }}">
|
||||
{{ Str::limit($version->processing_error, 50) }}
|
||||
</small>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ $version->created_at->format('d/m/Y') }}</small>
|
||||
@if($version->uploader)
|
||||
<br><small class="text-muted">{{ $version->uploader->name }}</small>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
@if($version->processing_status === 'indexed')
|
||||
<a href="{{ route('admin.documents.chunks', [$document, $version]) }}"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
title="Lihat Chunk">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('admin.documents.download', [$document, $version]) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Muat Turun">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Preview Chunks (versi semasa) --}}
|
||||
@if($document->currentVersion && $document->currentVersion->processing_status === 'indexed')
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0 fw-semibold">Chunk Terkini ({{ $document->currentVersion->chunks->count() }} preview)</h6>
|
||||
<a href="{{ route('admin.documents.chunks', [$document, $document->currentVersion]) }}"
|
||||
class="btn btn-sm btn-link p-0 text-muted">Lihat semua</a>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach($document->currentVersion->chunks->take(5) as $chunk)
|
||||
<div class="list-group-item py-2 px-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="badge bg-light text-dark border" style="font-size:.65rem">
|
||||
Chunk #{{ $chunk->chunk_index + 1 }}
|
||||
</span>
|
||||
@if($chunk->page_number)
|
||||
<span class="badge bg-light text-dark border" style="font-size:.65rem">
|
||||
ms. {{ $chunk->page_number }}
|
||||
</span>
|
||||
@endif
|
||||
@if($chunk->section_heading)
|
||||
<span class="text-muted small text-truncate">{{ $chunk->section_heading }}</span>
|
||||
@endif
|
||||
<span class="badge {{ $chunk->is_embedded ? 'bg-success-subtle text-success' : 'bg-warning-subtle text-warning' }} ms-auto" style="font-size:.65rem">
|
||||
{{ $chunk->is_embedded ? 'Embedded' : 'Belum Embed' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mb-0 small text-muted" style="white-space:pre-wrap">{{ Str::limit($chunk->content, 200) }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
142
resources/views/admin/knowledge-items/create.blade.php
Normal file
142
resources/views/admin/knowledge-items/create.blade.php
Normal file
@@ -0,0 +1,142 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Tambah Knowledge Item')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.knowledge-items.index') }}">FAQ & Pengetahuan</a></li>
|
||||
<li class="breadcrumb-item active">Tambah</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-9">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0 fw-bold"><i class="bi bi-plus-circle me-2"></i>Tambah Knowledge Item</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ route('admin.knowledge-items.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Kategori <span class="text-danger">*</span></label>
|
||||
<select name="category_id" class="form-select @error('category_id') is-invalid @enderror" required>
|
||||
<option value="">— Pilih Kategori —</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}"
|
||||
{{ (old('category_id', $prefillData['category_id'] ?? '') == $cat->id) ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('category_id')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Jenis Item <span class="text-danger">*</span></label>
|
||||
<select name="item_type" class="form-select @error('item_type') is-invalid @enderror" required>
|
||||
<option value="">— Pilih Jenis —</option>
|
||||
@foreach($typeLabels as $value => $label)
|
||||
<option value="{{ $value }}"
|
||||
{{ (old('item_type', $prefillData['item_type'] ?? '') == $value) ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('item_type')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">
|
||||
<span id="titleLabel">Tajuk</span> <span class="text-danger">*</span>
|
||||
<small class="text-muted fw-normal ms-1">(untuk FAQ: tuliskan soalannya)</small>
|
||||
</label>
|
||||
<input type="text" name="title" class="form-control @error('title') is-invalid @enderror"
|
||||
value="{{ old('title', $prefillData['title'] ?? '') }}"
|
||||
placeholder="Contoh: Apakah syarat untuk memohon lesen perniagaan?" required>
|
||||
@error('title')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">
|
||||
<span id="contentLabel">Kandungan / Jawapan</span> <span class="text-danger">*</span>
|
||||
</label>
|
||||
<textarea name="content" class="form-control @error('content') is-invalid @enderror"
|
||||
rows="8" required
|
||||
placeholder="Tulis jawapan atau kandungan penuh di sini...">{{ old('content', $prefillData['content'] ?? '') }}</textarea>
|
||||
<div class="d-flex justify-content-between">
|
||||
@error('content')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
<small class="text-muted ms-auto mt-1" id="charCount">0 / 10,000</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Bahasa</label>
|
||||
<select name="language" class="form-select">
|
||||
<option value="ms" {{ old('language', 'ms') == 'ms' ? 'selected' : '' }}>Bahasa Melayu</option>
|
||||
<option value="en" {{ old('language') == 'en' ? 'selected' : '' }}>English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tarikh Kuat Kuasa</label>
|
||||
<input type="date" name="effective_date" class="form-control"
|
||||
value="{{ old('effective_date') }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||
id="isActive" {{ old('is_active', '1') ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="isActive">Aktifkan selepas simpan</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input type="hidden" name="is_public" value="0">
|
||||
<input class="form-check-input" type="checkbox" name="is_public" value="1"
|
||||
id="isPublic" {{ old('is_public', '1') ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="isPublic">Boleh dicari oleh public</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>Simpan & Embed
|
||||
</button>
|
||||
<a href="{{ route('admin.knowledge-items.index') }}" class="btn btn-outline-secondary">Batal</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Kira karakter
|
||||
$('textarea[name="content"]').on('input', function() {
|
||||
const count = $(this).val().length;
|
||||
$('#charCount').text(count.toLocaleString() + ' / 10,000');
|
||||
if (count > 9000) $('#charCount').addClass('text-warning');
|
||||
else $('#charCount').removeClass('text-warning');
|
||||
});
|
||||
$('textarea[name="content"]').trigger('input');
|
||||
|
||||
// Update label ikut jenis
|
||||
$('select[name="item_type"]').on('change', function() {
|
||||
if ($(this).val() === 'faq') {
|
||||
$('#titleLabel').text('Soalan');
|
||||
$('#contentLabel').text('Jawapan');
|
||||
} else {
|
||||
$('#titleLabel').text('Tajuk');
|
||||
$('#contentLabel').text('Kandungan');
|
||||
}
|
||||
});
|
||||
$('select[name="item_type"]').trigger('change');
|
||||
</script>
|
||||
@endpush
|
||||
107
resources/views/admin/knowledge-items/edit.blade.php
Normal file
107
resources/views/admin/knowledge-items/edit.blade.php
Normal file
@@ -0,0 +1,107 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Edit Knowledge Item')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.knowledge-items.index') }}">FAQ & Pengetahuan</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.knowledge-items.show', $knowledgeItem) }}">{{ Str::limit($knowledgeItem->title, 30) }}</a></li>
|
||||
<li class="breadcrumb-item active">Edit</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-9">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h5 class="mb-0 fw-bold"><i class="bi bi-pencil me-2"></i>Edit Knowledge Item</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<form method="POST" action="{{ route('admin.knowledge-items.update', $knowledgeItem) }}">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Kategori <span class="text-danger">*</span></label>
|
||||
<select name="category_id" class="form-select @error('category_id') is-invalid @enderror" required>
|
||||
<option value="">— Pilih Kategori —</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}"
|
||||
{{ old('category_id', $knowledgeItem->category_id) == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('category_id')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Jenis Item <span class="text-danger">*</span></label>
|
||||
<select name="item_type" class="form-select @error('item_type') is-invalid @enderror" required>
|
||||
@foreach($typeLabels as $value => $label)
|
||||
<option value="{{ $value }}"
|
||||
{{ old('item_type', $knowledgeItem->item_type) == $value ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('item_type')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Tajuk / Soalan <span class="text-danger">*</span></label>
|
||||
<input type="text" name="title" class="form-control @error('title') is-invalid @enderror"
|
||||
value="{{ old('title', $knowledgeItem->title) }}" required>
|
||||
@error('title')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Kandungan / Jawapan <span class="text-danger">*</span></label>
|
||||
<textarea name="content" class="form-control @error('content') is-invalid @enderror"
|
||||
rows="10" required>{{ old('content', $knowledgeItem->content) }}</textarea>
|
||||
@error('content')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Bahasa</label>
|
||||
<select name="language" class="form-select">
|
||||
<option value="ms" {{ old('language', $knowledgeItem->language) == 'ms' ? 'selected' : '' }}>BM</option>
|
||||
<option value="en" {{ old('language', $knowledgeItem->language) == 'en' ? 'selected' : '' }}>EN</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">Tarikh Kuat Kuasa</label>
|
||||
<input type="date" name="effective_date" class="form-control"
|
||||
value="{{ old('effective_date', $knowledgeItem->effective_date?->toDateString()) }}">
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||
id="isActive" {{ old('is_active', $knowledgeItem->is_active) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="isActive">Aktif</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input type="hidden" name="is_public" value="0">
|
||||
<input class="form-check-input" type="checkbox" name="is_public" value="1"
|
||||
id="isPublic" {{ old('is_public', $knowledgeItem->is_public) ? 'checked' : '' }}>
|
||||
<label class="form-check-label" for="isPublic">Awam</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>Kemaskini & Re-embed
|
||||
</button>
|
||||
<a href="{{ route('admin.knowledge-items.show', $knowledgeItem) }}" class="btn btn-outline-secondary">Batal</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
142
resources/views/admin/knowledge-items/index.blade.php
Normal file
142
resources/views/admin/knowledge-items/index.blade.php
Normal file
@@ -0,0 +1,142 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'FAQ & Pengetahuan')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item active">FAQ & Pengetahuan</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h4 class="mb-0 fw-bold">FAQ & Pengetahuan</h4>
|
||||
<a href="{{ route('admin.knowledge-items.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i>Tambah Item
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{{-- Filter --}}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-3">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="search" class="form-control form-control-sm"
|
||||
placeholder="Cari tajuk atau kandungan..." value="{{ request('search') }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="category_id" class="form-select form-select-sm">
|
||||
<option value="">Semua Kategori</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}" {{ request('category_id') == $cat->id ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="item_type" class="form-select form-select-sm">
|
||||
<option value="">Semua Jenis</option>
|
||||
@foreach($typeLabels as $value => $label)
|
||||
<option value="{{ $value }}" {{ request('item_type') == $value ? 'selected' : '' }}>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-search me-1"></i>Tapis
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Table --}}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Tajuk / Soalan</th>
|
||||
<th>Kategori</th>
|
||||
<th>Jenis</th>
|
||||
<th>Status</th>
|
||||
<th>Embed</th>
|
||||
<th class="text-end pe-3">Tindakan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($items as $item)
|
||||
<tr class="{{ $item->trashed() ? 'opacity-50' : '' }}">
|
||||
<td class="ps-3">
|
||||
<div class="fw-semibold">{{ Str::limit($item->title, 80) }}</div>
|
||||
<small class="text-muted">{{ Str::limit(strip_tags($item->content), 80) }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background:{{ $item->category->color ?? '#6c757d' }}">
|
||||
{{ $item->category->name }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@php
|
||||
$typeColors = ['faq' => 'bg-primary', 'policy' => 'bg-success', 'note' => 'bg-warning', 'announcement' => 'bg-info'];
|
||||
@endphp
|
||||
<span class="badge {{ $typeColors[$item->item_type] ?? 'bg-secondary' }}">
|
||||
{{ $typeLabels[$item->item_type] ?? $item->item_type }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if($item->trashed())
|
||||
<span class="badge bg-danger">Dipadam</span>
|
||||
@elseif($item->is_active)
|
||||
<span class="badge bg-success">Aktif</span>
|
||||
@else
|
||||
<span class="badge bg-secondary">Tidak Aktif</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>
|
||||
@if($item->is_embedded)
|
||||
<i class="bi bi-check-circle-fill text-success" title="Sudah di-embed"></i>
|
||||
@else
|
||||
<i class="bi bi-clock text-warning" title="Belum di-embed"></i>
|
||||
@endif
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
@unless($item->trashed())
|
||||
<a href="{{ route('admin.knowledge-items.show', $item) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ route('admin.knowledge-items.edit', $item) }}"
|
||||
class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST" action="{{ route('admin.knowledge-items.toggle-status', $item) }}" class="d-inline">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="btn btn-sm {{ $item->is_active ? 'btn-outline-warning' : 'btn-outline-success' }}"
|
||||
title="{{ $item->is_active ? 'Nyahaktifkan' : 'Aktifkan' }}">
|
||||
<i class="bi {{ $item->is_active ? 'bi-toggle-on' : 'bi-toggle-off' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
@endunless
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-5">
|
||||
<i class="bi bi-lightbulb fs-2 d-block mb-2 opacity-25"></i>
|
||||
Tiada knowledge item ditemui.
|
||||
<a href="{{ route('admin.knowledge-items.create') }}">Tambah yang pertama.</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if($items->hasPages())
|
||||
<div class="card-footer bg-white border-top py-3">
|
||||
{{ $items->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
112
resources/views/admin/knowledge-items/show.blade.php
Normal file
112
resources/views/admin/knowledge-items/show.blade.php
Normal file
@@ -0,0 +1,112 @@
|
||||
@extends('layouts.admin')
|
||||
|
||||
@section('title', 'Knowledge Item')
|
||||
|
||||
@section('breadcrumb')
|
||||
<li class="breadcrumb-item"><a href="{{ route('admin.knowledge-items.index') }}">FAQ & Pengetahuan</a></li>
|
||||
<li class="breadcrumb-item active">{{ Str::limit($knowledgeItem->title, 40) }}</li>
|
||||
@endsection
|
||||
|
||||
@section('content')
|
||||
<div class="d-flex align-items-start justify-content-between mb-4">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
|
||||
@php $typeColors = ['faq' => 'bg-primary', 'policy' => 'bg-success', 'note' => 'bg-warning', 'announcement' => 'bg-info']; @endphp
|
||||
<span class="badge {{ $typeColors[$knowledgeItem->item_type] ?? 'bg-secondary' }}">
|
||||
{{ $knowledgeItem->type_label }}
|
||||
</span>
|
||||
<span class="badge" style="background:{{ $knowledgeItem->category->color ?? '#6c757d' }}">
|
||||
{{ $knowledgeItem->category->name }}
|
||||
</span>
|
||||
<span class="badge {{ $knowledgeItem->is_active ? 'bg-success' : 'bg-secondary' }}">
|
||||
{{ $knowledgeItem->is_active ? 'Aktif' : 'Tidak Aktif' }}
|
||||
</span>
|
||||
@if($knowledgeItem->is_embedded)
|
||||
<span class="badge bg-success-subtle text-success border border-success">
|
||||
<i class="bi bi-check2 me-1"></i>Di-embed
|
||||
</span>
|
||||
@else
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning">
|
||||
<i class="bi bi-clock me-1"></i>Belum Embed
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
<h4 class="mb-0 fw-bold">{{ $knowledgeItem->title }}</h4>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ route('admin.knowledge-items.edit', $knowledgeItem) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<form method="POST" action="{{ route('admin.knowledge-items.reindex', $knowledgeItem) }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-outline-info btn-sm">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Re-embed
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('admin.knowledge-items.toggle-status', $knowledgeItem) }}">
|
||||
@csrf @method('PATCH')
|
||||
<button type="submit" class="btn btn-sm {{ $knowledgeItem->is_active ? 'btn-warning' : 'btn-success' }}">
|
||||
{{ $knowledgeItem->is_active ? 'Nyahaktifkan' : 'Aktifkan' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom">
|
||||
<h6 class="mb-0 fw-semibold">Kandungan</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if($knowledgeItem->item_type === 'faq')
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small fw-semibold text-uppercase" style="font-size:.7rem">Soalan</label>
|
||||
<div class="p-3 bg-light rounded mt-1 fw-semibold">{{ $knowledgeItem->title }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small fw-semibold text-uppercase" style="font-size:.7rem">Jawapan</label>
|
||||
<div class="p-3 border rounded mt-1" style="white-space:pre-wrap">{{ $knowledgeItem->content }}</div>
|
||||
</div>
|
||||
@else
|
||||
<div style="white-space:pre-wrap">{{ $knowledgeItem->content }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-bottom"><h6 class="mb-0 fw-semibold">Maklumat</h6></div>
|
||||
<div class="card-body small">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-5 text-muted">Bahasa</dt>
|
||||
<dd class="col-7">{{ $knowledgeItem->language == 'ms' ? 'BM' : 'EN' }}</dd>
|
||||
@if($knowledgeItem->effective_date)
|
||||
<dt class="col-5 text-muted">Kuat Kuasa</dt>
|
||||
<dd class="col-7">{{ $knowledgeItem->effective_date->format('d/m/Y') }}</dd>
|
||||
@endif
|
||||
<dt class="col-5 text-muted">Dicipta</dt>
|
||||
<dd class="col-7">{{ $knowledgeItem->created_at->format('d/m/Y H:i') }}</dd>
|
||||
@if($knowledgeItem->embedded_at)
|
||||
<dt class="col-5 text-muted">Di-embed</dt>
|
||||
<dd class="col-7">{{ $knowledgeItem->embedded_at->format('d/m/Y H:i') }}</dd>
|
||||
@endif
|
||||
@if($knowledgeItem->creator)
|
||||
<dt class="col-5 text-muted">Dibuat oleh</dt>
|
||||
<dd class="col-7">{{ $knowledgeItem->creator->name }}</dd>
|
||||
@endif
|
||||
<dt class="col-5 text-muted">Awam</dt>
|
||||
<dd class="col-7">{{ $knowledgeItem->is_public ? 'Ya' : 'Tidak' }}</dd>
|
||||
</dl>
|
||||
@if($knowledgeItem->tags)
|
||||
<div class="mt-2">
|
||||
@foreach($knowledgeItem->tags as $tag)
|
||||
<span class="badge bg-light text-dark border me-1">{{ $tag }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
75
resources/views/auth/login.blade.php
Normal file
75
resources/views/auth/login.blade.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ms">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Log Masuk — Sistem Pangkalan Pengetahuan</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-database-fill-gear text-white fs-1"></i>
|
||||
<h4 class="text-white mt-2 fw-bold">Pangkalan Pengetahuan</h4>
|
||||
<p class="text-secondary">Panel Pentadbir</p>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-lg">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-4">Log Masuk</h5>
|
||||
|
||||
@if($errors->any())
|
||||
<div class="alert alert-danger">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('login') }}">
|
||||
@csrf
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">E-mel</label>
|
||||
<input type="email" name="email" class="form-control @error('email') is-invalid @enderror"
|
||||
value="{{ old('email') }}" required autofocus autocomplete="email">
|
||||
@error('email')<div class="invalid-feedback">{{ $message }}</div>@enderror
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Kata Laluan</label>
|
||||
<input type="password" name="password" class="form-control"
|
||||
required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember" name="remember">
|
||||
<label class="form-check-label" for="remember">Ingat saya</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100 py-2">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Log Masuk
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ route('chatbot.index') }}" class="text-muted small">
|
||||
<i class="bi bi-chat-dots me-1"></i>Pergi ke Chatbot Awam
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
402
resources/views/chatbot/index.blade.php
Normal file
402
resources/views/chatbot/index.blade.php
Normal file
@@ -0,0 +1,402 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ms">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Pembantu Maklumat — Sistem Pangkalan Pengetahuan</title>
|
||||
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #fafbff 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
height: 500px;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .75rem;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.message {
|
||||
max-width: 85%;
|
||||
animation: fadeIn .2s ease;
|
||||
}
|
||||
|
||||
.message.user-message {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message.bot-message {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: .75rem 1rem;
|
||||
border-radius: 1rem;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.user-message .message-bubble {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
border-bottom-right-radius: .25rem;
|
||||
}
|
||||
|
||||
.bot-message .message-bubble {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-bottom-left-radius: .25rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.05);
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
font-size: .7rem;
|
||||
color: #94a3b8;
|
||||
margin-top: .25rem;
|
||||
padding: 0 .25rem;
|
||||
}
|
||||
|
||||
.user-message .message-meta {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.source-card {
|
||||
font-size: .75rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: .5rem;
|
||||
padding: .5rem .75rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
background: #94a3b8;
|
||||
border-radius: 50%;
|
||||
animation: typing .8s infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) { animation-delay: .15s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: .3s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 100% { transform: translateY(0); opacity: .5; }
|
||||
50% { transform: translateY(-4px); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.feedback-btn.active { opacity: 1; }
|
||||
.feedback-btn { opacity: .6; transition: opacity .15s; }
|
||||
.feedback-btn:hover { opacity: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="chat-container py-4 px-3">
|
||||
|
||||
{{-- Header --}}
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center"
|
||||
style="width:40px;height:40px">
|
||||
<i class="bi bi-robot text-white fs-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-bold">Pembantu Maklumat</div>
|
||||
<div class="text-muted small">Sistem Pangkalan Pengetahuan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{{-- Pilih Kategori --}}
|
||||
<select id="categorySelect" class="form-select form-select-sm" style="max-width:200px">
|
||||
<option value="">Semua Kategori</option>
|
||||
@foreach($categories as $cat)
|
||||
<option value="{{ $cat->id }}"
|
||||
{{ ($selectedCatId == $cat->id) ? 'selected' : '' }}>
|
||||
{{ $cat->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
@auth
|
||||
<a href="{{ route('admin.dashboard') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-shield me-1"></i>Admin
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Chat Window --}}
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="chat-messages" id="chatMessages">
|
||||
{{-- Welcome message --}}
|
||||
<div class="message bot-message">
|
||||
<div class="message-bubble">
|
||||
<strong>Selamat datang!</strong> Saya pembantu maklumat anda. 👋<br><br>
|
||||
Anda boleh bertanya tentang:
|
||||
<ul class="mb-0 mt-1">
|
||||
@foreach($categories->take(5) as $cat)
|
||||
<li>{{ $cat->name }}</li>
|
||||
@endforeach
|
||||
@if($categories->count() > 5)
|
||||
<li class="text-muted">...dan {{ $categories->count() - 5 }} lagi</li>
|
||||
@endif
|
||||
</ul>
|
||||
<div class="mt-2 text-muted small">
|
||||
Pilih kategori di atas untuk soalan yang lebih tepat.
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-meta">Pembantu AI</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Input --}}
|
||||
<div class="border-top p-3">
|
||||
<form id="chatForm">
|
||||
<div class="input-group">
|
||||
<input type="text" id="questionInput" class="form-control"
|
||||
placeholder="Taip soalan anda di sini..."
|
||||
autocomplete="off" maxlength="1000">
|
||||
<button type="submit" id="sendBtn" class="btn btn-primary px-3">
|
||||
<i class="bi bi-send-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-1">
|
||||
<small class="text-muted">Tekan Enter untuk hantar</small>
|
||||
<small class="text-muted" id="charCounter">0/1000</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Disclaimer --}}
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Jawapan dijana secara automatik berdasarkan dokumen rasmi.
|
||||
Sila semak dokumen asal untuk maklumat muktamad.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
let lastChatLogId = null;
|
||||
|
||||
// Kira karakter
|
||||
$('#questionInput').on('input', function () {
|
||||
const len = $(this).val().length;
|
||||
$('#charCounter').text(len + '/1000');
|
||||
});
|
||||
|
||||
// Submit borang
|
||||
$('#chatForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
const question = $('#questionInput').val().trim();
|
||||
if (!question) return;
|
||||
|
||||
sendQuestion(question);
|
||||
});
|
||||
|
||||
// Enter key
|
||||
$('#questionInput').on('keydown', function (e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
$('#chatForm').trigger('submit');
|
||||
}
|
||||
});
|
||||
|
||||
function sendQuestion(question) {
|
||||
const categoryId = $('#categorySelect').val();
|
||||
|
||||
// Papar mesej user
|
||||
appendUserMessage(question);
|
||||
$('#questionInput').val('').trigger('input');
|
||||
|
||||
// Papar typing indicator
|
||||
const typingId = appendTypingIndicator();
|
||||
|
||||
// Disable input
|
||||
$('#sendBtn, #questionInput').prop('disabled', true);
|
||||
|
||||
$.ajax({
|
||||
url: '{{ route("chatbot.ask") }}',
|
||||
method: 'POST',
|
||||
data: {
|
||||
_token: $('meta[name="csrf-token"]').attr('content'),
|
||||
question: question,
|
||||
category_id: categoryId || null,
|
||||
},
|
||||
success: function (res) {
|
||||
removeTypingIndicator(typingId);
|
||||
if (res.success) {
|
||||
const msgId = 'msg-' + Date.now();
|
||||
appendBotMessage(res.answer, res.sources, res.has_answer, msgId);
|
||||
// Simpan untuk feedback (dalam real app, dapatkan chat_log_id dari server)
|
||||
// Untuk demo, kita simpan session token
|
||||
lastChatLogId = null; // akan diisi bila server return log ID
|
||||
} else {
|
||||
appendErrorMessage(res.message || 'Ralat berlaku.');
|
||||
}
|
||||
},
|
||||
error: function (xhr) {
|
||||
removeTypingIndicator(typingId);
|
||||
if (xhr.status === 429) {
|
||||
appendErrorMessage('Terlalu banyak soalan. Sila tunggu sebentar.');
|
||||
} else if (xhr.status === 503) {
|
||||
appendErrorMessage('Perkhidmatan AI tidak tersedia. Sila cuba sebentar lagi.');
|
||||
} else {
|
||||
appendErrorMessage('Ralat sistem. Sila cuba lagi.');
|
||||
}
|
||||
},
|
||||
complete: function () {
|
||||
$('#sendBtn, #questionInput').prop('disabled', false);
|
||||
$('#questionInput').focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function appendUserMessage(text) {
|
||||
const html = `
|
||||
<div class="message user-message">
|
||||
<div class="message-bubble">${escapeHtml(text)}</div>
|
||||
<div class="message-meta">${formatTime(new Date())}</div>
|
||||
</div>`;
|
||||
$('#chatMessages').append(html);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function appendBotMessage(answer, sources, hasAnswer, msgId) {
|
||||
let sourcesHtml = '';
|
||||
if (sources && sources.length > 0) {
|
||||
sourcesHtml = '<div class="source-card mt-2">';
|
||||
sourcesHtml += '<div class="fw-semibold mb-1 text-muted"><i class="bi bi-book me-1"></i>Sumber Rujukan:</div>';
|
||||
sources.forEach(function (s) {
|
||||
const icon = s.type === 'pdf' ? 'bi-file-pdf text-danger' : 'bi-lightbulb text-primary';
|
||||
const page = s.page_number ? ` — ms. ${s.page_number}` : '';
|
||||
sourcesHtml += `<div class="mb-1">
|
||||
<i class="bi ${icon} me-1"></i>
|
||||
<strong>${escapeHtml(s.title)}</strong>${page}
|
||||
<span class="badge bg-light text-dark border ms-1">${escapeHtml(s.category)}</span>
|
||||
</div>`;
|
||||
});
|
||||
sourcesHtml += '</div>';
|
||||
}
|
||||
|
||||
const warningHtml = !hasAnswer
|
||||
? '<div class="alert alert-warning py-1 px-2 mt-2 mb-0 small"><i class="bi bi-exclamation-triangle me-1"></i>Jawapan tidak dijumpai dalam pangkalan pengetahuan.</div>'
|
||||
: '';
|
||||
|
||||
const feedbackHtml = `
|
||||
<div class="d-flex gap-1 mt-2" id="feedback-${msgId}">
|
||||
<small class="text-muted me-1">Adakah ini membantu?</small>
|
||||
<button class="btn btn-sm btn-outline-success feedback-btn py-0 px-1" data-rating="helpful" data-msgid="${msgId}">
|
||||
<i class="bi bi-hand-thumbs-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger feedback-btn py-0 px-1" data-rating="not_helpful" data-msgid="${msgId}">
|
||||
<i class="bi bi-hand-thumbs-down"></i>
|
||||
</button>
|
||||
</div>`;
|
||||
|
||||
const html = `
|
||||
<div class="message bot-message" id="${msgId}">
|
||||
<div class="message-bubble">
|
||||
<div style="white-space:pre-wrap">${escapeHtml(answer)}</div>
|
||||
${warningHtml}
|
||||
${sourcesHtml}
|
||||
${feedbackHtml}
|
||||
</div>
|
||||
<div class="message-meta">${formatTime(new Date())}</div>
|
||||
</div>`;
|
||||
|
||||
$('#chatMessages').append(html);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function appendErrorMessage(text) {
|
||||
const html = `
|
||||
<div class="message bot-message">
|
||||
<div class="message-bubble bg-danger-subtle border-danger">
|
||||
<i class="bi bi-exclamation-circle me-1 text-danger"></i>
|
||||
${escapeHtml(text)}
|
||||
</div>
|
||||
</div>`;
|
||||
$('#chatMessages').append(html);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function appendTypingIndicator() {
|
||||
const id = 'typing-' + Date.now();
|
||||
const html = `
|
||||
<div class="message bot-message" id="${id}">
|
||||
<div class="message-bubble">
|
||||
<div class="typing-indicator d-flex gap-1 align-items-center">
|
||||
<span></span><span></span><span></span>
|
||||
<small class="text-muted ms-1">Sedang mencari jawapan...</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
$('#chatMessages').append(html);
|
||||
scrollToBottom();
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeTypingIndicator(id) {
|
||||
$('#' + id).remove();
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const el = document.getElementById('chatMessages');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return $('<div>').text(text).html();
|
||||
}
|
||||
|
||||
function formatTime(date) {
|
||||
return date.toLocaleTimeString('ms-MY', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Feedback handler
|
||||
$(document).on('click', '.feedback-btn', function () {
|
||||
const rating = $(this).data('rating');
|
||||
const msgId = $(this).data('msgid');
|
||||
|
||||
// Note: Untuk feedback berfungsi penuh, perlu simpan chat_log_id
|
||||
// dari response server. Ini adalah placeholder UI.
|
||||
$(this).addClass('active').siblings('.feedback-btn').removeClass('active');
|
||||
|
||||
const feedbackDiv = $('#feedback-' + msgId);
|
||||
feedbackDiv.html('<small class="text-muted"><i class="bi bi-check2 me-1 text-success"></i>Terima kasih atas maklum balas!</small>');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
195
resources/views/layouts/admin.blade.php
Normal file
195
resources/views/layouts/admin.blade.php
Normal file
@@ -0,0 +1,195 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ms">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'Panel Admin') — Sistem Pangkalan Pengetahuan</title>
|
||||
|
||||
<!-- Bootstrap 5 -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Bootstrap Icons -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body { background-color: #f8f9fa; }
|
||||
|
||||
.sidebar {
|
||||
min-height: 100vh;
|
||||
background: #1e293b;
|
||||
width: 260px;
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
z-index: 1000;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: #94a3b8;
|
||||
padding: .6rem 1.25rem;
|
||||
border-radius: .375rem;
|
||||
margin: 0 .5rem .125rem;
|
||||
font-size: .9rem;
|
||||
transition: all .15s;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
background: #334155;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar .nav-link i { width: 1.25rem; }
|
||||
|
||||
.sidebar .nav-section {
|
||||
color: #475569;
|
||||
font-size: .7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
padding: .75rem 1.25rem .25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 260px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: .75rem 1.5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.badge-processing { background: #f59e0b; }
|
||||
.badge-indexed { background: #10b981; }
|
||||
.badge-failed { background: #ef4444; }
|
||||
.badge-pending { background: #6b7280; }
|
||||
.badge-inactive { background: #94a3b8; }
|
||||
</style>
|
||||
|
||||
@stack('styles')
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar p-0">
|
||||
<div class="p-3 border-bottom border-secondary">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-database-fill-gear text-primary fs-4"></i>
|
||||
<div>
|
||||
<div class="text-white fw-bold small">Pangkalan Pengetahuan</div>
|
||||
<div class="text-muted" style="font-size:.7rem">Panel Admin</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-2 pb-4">
|
||||
<div class="nav-section">Utama</div>
|
||||
<a href="{{ route('admin.dashboard') }}"
|
||||
class="nav-link {{ request()->routeIs('admin.dashboard') ? 'active' : '' }}">
|
||||
<i class="bi bi-grid-1x2 me-2"></i>Dashboard
|
||||
</a>
|
||||
|
||||
@if(auth()->user()->isAdmin())
|
||||
<div class="nav-section mt-2">Kandungan</div>
|
||||
<a href="{{ route('admin.categories.index') }}"
|
||||
class="nav-link {{ request()->routeIs('admin.categories.*') ? 'active' : '' }}">
|
||||
<i class="bi bi-folder2 me-2"></i>Kategori
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<a href="{{ route('admin.documents.index') }}"
|
||||
class="nav-link {{ request()->routeIs('admin.documents.*') ? 'active' : '' }}">
|
||||
<i class="bi bi-file-pdf me-2"></i>Dokumen PDF
|
||||
</a>
|
||||
|
||||
<a href="{{ route('admin.knowledge-items.index') }}"
|
||||
class="nav-link {{ request()->routeIs('admin.knowledge-items.*') ? 'active' : '' }}">
|
||||
<i class="bi bi-lightbulb me-2"></i>FAQ & Pengetahuan
|
||||
</a>
|
||||
|
||||
<div class="nav-section mt-2">Chat & Audit</div>
|
||||
<a href="{{ route('admin.chat-feedback.index') }}"
|
||||
class="nav-link {{ request()->routeIs('admin.chat-feedback.*') ? 'active' : '' }}">
|
||||
<i class="bi bi-chat-dots me-2"></i>Semakan Chat
|
||||
@php $flagged = cache()->remember('flagged_chat_count', 60, fn() => \App\Models\ChatLog::where('is_flagged', true)->count()); @endphp
|
||||
@if($flagged > 0)
|
||||
<span class="badge bg-danger ms-1">{{ $flagged }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@if(auth()->user()->isAdmin())
|
||||
<a href="{{ route('admin.audit-logs.index') }}"
|
||||
class="nav-link {{ request()->routeIs('admin.audit-logs.*') ? 'active' : '' }}">
|
||||
<i class="bi bi-journal-text me-2"></i>Log Audit
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<div class="nav-section mt-2">Awam</div>
|
||||
<a href="{{ route('chatbot.index') }}" target="_blank"
|
||||
class="nav-link">
|
||||
<i class="bi bi-robot me-2"></i>Buka Chatbot
|
||||
<i class="bi bi-box-arrow-up-right ms-1 small"></i>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
|
||||
<!-- Topbar -->
|
||||
<div class="topbar d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb" class="mb-0">
|
||||
<ol class="breadcrumb mb-0 small">
|
||||
@yield('breadcrumb')
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="small text-muted">
|
||||
<i class="bi bi-person-circle me-1"></i>
|
||||
{{ auth()->user()->name }}
|
||||
<span class="badge bg-secondary ms-1">{{ auth()->user()->role_label }}</span>
|
||||
</span>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-box-arrow-right"></i> Log Keluar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="p-4">
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check-circle me-2"></i>{{ session('success') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>{{ session('error') }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@yield('content')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
225
resources/views/welcome.blade.php
Normal file
225
resources/views/welcome.blade.php
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user