nambah aja

This commit is contained in:
MufridaFaraDiani27 2026-05-10 01:44:07 +07:00
parent 227aad6557
commit 8e78dce219
14 changed files with 1225 additions and 923 deletions

View File

@ -4,30 +4,29 @@
use Illuminate\Http\Request;
use App\Models\HasilAnalisis;
use Illuminate\Support\Facades\DB;
class AnalisisController extends Controller
{
public function index(Request $request)
{
// Dropdown list destinasi unik
// Ambil data evaluasi model
$evaluasi = DB::table('evaluasi_model')->latest()->first();
// Dropdown wisata
$wisataList = HasilAnalisis::select('wisata')
->distinct()
->pluck('wisata');
// Query utama tabel
// Query hasil analisis dengan filter
$query = HasilAnalisis::query();
// Filter kalau user pilih destinasi tertentu
if ($request->filled('wisata')) {
$query->where('wisata', $request->wisata);
}
// Data hasil analisis (pakai paginate biar rapi)
$hasil = $query->latest()->paginate(5);
$hasil = $query->latest()->paginate(10);
return view('analisis.index', compact(
'hasil',
'wisataList'
));
return view('analisis.index', compact('hasil', 'wisataList', 'evaluasi'));
}
}

View File

@ -7,138 +7,302 @@
class DashboardController extends Controller
{
public function index(Request $request)
{
$wisata = $request->wisata;
private array $issueRules = [
'Kebersihan' => [
'keywords' => ['kotor', 'sampah', 'jorok', 'kumuh', 'toilet', 'wc', 'kamar mandi', 'limbah', 'bau', 'bersih'],
'color' => 'red',
'icon' => '🧹',
'saran' => [
'actions' => ['Tambah tempat sampah', 'Jadwal pembersihan rutin', 'Petugas kebersihan tambahan'],
'dampak' => 'Kepuasan meningkat signifikan',
'tip' => 'Tambah tempat sampah & jadwal pembersihan rutin.',
],
],
'Parkir' => [
'keywords' => ['parkir', 'lahan parkir', 'tempat parkir', 'parkiran', 'motor', 'mobil'],
'color' => 'orange',
'icon' => '🚗',
'saran' => [
'actions' => ['Penataan area parkir', 'Tarif transparan', 'Petugas lebih ramah'],
'dampak' => 'Mengurangi keluhan',
'tip' => 'Sistem parkir lebih rapi dan transparan.',
],
],
'Harga / Tiket' => [
'keywords' => ['mahal', 'harga', 'tiket', 'bayar', 'biaya', 'tarif', 'terjangkau'],
'color' => 'yellow',
'icon' => '🎟️',
'saran' => [
'actions' => ['Evaluasi harga tiket', 'Promo wisata', 'Diskon tertentu'],
'dampak' => 'Daya tarik meningkat',
'tip' => 'Penyesuaian harga tiket agar lebih terjangkau.',
],
],
'Fasilitas' => [
'keywords' => ['fasilitas', 'gazebo', 'warung', 'kantin', 'kamar ganti', 'musholla', 'wifi', 'bangku', 'kursi'],
'color' => 'green',
'icon' => '🏗️',
'saran' => [
'actions' => ['Perbaiki fasilitas rusak', 'Tambah fasilitas umum', 'Perawatan berkala'],
'dampak' => 'Kenyamanan pengunjung meningkat',
'tip' => 'Fasilitas lengkap & terawat meningkatkan kepuasan.',
],
],
'Keramaian' => [
'keywords' => ['ramai', 'macet', 'antri', 'sesak', 'penuh', 'padat', 'berdesakan'],
'color' => 'blue',
'icon' => '👥',
'saran' => [
'actions' => ['Atur kapasitas pengunjung', 'Sistem antrean', 'Jam kunjungan fleksibel'],
'dampak' => 'Pengalaman pengunjung lebih nyaman',
'tip' => 'Manajemen kapasitas pengunjung lebih baik.',
],
],
];
public function index(Request $request)
{
$wisata = $request->wisata;
$destinasi = $request->destinasi;
// ================================================================
// AMBIL SEMUA PERIODE & PERIODE AKTIF
// ================================================================
$periodeList = DB::table('periode_analisis')->orderBy('id', 'desc')->get();
$periodeAktif = $request->periode_id
? DB::table('periode_analisis')->find($request->periode_id)
: DB::table('periode_analisis')->orderBy('id', 'desc')->first();
$periodeId = $periodeAktif->id ?? null;
$evaluasi = DB::table('evaluasi_model')
->latest()
->first();
// =============================
// TOTAL ULASAN (DARI TABEL ULASAN)
// =============================
$totalUlasan = DB::table('ulasan')
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) {
$q->where('wisata', $wisata);
})
->count();
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest()->first();
// =============================
// CEK ADA ANALISIS ATAU BELUM
// =============================
$hasAnalisis = DB::table('hasil_analisis')->count() > 0;
// ================================================================
// TOTAL ULASAN
// ================================================================
$totalUlasan = DB::table('ulasan')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->count();
// =============================
// HITUNG SENTIMEN (DARI HASIL ANALISIS)
// =============================
$positif = DB::table('hasil_analisis')
->where('sentimen', 'Positif')
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) {
$q->where('wisata', $wisata);
})
->count();
// ================================================================
// CEK ADA ANALISIS ATAU BELUM
// ================================================================
$hasAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->count() > 0;
$negatif = DB::table('hasil_analisis')
->where('sentimen', 'Negatif')
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) {
$q->where('wisata', $wisata);
})
->count();
// ================================================================
// HITUNG SENTIMEN
// ================================================================
$positif = DB::table('hasil_analisis')
->where('sentimen', 'Positif')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->count();
$netral = DB::table('hasil_analisis')
->where('sentimen', 'Netral')
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) {
$q->where('wisata', $wisata);
})
->count();
$negatif = DB::table('hasil_analisis')
->where('sentimen', 'Negatif')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->count();
// =============================
// PERSENTASE SENTIMEN
// =============================
$totalSentimen = $positif + $negatif + $netral;
$netral = DB::table('hasil_analisis')
->where('sentimen', 'Netral')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->count();
$stats = [
'total' => $totalUlasan,
'positif_persen' => $totalSentimen > 0 ? round(($positif / $totalSentimen) * 100) : 0,
'negatif_persen' => $totalSentimen > 0 ? round(($negatif / $totalSentimen) * 100) : 0,
'netral_persen' => $totalSentimen > 0 ? round(($netral / $totalSentimen) * 100) : 0,
];
$totalSentimen = $positif + $negatif + $netral;
// =============================
// GRAFIK PIE (Distribusi Sentimen)
// =============================
$chartSentimen = $hasAnalisis
? DB::table('hasil_analisis')
->select('sentimen', DB::raw('count(*) as total'))
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) {
$q->where('wisata', $wisata);
})
->groupBy('sentimen')
->orderByRaw("FIELD(sentimen, 'Positif', 'Negatif', 'Netral')") // 🔥 INI KUNCINYA
->get()
: collect();
$stats = [
'total' => $totalUlasan,
'positif_persen' => $totalSentimen > 0 ? round(($positif / $totalSentimen) * 100) : 0,
'negatif_persen' => $totalSentimen > 0 ? round(($negatif / $totalSentimen) * 100) : 0,
'netral_persen' => $totalSentimen > 0 ? round(($netral / $totalSentimen) * 100) : 0,
];
// =============================
// GRAFIK BAR (Sentimen per Destinasi)
// =============================
$chartDestinasi = $hasAnalisis
? DB::table('hasil_analisis')
->select(
'wisata',
DB::raw("SUM(CASE WHEN sentimen = 'Positif' THEN 1 ELSE 0 END) as positif"),
DB::raw("SUM(CASE WHEN sentimen = 'Negatif' THEN 1 ELSE 0 END) as negatif"),
DB::raw("SUM(CASE WHEN sentimen = 'Netral' THEN 1 ELSE 0 END) as netral")
)
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) {
$q->where('wisata', $wisata);
})
->groupBy('wisata')
->get()
: collect();
// ================================================================
// CHART DATA
// ================================================================
$chartSentimen = $hasAnalisis
? DB::table('hasil_analisis')
->select('sentimen', DB::raw('count(*) as total'))
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->groupBy('sentimen')
->orderByRaw("FIELD(sentimen, 'Positif', 'Negatif', 'Netral')")
->get()
: collect();
// =============================
// TOTAL DATA PER DESTINASI (DARI ULASAN)
// =============================
$totalPerWisata = DB::table('ulasan')
->select('wisata', DB::raw('count(*) as total'))
$chartDestinasi = $hasAnalisis
? DB::table('hasil_analisis')
->select(
'wisata',
DB::raw("SUM(CASE WHEN sentimen = 'Positif' THEN 1 ELSE 0 END) as positif"),
DB::raw("SUM(CASE WHEN sentimen = 'Negatif' THEN 1 ELSE 0 END) as negatif"),
DB::raw("SUM(CASE WHEN sentimen = 'Netral' THEN 1 ELSE 0 END) as netral")
)
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->groupBy('wisata')
->get();
->get()
: collect();
// =============================
// WORD CLOUD (DARI PREPROCESSING)
// =============================
$texts = DB::table('preprocessing_data')->pluck('stemming')->toArray();
$allText = count($texts) > 0 ? implode(' ', $texts) : '';
// ================================================================
// TOTAL PER DESTINASI + WORD CLOUD + LAST UPDATE
// ================================================================
$totalPerWisata = DB::table('ulasan')
->select('wisata', DB::raw('count(*) as total'))
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->groupBy('wisata')
->get();
// =============================
// LAST UPDATE
// =============================
$lastUpdate = DB::table('hasil_analisis')
->latest('created_at')
->value('created_at');
$texts = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->pluck('ulasan_bersih')
->toArray();
$allText = count($texts) > 0 ? implode(' ', $texts) : '';
$destinasiList = DB::table('ulasan')
->select('wisata')
->distinct()
->pluck('wisata');
$lastUpdate = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest('created_at')->value('created_at');
$hasil = DB::table('hasil_analisis')
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) {
$q->where('wisata', $wisata);
})
->paginate(10);
$destinasiList = DB::table('ulasan')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->select('wisata')->distinct()->pluck('wisata');
return view('dashboard', compact(
'stats',
'chartSentimen',
'chartDestinasi',
'totalPerWisata',
'allText',
'lastUpdate',
'hasAnalisis',
'destinasiList', // WAJIB
'hasil',
'evaluasi' // WAJIB
));
$hasil = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->paginate(10);
// ================================================================
// REKOMENDASI
// ================================================================
$ulasanNegatif = DB::table('hasil_analisis')
->where('sentimen', 'Negatif')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
->pluck('ulasan_bersih')
->toArray();
$textRek = strtolower(implode(' ', $ulasanNegatif));
$wordsRek = array_filter(preg_split('/\s+/', $textRek));
$issueSkor = [];
foreach ($this->issueRules as $kategori => $rule) {
$skor = 0;
foreach ($rule['keywords'] as $kw) {
$skor += substr_count($textRek, $kw);
}
$issueSkor[$kategori] = $skor;
}
arsort($issueSkor);
$totalSkorIsu = array_sum($issueSkor) ?: 1;
$isuUtama = [];
foreach (array_slice($issueSkor, 0, 5, true) as $nama => $skor) {
$isuUtama[] = [
'nama' => $nama,
'skor' => $skor,
'persen' => round(($skor / $totalSkorIsu) * 100),
'color' => $this->issueRules[$nama]['color'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
$stopwords = [
'yang','dan','di','ke','dari','untuk','dengan','ini','itu','tidak',
'ada','juga','sangat','lebih','sudah','bisa','tapi','karena','pada',
'akan','atau','saya','kami','mereka','nya','ga','gak','udah','pas',
'kita','sih','deh','aja','ya','yg','tp','bgt','jadi','biar','kalau',
];
$freq = array_count_values($wordsRek);
foreach ($stopwords as $sw) { unset($freq[$sw]); }
foreach (array_keys($freq) as $w) {
if (mb_strlen($w) < 3) unset($freq[$w]);
}
arsort($freq);
$kataDominan = array_slice($freq, 0, 10, true);
$saranPerbaikan = [];
foreach (array_slice($issueSkor, 0, 3, true) as $nama => $skor) {
$saranPerbaikan[] = [
'nama' => 'Perbaikan ' . $nama,
'tip' => $this->issueRules[$nama]['saran']['tip'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
$prioritas = [];
$rank = 1;
foreach (array_slice($issueSkor, 0, 3, true) as $nama => $skor) {
$prioritas[] = [
'rank' => $rank++,
'nama' => $nama,
'icon' => $this->issueRules[$nama]['icon'],
'actions' => $this->issueRules[$nama]['saran']['actions'],
'dampak' => $this->issueRules[$nama]['saran']['dampak'],
'color' => $this->issueRules[$nama]['color'],
];
}
$positifRek = DB::table('hasil_analisis')
->where('sentimen', 'Positif')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
->count();
$negatifRek = DB::table('hasil_analisis')
->where('sentimen', 'Negatif')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
->count();
$totalSentimenRek = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
->count() ?: 1;
$totalNegatif = $negatifRek;
$persenNegatif = round(($negatifRek / $totalSentimenRek) * 100, 2);
$tingkatKepuasan = round(($positifRek / $totalSentimenRek) * 100);
$labelKepuasan = match(true) {
$tingkatKepuasan >= 80 => 'Baik',
$tingkatKepuasan >= 60 => 'Sedang',
default => 'Kurang',
};
$isuDominan = $isuUtama[0]['nama'] ?? '-';
$isuDominanPersen = $isuUtama[0]['persen'] ?? 0;
// ================================================================
// RETURN VIEW
// ================================================================
return view('dashboard', compact(
'stats',
'chartSentimen',
'chartDestinasi',
'totalPerWisata',
'allText',
'lastUpdate',
'hasAnalisis',
'destinasiList',
'hasil',
'evaluasi',
'totalNegatif',
'persenNegatif',
'tingkatKepuasan',
'labelKepuasan',
'isuUtama',
'isuDominan',
'isuDominanPersen',
'kataDominan',
'saranPerbaikan',
'prioritas',
'periodeList', // ← tambahan
'periodeAktif', // ← tambahan
));
}
}

View File

@ -8,65 +8,208 @@
class RekomendasiController extends Controller
{
/**
* Rule-based: mapping kategori isu ke keyword terkait
*/
private array $issueRules = [
'Kebersihan' => [
'keywords' => ['kotor', 'sampah', 'bersih', 'jorok', 'kumuh', 'toilet', 'wc', 'kamar mandi', 'limbah', 'bau'],
'color' => 'red',
'icon' => '🧹',
'saran' => [
'actions' => ['Tambah tempat sampah', 'Jadwal pembersihan rutin', 'Petugas kebersihan tambahan'],
'dampak' => 'Kepuasan meningkat signifikan',
'tip' => 'Tambah tempat sampah & jadwal pembersihan rutin.',
],
],
'Parkir' => [
'keywords' => ['parkir', 'lahan parkir', 'tempat parkir', 'parkiran', 'motor', 'mobil'],
'color' => 'orange',
'icon' => '🚗',
'saran' => [
'actions' => ['Penataan area parkir', 'Tarif transparan', 'Petugas lebih ramah'],
'dampak' => 'Mengurangi keluhan',
'tip' => 'Sistem parkir lebih rapi dan transparan.',
],
],
'Harga / Tiket' => [
'keywords' => ['mahal', 'harga', 'tiket', 'bayar', 'biaya', 'tarif', 'murah', 'terjangkau'],
'color' => 'yellow',
'icon' => '🎟️',
'saran' => [
'actions' => ['Evaluasi harga tiket', 'Promo wisata', 'Diskon tertentu'],
'dampak' => 'Daya tarik meningkat',
'tip' => 'Penyesuaian harga tiket agar lebih terjangkau.',
],
],
'Fasilitas' => [
'keywords' => ['fasilitas', 'gazebo', 'warung', 'kantin', 'kamar ganti', 'musholla', 'wifi', 'bangku', 'kursi'],
'color' => 'green',
'icon' => '🏗️',
'saran' => [
'actions' => ['Perbaiki fasilitas rusak', 'Tambah fasilitas umum', 'Perawatan berkala'],
'dampak' => 'Kenyamanan pengunjung meningkat',
'tip' => 'Fasilitas lengkap & terawat meningkatkan kepuasan.',
],
],
'Keramaian' => [
'keywords' => ['ramai', 'macet', 'antri', 'sesak', 'penuh', 'padat', 'berdesakan'],
'color' => 'blue',
'icon' => '👥',
'saran' => [
'actions' => ['Atur kapasitas pengunjung', 'Sistem antrean', 'Jam kunjungan fleksibel'],
'dampak' => 'Pengalaman pengunjung lebih nyaman',
'tip' => 'Manajemen kapasitas pengunjung lebih baik.',
],
],
];
public function index(Request $request)
{
// dropdown destinasi unik
$destinasiList = HasilAnalisis::select('wisata')
// --- 1. Dropdown destinasi ---
$destinasiList = DB::table('hasil_analisis')
->select('wisata')
->distinct()
->pluck('wisata');
// query utama
$query = HasilAnalisis::query();
// --- 2. Query dengan filter destinasi ---
$queryAll = HasilAnalisis::query();
$queryFilter = HasilAnalisis::query();
// filter destinasi jika dipilih
if ($request->filled('destinasi')) {
$query->where('wisata', $request->destinasi);
$queryFilter->where('wisata', $request->destinasi);
}
// ambil ulasan negatif
$negatif = $query->where('sentimen', 'negatif')
->pluck('ulasan_terolah')
// --- 3. Hitung total & sentimen ---
$totalUlasan = $queryFilter->count();
$totalNegatif = (clone $queryFilter)->where('sentimen', 'negatif')->count();
$totalPositif = (clone $queryFilter)->where('sentimen', 'positif')->count();
$totalNetral = (clone $queryFilter)->where('sentimen', 'netral')->count();
$persenNegatif = $totalUlasan > 0 ? round(($totalNegatif / $totalUlasan) * 100, 2) : 0;
$tingkatKepuasan = $totalUlasan > 0 ? round(($totalPositif / $totalUlasan) * 100) : 0;
// Label kepuasan
$labelKepuasan = match(true) {
$tingkatKepuasan >= 80 => 'Baik',
$tingkatKepuasan >= 60 => 'Sedang',
default => 'Kurang',
};
// --- 4. Ambil ulasan negatif & bersihkan teks ---
$ulasanNegatif = (clone $queryFilter)
->where('sentimen', 'negatif')
->pluck('ulasan_bersih') // gunakan kolom yang sudah dipreproses
->toArray();
// gabungkan semua ulasan jadi 1 string
$text = strtolower(implode(" ", $negatif));
$text = strtolower(implode(' ', $ulasanNegatif));
$words = array_filter(preg_split('/\s+/', $text));
// token kata sederhana
$words = array_filter(explode(" ", $text));
// --- 5. Rule-based: hitung skor tiap kategori isu ---
$issueSkor = [];
$issueWords = []; // kata dominan per kategori
foreach ($this->issueRules as $kategori => $rule) {
$skor = 0;
$matchWords = [];
foreach ($rule['keywords'] as $kw) {
$count = substr_count($text, $kw);
if ($count > 0) {
$skor += $count;
$matchWords[$kw] = $count;
}
}
$issueSkor[$kategori] = $skor;
$issueWords[$kategori] = $matchWords;
}
// Urutkan berdasarkan skor tertinggi
arsort($issueSkor);
$totalSkorIsu = array_sum($issueSkor) ?: 1; // hindari division by zero
// --- 6. Isu utama (top 5) dengan persentase ---
$isuUtama = [];
foreach (array_slice($issueSkor, 0, 5, true) as $nama => $skor) {
$isuUtama[] = [
'nama' => $nama,
'skor' => $skor,
'persen' => round(($skor / $totalSkorIsu) * 100),
'color' => $this->issueRules[$nama]['color'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
// --- 7. Isu dominan (ranking 1) ---
$isuDominan = $isuUtama[0]['nama'] ?? 'Belum ada isu';
$isuDominanPersen = $isuUtama[0]['persen'] ?? 0;
// --- 8. Kata kunci dominan (global, top 10, filter stopword) ---
$stopwords = [
'yang', 'dan', 'di', 'ke', 'dari', 'untuk', 'dengan',
'ini', 'itu', 'tidak', 'ada', 'juga', 'sangat', 'lebih',
'sudah', 'bisa', 'tapi', 'karena', 'pada', 'akan', 'atau',
'saya', 'kami', 'mereka', 'nya', 'ga', 'gak', 'udah', 'pas',
'kita', 'sih', 'deh', 'aja', 'ya', 'yg', 'tp', 'bgt',
];
// hitung frekuensi kata
$freq = array_count_values($words);
foreach ($stopwords as $sw) {
unset($freq[$sw]);
}
// filter kata pendek (< 3 karakter)
foreach (array_keys($freq) as $w) {
if (mb_strlen($w) < 3) unset($freq[$w]);
}
// urutkan kata paling sering
arsort($freq);
$kataDominan = array_slice($freq, 0, 10, true);
// ambil top 5 keyword
$topKeywords = array_slice(array_keys($freq), 0, 5);
// --- 9. Prioritas rekomendasi (top 3 isu) ---
$prioritas = [];
$rank = 1;
foreach (array_slice($issueSkor, 0, 3, true) as $nama => $skor) {
$prioritas[] = [
'rank' => $rank++,
'nama' => $nama,
'icon' => $this->issueRules[$nama]['icon'],
'actions' => $this->issueRules[$nama]['saran']['actions'],
'dampak' => $this->issueRules[$nama]['saran']['dampak'],
'color' => $this->issueRules[$nama]['color'],
];
}
// buat output card
$isuUtama = $topKeywords[0] ?? "Belum ada isu";
$kataDominan = implode(", ", $topKeywords);
// --- 10. Saran perbaikan (top 3) ---
$saranPerbaikan = [];
foreach (array_slice($issueSkor, 0, 3, true) as $nama => $skor) {
$saranPerbaikan[] = [
'nama' => 'Perbaikan ' . $nama,
'tip' => $this->issueRules[$nama]['saran']['tip'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
// saran otomatis sederhana
$saran = "Perlu peningkatan layanan terkait: " . $isuUtama;
// --- 11. Filter destinasi yang sedang aktif ---
$destinasiAktif = $request->input('destinasi', 'Semua Destinasi');
$destinasiList = DB::table('hasil_analisis')
->select('wisata')
->distinct()
->pluck('wisata');
return view('dashboard', compact(
'stats',
'chartSentimen',
'chartDestinasi',
'totalPerWisata',
'allText',
'lastUpdate',
'hasAnalisis',
'wisataList',
'hasil',
'destinasiList' // ✅ TAMBAH INI
));
return view('rekomendasi.index', compact(
'destinasiList',
'destinasiAktif',
'totalUlasan',
'totalNegatif',
'totalPositif',
'totalNetral',
'persenNegatif',
'tingkatKepuasan',
'labelKepuasan',
'isuUtama',
'isuDominan',
'isuDominanPersen',
'kataDominan',
'saranPerbaikan',
'prioritas',
));
}
}
}

View File

@ -12,6 +12,7 @@ class UlasanController extends Controller
{
public function index(Request $request)
{
$periodeList = DB::table('periode_analisis')->orderBy('id', 'desc')->get();
$query = Ulasan::query();
// 🔍 SEARCH (ulasan + wisata)
@ -29,87 +30,116 @@ public function index(Request $request)
$query->where('wisata', 'LIKE', '%' . $request->wisata . '%');
}
$mentah = $query->latest()->paginate(20)->withQueryString();
// OPTIONAL
$preprocessing = PreprocessingData::latest()->paginate(30, ['*'], 'pre_page');
// 🔥 RINGKASAN DATA
$totalUlasan = Ulasan::count();
$totalWisata = Ulasan::distinct('wisata')->count('wisata');
$lastUpdate = \App\Models\Ulasan::latest()->value('created_at');
return view('ulasan.index', compact(
'mentah',
'preprocessing',
'totalUlasan',
'totalWisata',
'lastUpdate'
));
}
public function upload(Request $request)
{
$request->validate([
'file' => 'required|mimes:csv,txt,xlsx,xls|max:10240'
]);
$file = $request->file('file');
$extension = strtolower($file->getClientOriginalExtension());
if (in_array($extension, ['xlsx', 'xls'])) {
$data = Excel::toArray(null, $file);
$rows = $data[0] ?? [];
unset($rows[0]);
foreach ($rows as $row) {
if (empty($row) || count($row) < 4) continue;
DB::table('ulasan')->insert([
'wisata' => $row[0] ?? null,
'rating' => isset($row[1]) ? (float) $row[1] : 0,
'ulasan' => $row[2] ?? '',
'tanggal' => isset($row[3]) ? date('Y-m-d H:i:s', strtotime($row[3])) : now(),
'sentimen' => null,
'reviewer' => 'anonymous',
'created_at' => now(),
'updated_at' => now(),
]);
}
} else {
$data = array_map(fn($line) => str_getcsv($line, ';'), file($file));
unset($data[0]);
foreach ($data as $row) {
if (empty($row) || count($row) < 4) continue;
DB::table('ulasan')->insert([
'wisata' => $row[0] ?? null,
'rating' => isset($row[1]) ? (float) $row[1] : 0,
'ulasan' => $row[2] ?? '',
'tanggal' => isset($row[3]) ? date('Y-m-d H:i:s', strtotime($row[3])) : now(),
'sentimen' => null,
'reviewer' => 'anonymous',
'created_at' => now(),
'updated_at' => now(),
]);
}
// ← filter periode
if ($request->filled('periode_id')) {
$query->where('periode_id', $request->periode_id);
}
return redirect()->route('ulasan.index')
->with('success', 'Upload berhasil dan data masuk!');
$mentah = $query->orderBy('tanggal', 'desc')->paginate(20)->withQueryString();
// OPTIONAL
$preprocessing = PreprocessingData::latest()->paginate(20, ['*'], 'pre_page');
// 🔥 RINGKASAN DATA
$totalUlasan = Ulasan::when($request->filled('periode_id'), fn($q) => $q->where('periode_id', $request->periode_id))->count();
$totalWisata = Ulasan::when($request->filled('periode_id'), fn($q) => $q->where('periode_id', $request->periode_id))->distinct('wisata')->count('wisata');
$lastUpdate = Ulasan::when($request->filled('periode_id'), fn($q) => $q->where('periode_id', $request->periode_id))->latest()->value('created_at');
return view('ulasan.index', compact(
'mentah',
'totalUlasan',
'totalWisata',
'lastUpdate',
'periodeList'
));
}
public function analisisData()
{
$pythonPath = '"C:\\Users\\Mufrida Farah\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"';
$scriptPath = '"C:\\laragon\\www\\Sentara\\scraper\\preprocessing.py"';
public function upload(Request $request)
{
$request->validate([
'file' => 'required|mimes:csv,txt,xlsx,xls|max:10240'
]);
shell_exec($pythonPath . " " . $scriptPath . " 2>&1");
// ✅ OTOMATIS BUAT / AMBIL PERIODE BULAN INI
$bulan = now()->month;
$tahun = now()->year;
$namaBulan = now()->translatedFormat('F Y'); // contoh: "Mei 2026"
return redirect()->route('ulasan.index')
->with('success', 'Analisis berhasil dijalankan');
$periode = DB::table('periode_analisis')
->where('bulan', $bulan)
->where('tahun', $tahun)
->first();
if (!$periode) {
$periodeId = DB::table('periode_analisis')->insertGetId([
'nama' => $namaBulan,
'bulan' => $bulan,
'tahun' => $tahun,
'created_at' => now(),
]);
} else {
$periodeId = $periode->id;
}
// ✅ PROSES FILE
$file = $request->file('file');
$extension = strtolower($file->getClientOriginalExtension());
if (in_array($extension, ['xlsx', 'xls'])) {
$data = Excel::toArray(null, $file);
$rows = $data[0] ?? [];
unset($rows[0]);
foreach ($rows as $row) {
if (empty($row) || count($row) < 4) continue;
DB::table('ulasan')->insert([
'wisata' => $row[0] ?? null,
'rating' => isset($row[1]) ? (float) $row[1] : 0,
'ulasan' => $row[2] ?? '',
'tanggal' => isset($row[3]) ? date('Y-m-d H:i:s', strtotime($row[3])) : now(),
'sentimen' => null,
'reviewer' => 'anonymous',
'periode_id' => $periodeId, // ← tag periode
'created_at' => now(),
'updated_at' => now(),
]);
}
} else {
$data = array_map(fn($line) => str_getcsv($line, ';'), file($file));
unset($data[0]);
foreach ($data as $row) {
if (empty($row) || count($row) < 4) continue;
DB::table('ulasan')->insert([
'wisata' => $row[0] ?? null,
'rating' => isset($row[1]) ? (float) $row[1] : 0,
'ulasan' => $row[2] ?? '',
'tanggal' => isset($row[3]) ? date('Y-m-d H:i:s', strtotime($row[3])) : now(),
'sentimen' => null,
'reviewer' => 'anonymous',
'periode_id' => $periodeId, // ← tag periode
'created_at' => now(),
'updated_at' => now(),
]);
}
}
return redirect()->route('ulasan.index')
->with('success', 'Upload berhasil! Data masuk ke periode ' . $namaBulan);
}
public function analisisData()
{
$pythonPath = "C:\\Users\\Mufrida Farah\\AppData\\Local\\Programs\\Python\\Python312\\python.exe";
$scriptPath = base_path('scraper/analisis.py');
$command = "\"$pythonPath\" \"$scriptPath\" 2>&1";
$output = shell_exec($command);
return redirect()->route('analisis.index')
->with('success', 'Analisis berhasil dijalankan');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EvaluasiModel extends Model
{
protected $table = 'evaluasi_model';
protected $fillable = [
'precision',
'recall',
'f1_score',
'accuracy',
'tp',
'tn',
'fp',
'fn'
];
}

View File

@ -2,19 +2,18 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class HasilAnalisis extends Model
{
use HasFactory;
protected $table = 'hasil_analisis';
protected $fillable = [
'wisata',
'ulasan_terolah',
'ulasan_asli', // ← fix dari ulasan_terolah
'ulasan_bersih', // ← tambah ini
'hasil_preprocessing',
'sentimen',
'probabilitas'
];
}
}

View File

@ -21,6 +21,7 @@ public function register(): void
public function boot(): void
{
Paginator::useTailwind();
\Carbon\Carbon::setLocale('id');
//
}
}

View File

@ -65,7 +65,7 @@
|
*/
'timezone' => 'UTC',
'timezone' => 'Asia/Jakarta',
/*
|--------------------------------------------------------------------------

View File

@ -8,9 +8,6 @@
Berikut adalah hasil evaluasi model dan detail analisis sentimen terhadap ulasan wisata.
</p>
</div>
<!-- <div class="text-sm text-gray-500">
{{ now()->format('d F Y - H:i') }}
</div> -->
</div>
{{-- ================= METRICS ================= --}}
@ -19,28 +16,28 @@
<div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">Precision</p>
<h3 class="text-2xl font-bold text-blue-600">
{{ $evaluasi->precision ?? 0 }}
{{ number_format($evaluasi->precision ?? 0, 2) }}
</h3>
</div>
<div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">Recall</p>
<h3 class="text-2xl font-bold text-green-600">
{{ $evaluasi->recall ?? 0 }}
{{ number_format($evaluasi->recall ?? 0, 2) }}
</h3>
</div>
<div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">F1 Score</p>
<h3 class="text-2xl font-bold text-purple-600">
{{ $evaluasi->f1_score ?? 0 }}
{{ number_format($evaluasi->f1_score ?? 0, 2) }}
</h3>
</div>
<div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">Akurasi</p>
<h3 class="text-2xl font-bold text-indigo-600">
{{ $evaluasi->accuracy ?? 0 }}
{{ number_format($evaluasi->accuracy ?? 0, 2) }}
</h3>
</div>
@ -66,8 +63,8 @@
<tr>
<th class="bg-gray-50">Positif</th>
<td class="bg-green-100">{{ $evaluasi->tp ?? 0 }}</td>
<td class="bg-red-100">{{ $evaluasi->fn ?? 0 }}</td>
<td class="bg-yellow-100">0</td>
<td class="bg-red-100">{{ $evaluasi->fp ?? 0 }}</td>
<td class="bg-yellow-100">{{ $evaluasi->fn ?? 0 }}</td>
</tr>
<tr>
<th class="bg-gray-50">Negatif</th>
@ -96,29 +93,28 @@
<th>Precision</th>
<th>Recall</th>
<th>F1</th>
<th>Support</th>
</tr>
</thead>
<tbody>
<tr>
<td class="text-green-600 font-semibold">Positif</td>
<td>{{ $evaluasi->precision ?? 0 }}</td>
<td>{{ $evaluasi->recall ?? 0 }}</td>
<td>{{ $evaluasi->f1_score ?? 0 }}</td>
<td>{{ number_format($evaluasi->precision ?? 0, 2) }}</td>
<td>{{ number_format($evaluasi->recall ?? 0, 2) }}</td>
<td>{{ number_format($evaluasi->f1_score ?? 0, 2) }}</td>
<td>-</td>
</tr>
<tr>
<td class="text-red-600 font-semibold">Negatif</td>
<td>{{ $evaluasi->precision ?? 0 }}</td>
<td>{{ $evaluasi->recall ?? 0 }}</td>
<td>{{ $evaluasi->f1_score ?? 0 }}</td>
<td>{{ number_format($evaluasi->precision ?? 0, 2) }}</td>
<td>{{ number_format($evaluasi->recall ?? 0, 2) }}</td>
<td>{{ number_format($evaluasi->f1_score ?? 0, 2) }}</td>
<td>-</td>
</tr>
<tr>
<td class="text-yellow-600 font-semibold">Netral</td>
<td>{{ $evaluasi->precision ?? 0 }}</td>
<td>{{ $evaluasi->recall ?? 0 }}</td>
<td>{{ $evaluasi->f1_score ?? 0 }}</td>
<td>{{ number_format($evaluasi->precision ?? 0, 2) }}</td>
<td>{{ number_format($evaluasi->recall ?? 0, 2) }}</td>
<td>{{ number_format($evaluasi->f1_score ?? 0, 2) }}</td>
<td>-</td>
</tr>
</tbody>
@ -128,25 +124,23 @@
</div>
{{-- ================= FILTER ================= --}}
<form id="filter-form" class="flex items-center gap-3">
<form id="filter-form" class="flex items-center gap-3">
<input type="hidden" name="tab" value="analisis">
<input type="hidden" name="tab" value="analisis">
<select name="wisata" class="border rounded px-3 py-2">
<option value="">Semua Destinasi</option>
<select name="wisata" class="border rounded px-3 py-2">
<option value="">Semua Destinasi</option>
<option value="Pantai Papuma" {{ request('wisata') == 'Pantai Papuma' ? 'selected' : '' }}>Pantai Papuma</option>
<option value="Pantai Watu Ulo" {{ request('wisata') == 'Pantai Watu Ulo' ? 'selected' : '' }}>Pantai Watu Ulo</option>
<option value="Teluk Love" {{ request('wisata') == 'Teluk Love' ? 'selected' : '' }}>Teluk Love</option>
<option value="Wisata Kebun Teh Gunung Gambir" {{ request('wisata') == 'Wisata Kebun Teh Gunung Gambir' ? 'selected' : '' }}>Wisata Kebun Teh Gunung Gambir</option>
</select>
@foreach($destinasiList as $d)
<option value="{{ $d }}" {{ request('wisata') == $d ? 'selected' : '' }}>
{{ $d }}
</option>
@endforeach
</select>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">
Filter
</button>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">
Filter
</button>
</form>
</form>
{{-- ================= TABLE ================= --}}
<div class="bg-white rounded-xl shadow p-4">
@ -158,102 +152,100 @@
<tr>
<th>No</th>
<th>Wisata</th>
<th>Ulasan</th>
<th>Ulasan Asli</th>
<th>Ulasan Bersih</th>
<th>Sentimen</th>
<th>Probabilitas</th>
</tr>
</thead>
<tbody>
@forelse($hasil as $index => $item)
@forelse($hasil as $item)
<tr class="border-b">
<td>{{ $loop->iteration }}</td>
<td>{{ ($hasil->currentPage() - 1) * $hasil->perPage() + $loop->iteration }}</td>
<td>{{ $item->wisata }}</td>
<td>{{ Str::limit($item->ulasan_terolah, 80) }}</td>
<td class="text-gray-500">{{ Str::limit($item->ulasan_asli ?? '-', 80) }}</td>
<td>{{ Str::limit($item->hasil_preprocessing ?? '-', 80) }}</td>
<td>
<span class="
px-2 py-1 rounded text-xs
{{ $item->sentimen == 'Positif' ? 'bg-green-100 text-green-600' : '' }}
{{ $item->sentimen == 'Negatif' ? 'bg-red-100 text-red-600' : '' }}
{{ $item->sentimen == 'Netral' ? 'bg-yellow-100 text-yellow-600' : '' }}
<span class="px-2 py-1 rounded text-xs
{{ strtolower($item->sentimen) == 'positif' ? 'bg-green-100 text-green-600' : '' }}
{{ strtolower($item->sentimen) == 'negatif' ? 'bg-red-100 text-red-600' : '' }}
{{ strtolower($item->sentimen) == 'netral' ? 'bg-yellow-100 text-yellow-600' : '' }}
">
{{ $item->sentimen }}
{{ ucfirst($item->sentimen) }}
</span>
</td>
<td>{{ $item->probabilitas ?? '-' }}</td>
<td>{{ number_format($item->probabilitas ?? 0, 2) }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-4 text-gray-500">
Tidak ada data
</td>
<td colspan="6" class="text-center py-4 text-gray-500">Tidak ada data</td>
</tr>
@endforelse
</tbody>
</table>
{{-- PAGINATION --}}
<div class="mt-6 flex items-center justify-between text-sm text-gray-500">
{{-- ================= PAGINATION ================= --}}
@if($hasil->hasPages())
@php
$current = $hasil->currentPage();
$last = $hasil->lastPage();
$query = http_build_query(request()->except('page'));
// Window 2 halaman kiri & kanan dari current
$window = collect(range(max(1, $current - 2), min($last, $current + 2)));
@endphp
{{-- INFO --}}
<div>
Menampilkan {{ $hasil->firstItem() }} - {{ $hasil->lastItem() }}
dari {{ $hasil->total() }} data
</div>
<div class="mt-4 flex items-center justify-between text-sm text-gray-600">
{{-- PAGINATION --}}
{{-- PAGINATION --}}
<div class="flex items-center gap-1">
{{-- Info --}}
<p>Menampilkan {{ $hasil->firstItem() }} - {{ $hasil->lastItem() }} dari {{ $hasil->total() }} data</p>
{{-- PREV --}}
@if ($hasil->onFirstPage())
<span class="px-3 py-1 rounded border text-gray-300"></span>
@else
<a href="{{ $hasil->appends(request()->query())->previousPageUrl() }}"
class="px-3 py-1 rounded border hover:bg-gray-100"></a>
@endif
{{-- Tombol --}}
<div class="flex items-center gap-1">
{{-- PAGE NUMBERS --}}
@foreach ($hasil->getUrlRange(1, $hasil->lastPage()) as $page => $url)
{{-- « --}}
@if($hasil->onFirstPage())
<span class="px-3 py-1 rounded border bg-gray-100 text-gray-400 cursor-not-allowed">&laquo;</span>
@else
<a href="{{ $hasil->previousPageUrl() }}&{{ $query }}" class="px-3 py-1 rounded border hover:bg-blue-50 text-blue-600">&laquo;</a>
@endif
@php
$query = request()->query();
$query['page'] = $page;
$customUrl = url()->current() . '?' . http_build_query($query);
@endphp
{{-- Halaman 1 (jika tidak ada di window) --}}
@if(!$window->contains(1))
<a href="{{ $hasil->url(1) }}&{{ $query }}" class="px-3 py-1 rounded border hover:bg-blue-50 text-blue-600">1</a>
@if($window->min() > 2)
<span class="px-2 py-1 text-gray-400">...</span>
@endif
@endif
@if ($page == $hasil->currentPage())
<span class="px-3 py-1 rounded bg-blue-600 text-white">
{{ $page }}
</span>
{{-- Window --}}
@foreach($window as $page)
@if($page == $current)
<span class="px-3 py-1 rounded border bg-blue-600 text-white font-semibold">{{ $page }}</span>
@else
<a href="{{ $hasil->url($page) }}&{{ $query }}" class="px-3 py-1 rounded border hover:bg-blue-50 text-blue-600">{{ $page }}</a>
@endif
@endforeach
@elseif ($page <= 3 || $page > $hasil->lastPage()-3 || abs($page - $hasil->currentPage()) <= 1)
{{-- Halaman terakhir (jika tidak ada di window) --}}
@if(!$window->contains($last))
@if($window->max() < $last - 1)
<span class="px-2 py-1 text-gray-400">...</span>
@endif
<a href="{{ $hasil->url($last) }}&{{ $query }}" class="px-3 py-1 rounded border hover:bg-blue-50 text-blue-600">{{ $last }}</a>
@endif
<a href="{{ $customUrl }}"
class="px-3 py-1 rounded border hover:bg-gray-100">
{{ $page }}
</a>
{{-- » --}}
@if($hasil->hasMorePages())
<a href="{{ $hasil->nextPageUrl() }}&{{ $query }}" class="px-3 py-1 rounded border hover:bg-blue-50 text-blue-600">&raquo;</a>
@else
<span class="px-3 py-1 rounded border bg-gray-100 text-gray-400 cursor-not-allowed">&raquo;</span>
@endif
@elseif ($page == 4 || $page == $hasil->lastPage()-3)
<span class="px-2">...</span>
</div>
</div>
@endif
@endforeach
{{-- NEXT --}}
@if ($hasil->hasMorePages())
<a href="{{ $hasil->appends(request()->query())->nextPageUrl() }}"
class="px-3 py-1 rounded border hover:bg-gray-100"></a>
@else
<span class="px-3 py-1 rounded border text-gray-300"></span>
@endif
</div>
</div>
</div>
</div>

View File

@ -11,264 +11,243 @@
</div>
@endif
<div x-data="{ tab: 'dashboard' }" class="mb-6">
<!-- TAB -->
<div class="bg-blue-700 rounded-xl p-1 flex space-x-2 shadow mb-6">
<button @click="tab='dashboard'"
:class="tab==='dashboard'
? 'bg-white text-blue-700 shadow'
: 'text-white hover:bg-blue-600'"
class="flex-1 py-3 rounded-lg text-sm font-medium transition">
Dashboard Sentara
</button>
<button @click="tab='analisis'"
:class="tab==='analisis'
? 'bg-white text-blue-700 shadow'
: 'text-white hover:bg-blue-600'"
class="flex-1 py-3 rounded-lg text-sm font-medium transition">
Hasil Analisis Sentimen
</button>
<button @click="tab='rekomendasi'"
:class="tab==='rekomendasi'
? 'bg-white text-blue-700 shadow'
: 'text-white hover:bg-blue-600'"
class="flex-1 py-3 rounded-lg text-sm font-medium transition">
Rekomendasi Layanan
</button>
</div>
<div x-show="tab === 'dashboard'">
{{-- Statistik Cards --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Total Ulasan</p>
<h3 class="text-2xl font-bold mt-2 text-gray-900">
{{ $stats['total'] }}
</h3>
{{-- DROPDOWN PERIODE --}}
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-sm text-gray-600 font-medium">Periode Analisis:</span>
<form method="GET" action="{{ route('dashboard') }}" id="periode-form">
<input type="hidden" name="tab" id="tab-hidden" value="{{ request('tab', 'dashboard') }}">
<input type="hidden" name="wisata" value="{{ request('wisata') }}">
<input type="hidden" name="destinasi" value="{{ request('destinasi') }}">
<select name="periode_id" onchange="document.getElementById('periode-form').submit()"
class="border rounded-lg px-3 py-2 text-sm bg-white shadow-sm">
@foreach($periodeList as $p)
<option value="{{ $p->id }}" {{ ($periodeAktif && $periodeAktif->id == $p->id) ? 'selected' : '' }}>
{{ $p->nama }}
</option>
@endforeach
</select>
</form>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Sentimen Positif</p>
<h3 class="text-2xl font-bold mt-2 text-green-600">
{{ $stats['positif_persen'] }}%
</h3>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Sentimen Negatif</p>
<h3 class="text-2xl font-bold mt-2 text-red-600">
{{ $stats['negatif_persen'] }}%
</h3>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Sentimen Netral</p>
<h3 class="text-2xl font-bold mt-2 text-yellow-500">
{{ $stats['netral_persen'] }}%
</h3>
</div>
@if($periodeAktif)
<span class="text-xs text-gray-400">
Data dianalisis: {{ \Carbon\Carbon::parse($periodeAktif->created_at)->format('d M Y') }}
</span>
@endif
</div>
{{-- Chart Section --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<div x-data="{ tab: new URLSearchParams(window.location.search).get('tab') || 'dashboard' }" class="mb-6">
<!-- Pie Chart -->
<div class="bg-white rounded-xl shadow p-6">
<h3 class="font-semibold mb-4 text-gray-700">Distribusi Sentimen</h3>
<div style="height:300px;">
<canvas id="pieChart"></canvas>
</div>
<!-- TAB -->
<div class="bg-blue-700 rounded-xl p-1 flex space-x-2 shadow mb-6">
<button @click="tab='dashboard'; document.getElementById('tab-hidden').value='dashboard'"
:class="tab==='dashboard' ? 'bg-white text-blue-700 shadow' : 'text-white hover:bg-blue-600'"
class="flex-1 py-3 rounded-lg text-sm font-medium transition">
Dashboard Sentara
</button>
<button @click="tab='analisis'; document.getElementById('tab-hidden').value='analisis'"
:class="tab==='analisis' ? 'bg-white text-blue-700 shadow' : 'text-white hover:bg-blue-600'"
class="flex-1 py-3 rounded-lg text-sm font-medium transition">
Hasil Analisis Sentimen
</button>
<button @click="tab='rekomendasi'; document.getElementById('tab-hidden').value='rekomendasi'"
:class="tab==='rekomendasi' ? 'bg-white text-blue-700 shadow' : 'text-white hover:bg-blue-600'"
class="flex-1 py-3 rounded-lg text-sm font-medium transition">
Rekomendasi Layanan
</button>
</div>
<!-- Bar Chart -->
<div class="bg-white rounded-xl shadow p-6">
<h3 class="font-semibold mb-4 text-gray-700">Sentimen per Destinasi</h3>
<div style="height:300px;">
<canvas id="barChart"></canvas>
</div>
</div>
<!-- ================= DASHBOARD ================= -->
<div x-show="tab === 'dashboard'">
</div>
{{-- Section Bawah --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Total Data per Destinasi -->
<div class="bg-white rounded-xl shadow p-6 h-[320px]">
<h3 class="font-semibold mb-6 text-gray-700">
Total Data per Destinasi
</h3>
<div class="space-y-4 text-sm text-gray-600">
@foreach($totalPerWisata as $item)
<div class="flex justify-between border-b pb-3">
<span>{{ $item->wisata }}</span>
<span class="font-semibold text-gray-800">
{{ $item->total }} ulasan
</span>
</div>
@endforeach
{{-- Statistik Cards --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Total Ulasan</p>
<h3 class="text-2xl font-bold mt-2 text-gray-900">{{ $stats['total'] }}</h3>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Sentimen Positif</p>
<h3 class="text-2xl font-bold mt-2 text-green-600">{{ $stats['positif_persen'] }}%</h3>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Sentimen Negatif</p>
<h3 class="text-2xl font-bold mt-2 text-red-600">{{ $stats['negatif_persen'] }}%</h3>
</div>
<div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Sentimen Netral</p>
<h3 class="text-2xl font-bold mt-2 text-yellow-500">{{ $stats['netral_persen'] }}%</h3>
</div>
</div>
</div>
<!-- WORD CLOUD -->
<div class="bg-white rounded-xl shadow p-6 h-[320px]">
<h3 class="font-semibold mb-6 text-gray-700">
Word Cloud
</h3>
<div id="wordCloud" class="w-full h-[240px] overflow-hidden"></div>
</div>
{{-- Chart Section --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
</div>
{{-- PIE CHART --}}
<div class="bg-white rounded-xl shadow p-6">
<h3 class="font-semibold mb-4 text-gray-700">Distribusi Sentimen</h3>
<div style="height:300px;">
<canvas id="pieChart"></canvas>
</div>
</div>
{{-- BAR CHART - PURE HTML --}}
{{-- BAR CHART - PURE HTML VERTICAL --}}
<div class="bg-white rounded-xl shadow p-6">
<h3 class="font-semibold mb-4 text-gray-700">Sentimen per Destinasi</h3>
{{-- Legend --}}
<div class="flex gap-4 mb-4 text-xs">
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-blue-500 inline-block"></span> Positif</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-red-400 inline-block"></span> Negatif</span>
<span class="flex items-center gap-1"><span class="w-3 h-3 rounded bg-yellow-400 inline-block"></span> Netral</span>
</div>
@php
$maxVal = 1;
foreach($chartDestinasi as $item) {
$maxVal = max($maxVal, $item->positif, $item->negatif, $item->netral);
}
@endphp
<div class="flex items-end gap-4 overflow-x-auto" style="height:220px;">
@foreach($chartDestinasi as $item)
@php
$hPos = round(($item->positif / $maxVal) * 180);
$hNeg = round(($item->negatif / $maxVal) * 180);
$hNet = round(($item->netral / $maxVal) * 180);
$label = strlen($item->wisata) > 12
? substr($item->wisata, 0, 12) . '...'
: $item->wisata;
@endphp
<div class="flex flex-col items-center flex-1 min-w-[60px]">
<div class="flex items-end gap-1 w-full justify-center" style="height:180px;">
{{-- Positif --}}
<div class="relative group flex-1" style="height:{{ $hPos }}px; background:#3B82F6; border-radius:4px 4px 0 0; min-width:10px;">
<span class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs text-blue-600 font-semibold">{{ $item->positif }}</span>
</div>
{{-- Negatif --}}
<div class="relative group flex-1" style="height:{{ $hNeg }}px; background:#F87171; border-radius:4px 4px 0 0; min-width:10px;">
<span class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs text-red-500 font-semibold">{{ $item->negatif }}</span>
</div>
{{-- Netral --}}
<div class="relative group flex-1" style="height:{{ $hNet }}px; background:#FBBF24; border-radius:4px 4px 0 0; min-width:10px;">
<span class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs text-yellow-500 font-semibold">{{ $item->netral }}</span>
</div>
</div>
<p class="text-xs text-gray-500 text-center mt-2 leading-tight" style="max-width:70px; word-break:break-word;">{{ $label }}</p>
</div>
@endforeach
</div>
<!-- ================= ANALISIS ================= -->
<div x-show="tab === 'analisis'" id="analisis-content">
@include('analisis.index')
</div>
<!-- ================= REKOMENDASI ================= -->
<div x-show="tab === 'rekomendasi'" id="rekomendasi-content">
@include('rekomendasi.index')
</div>
{{-- Section Bawah --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white rounded-xl shadow p-6 h-[320px]">
<h3 class="font-semibold mb-6 text-gray-700">Total Data per Destinasi</h3>
<div class="space-y-4 text-sm text-gray-600">
@foreach($totalPerWisata as $item)
<div class="flex justify-between border-b pb-3">
<span>{{ $item->wisata }}</span>
<span class="font-semibold text-gray-800">{{ $item->total }} ulasan</span>
</div>
@endforeach
</div>
</div>
<div class="bg-white rounded-xl shadow p-6 h-[320px]">
<h3 class="font-semibold mb-6 text-gray-700">Word Cloud</h3>
<div id="wordCloud" class="w-full h-[240px] overflow-hidden"></div>
</div>
</div>
</div>
<!-- ================= ANALISIS ================= -->
<div x-show="tab === 'analisis'" id="analisis-content">
@include('analisis.index')
</div>
<!-- ================= REKOMENDASI ================= -->
<div x-show="tab === 'rekomendasi'" id="rekomendasi-content">
@include('rekomendasi.index')
</div>
</div>
</div>
</div>
{{-- ========================= --}}
{{-- SCRIPT SECTION --}}
{{-- ========================= --}}
{{-- SCRIPTS --}}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.1.2/wordcloud2.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
// =============================
// PIE CHART
// =============================
const pieData = JSON.parse('{!! json_encode($chartSentimen) !!}');
const pieLabels = pieData.map(item => item.sentimen);
const pieValues = pieData.map(item => item.total);
new Chart(document.getElementById('pieChart'), {
const pieCanvas = document.getElementById('pieChart');
if (window.pieChartInstance) window.pieChartInstance.destroy();
window.pieChartInstance = new Chart(pieCanvas, {
type: 'pie',
data: {
labels: pieLabels,
labels: pieData.map(i => i.sentimen),
datasets: [{
data: pieValues
data: pieData.map(i => i.total),
backgroundColor: ['#3B82F6', '#EF4444', '#F59E0B']
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false
}
});
// =============================
// BAR CHART
// =============================
const barData = JSON.parse('{!! json_encode($chartDestinasi) !!}');
const labelsBar = barData.map(item => item.wisata);
const positif = barData.map(item => item.positif);
const negatif = barData.map(item => item.negatif);
const netral = barData.map(item => item.netral);
new Chart(document.getElementById('barChart'), {
type: 'bar',
data: {
labels: labelsBar,
datasets: [
{ label: 'Positif', data: positif },
{ label: 'Negatif', data: negatif },
{ label: 'Netral', data: netral }
]
}
});
// =============================
// WORD CLOUD
// =============================
const text = `{!! $allText !!}`;
if(text.length > 0){
const words = text.split(" ");
const text = `{!! addslashes($allText) !!}`;
if (text.length > 0) {
const wordCount = {};
words.forEach(word => {
if(word.length > 3){
wordCount[word] = (wordCount[word] || 0) + 1;
}
});
const list = Object.entries(wordCount);
WordCloud(document.getElementById('wordCloud'), {
list: list
text.split(" ").forEach(word => {
if (word.length > 3) wordCount[word] = (wordCount[word] || 0) + 1;
});
WordCloud(document.getElementById('wordCloud'), { list: Object.entries(wordCount) });
}
});
</script>
<script>
const tab = new URLSearchParams(window.location.search).get('tab');
if (tab === 'analisis') {
showTab('analisis');
}
</script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById('analisis-content');
// ================= FILTER AJAX =================
// FILTER AJAX
document.addEventListener('submit', function(e){
if(e.target.id === 'filter-form'){
e.preventDefault();
const formData = new FormData(e.target);
const params = new URLSearchParams(formData).toString();
const params = new URLSearchParams(new FormData(e.target)).toString();
fetch('/dashboard?' + params)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.getElementById('analisis-content');
container.innerHTML = newContent.innerHTML;
const doc = new DOMParser().parseFromString(html, 'text/html');
container.innerHTML = doc.getElementById('analisis-content').innerHTML;
});
}
});
// ================= PAGINATION AJAX =================
// PAGINATION AJAX
document.addEventListener('click', function(e){
const link = e.target.closest('a');
if(link && link.href.includes('page=')){
e.preventDefault();
fetch(link.href)
.then(res => res.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.getElementById('analisis-content');
container.innerHTML = newContent.innerHTML;
const doc = new DOMParser().parseFromString(html, 'text/html');
container.innerHTML = doc.getElementById('analisis-content').innerHTML;
});
}
});
});
</script>
@endsection

View File

@ -1,3 +1,18 @@
@php
$destinasiList ??= collect([]);
$totalNegatif ??= 0;
$totalUlasan ??= 0;
$persenNegatif ??= 0;
$tingkatKepuasan ??= 0;
$labelKepuasan ??= 'Kurang';
$isuDominan ??= '-';
$isuDominanPersen ??= 0;
$isuUtama ??= [];
$kataDominan ??= [];
$saranPerbaikan ??= [];
$prioritas ??= [];
@endphp
<div class="space-y-6">
{{-- HEADER --}}
@ -9,26 +24,29 @@
</p>
</div>
<div class="flex gap-3">
<select class="border rounded-lg px-3 py-2 text-sm">
<option>Semua Destinasi</option>
{{-- FILTER DESTINASI submit ke /dashboard agar tab tidak reset --}}
<form method="GET" action="{{ route('dashboard') }}" class="flex gap-3">
<input type="hidden" name="tab" value="rekomendasi">
<select name="destinasi" onchange="this.form.submit()"
class="border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400">
<option value="">Semua Destinasi</option>
<option value="Pantai Papuma" {{ request('destinasi') == 'Pantai Papuma' ? 'selected' : '' }}>Pantai Papuma</option>
<option value="Pantai Watu Ulo" {{ request('destinasi') == 'Pantai Watu Ulo' ? 'selected' : '' }}>Pantai Watu Ulo</option>
<option value="Teluk Love" {{ request('destinasi') == 'Teluk Love' ? 'selected' : '' }}>Teluk Love</option>
<option value="Wisata Kebun Teh Gunung Gambir" {{ request('destinasi') == 'Wisata Kebun Teh Gunung Gambir' ? 'selected' : '' }}>Wisata Kebun Teh Gunung Gambir</option>
</select>
<!-- <select class="border rounded-lg px-3 py-2 text-sm">
<option>2020 - 2025</option>
</select> -->
</div>
</form>
</div>
{{-- SUMMARY --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-5">
{{-- SUMMARY CARDS --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
<div class="bg-red-100 text-red-500 p-3 rounded-full text-xl">😟</div>
<div>
<p class="text-sm text-gray-500">Total Ulasan Negatif</p>
<h3 class="text-xl font-bold">312</h3>
<p class="text-xs text-red-500">25,06% dari total</p>
<h3 class="text-xl font-bold">{{ $totalNegatif }}</h3>
<p class="text-xs text-red-500">{{ $persenNegatif }}% dari total</p>
</div>
</div>
@ -36,9 +54,12 @@
<div class="bg-green-100 text-green-600 p-3 rounded-full text-xl">😊</div>
<div>
<p class="text-sm text-gray-500">Tingkat Kepuasan</p>
<h3 class="text-xl font-bold">68%</h3>
<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded">
Sedang
<h3 class="text-xl font-bold">{{ $tingkatKepuasan }}%</h3>
<span class="text-xs px-2 py-1 rounded
{{ $labelKepuasan === 'Baik' ? 'bg-green-100 text-green-700' :
($labelKepuasan === 'Sedang' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700') }}">
{{ $labelKepuasan }}
</span>
</div>
</div>
@ -47,20 +68,11 @@
<div class="bg-purple-100 text-purple-600 p-3 rounded-full text-xl"></div>
<div>
<p class="text-sm text-gray-500">Isu Dominan</p>
<h3 class="text-xl font-bold">Kebersihan</h3>
<p class="text-xs text-gray-500">45% dari total isu</p>
<h3 class="text-xl font-bold">{{ $isuDominan }}</h3>
<p class="text-xs text-gray-500">{{ $isuDominanPersen }}% dari total isu</p>
</div>
</div>
<!-- <div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
<div class="bg-blue-100 text-blue-600 p-3 rounded-full text-xl">📅</div>
<div>
<p class="text-sm text-gray-500">Periode Data</p>
<h3 class="text-xl font-bold">2020 - 2025</h3>
<p class="text-xs text-gray-500">6 tahun data</p>
</div>
</div> -->
</div>
{{-- GRID ANALISIS --}}
@ -70,158 +82,120 @@
<div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-700">Isu Utama (Top 5)</h3>
@php
$isu = [
['nama'=>'Kebersihan','persen'=>45,'warna'=>'red'],
['nama'=>'Parkir','persen'=>30,'warna'=>'orange'],
['nama'=>'Harga / Tiket','persen'=>15,'warna'=>'yellow'],
['nama'=>'Fasilitas','persen'=>6,'warna'=>'green'],
['nama'=>'Keramaian','persen'=>4,'warna'=>'blue'],
];
@endphp
@foreach($isu as $i => $item)
@forelse ($isuUtama as $i => $isu)
@php
$warnaClass = match($isu['color']) {
'red' => 'bg-red-500',
'orange' => 'bg-orange-500',
'yellow' => 'bg-yellow-400',
'green' => 'bg-green-500',
default => 'bg-blue-500',
};
@endphp
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span>{{ $i+1 }}. {{ $item['nama'] }}</span>
<span>{{ $item['persen'] }}%</span>
<span>{{ $i + 1 }}. {{ $isu['nama'] }}</span>
<span class="font-semibold">{{ $isu['persen'] }}%</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded">
<div class="h-2 rounded
@if($item['warna'] == 'red') bg-red-500
@elseif($item['warna'] == 'orange') bg-orange-500
@elseif($item['warna'] == 'yellow') bg-yellow-500
@elseif($item['warna'] == 'green') bg-green-500
@else bg-blue-500
@endif"
style="width: {{ $item['persen'] }}%">
</div>
<div class="h-2 rounded {{ $warnaClass }}" style="width: {{ $isu['persen'] }}%"></div>
</div>
</div>
@endforeach
@empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu.</p>
@endforelse
</div>
{{-- KEYWORD --}}
{{-- KATA KUNCI DOMINAN --}}
<div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-700">Kata Kunci Dominan</h3>
@php
$keywords = [
['kotor',15],
['parkir',10],
['mahal',7],
['sampah',6],
['toilet',5],
];
@endphp
@php $maxFreq = !empty($kataDominan) ? max($kataDominan) : 1; @endphp
@foreach($keywords as $k)
@forelse (array_slice($kataDominan, 0, 5, true) as $kata => $jumlah)
<div class="mb-3">
<div class="flex justify-between text-sm">
<span>{{ $k[0] }}</span>
<span>{{ $k[1] }}x</span>
<span>{{ $kata }}</span>
<span class="font-semibold text-blue-600">{{ $jumlah }}x</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded mt-1">
<div class="bg-blue-500 h-2 rounded"
style="width: {{ $k[1]*5 }}%"></div>
<div class="bg-blue-500 h-2 rounded" style="width: {{ round(($jumlah / $maxFreq) * 100) }}%"></div>
</div>
</div>
@endforeach
@empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada kata kunci.</p>
@endforelse
</div>
{{-- SARAN --}}
{{-- SARAN PERBAIKAN --}}
<div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-700">Saran Perbaikan</h3>
<div class="space-y-4 text-sm">
<div class="flex gap-3">
<div class="text-green-500 text-xl">🧹</div>
<div>
<p class="font-semibold">Perbaikan Kebersihan</p>
<p class="text-gray-600">
Tambah tempat sampah & jadwal pembersihan rutin.
</p>
@forelse ($saranPerbaikan as $saran)
<div class="flex gap-3">
<div class="text-xl">{{ $saran['icon'] }}</div>
<div>
<p class="font-semibold">{{ $saran['nama'] }}</p>
<p class="text-gray-600">{{ $saran['tip'] }}</p>
</div>
</div>
</div>
<div class="flex gap-3">
<div class="text-orange-500 text-xl">🚗</div>
<div>
<p class="font-semibold">Penataan Parkir</p>
<p class="text-gray-600">
Sistem parkir lebih rapi dan transparan.
</p>
</div>
</div>
<div class="flex gap-3">
<div class="text-yellow-500 text-xl">🏷️</div>
<div>
<p class="font-semibold">Evaluasi Harga</p>
<p class="text-gray-600">
Penyesuaian harga tiket agar lebih terjangkau.
</p>
</div>
</div>
@empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada saran.</p>
@endforelse
</div>
</div>
</div>
{{-- PRIORITAS --}}
{{-- PRIORITAS REKOMENDASI --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{{-- CARD 1 --}}
<div class="border border-red-300 rounded-xl p-5 bg-white">
<h4 class="font-semibold text-red-600 mb-2">Prioritas 1</h4>
<h3 class="text-lg font-bold mb-3">Kebersihan</h3>
@forelse ($prioritas as $p)
@php
$border = match($p['color']) {
'red' => 'border-red-300',
'orange' => 'border-orange-300',
'yellow' => 'border-yellow-300',
'green' => 'border-green-300',
default => 'border-blue-300',
};
$title = match($p['color']) {
'red' => 'text-red-600',
'orange' => 'text-orange-500',
'yellow' => 'text-yellow-600',
'green' => 'text-green-600',
default => 'text-blue-600',
};
$dampak = match($p['color']) {
'red' => 'bg-red-100 text-red-600',
'orange' => 'bg-orange-100 text-orange-600',
'yellow' => 'bg-yellow-100 text-yellow-600',
'green' => 'bg-green-100 text-green-700',
default => 'bg-blue-100 text-blue-600',
};
@endphp
<div class="border {{ $border }} rounded-xl p-5 bg-white">
<h4 class="font-semibold {{ $title }} mb-2">Prioritas {{ $p['rank'] }}</h4>
<h3 class="text-lg font-bold mb-3">{{ $p['nama'] }}</h3>
<ul class="text-sm space-y-2">
<li> Tambah tempat sampah</li>
<li> Jadwal pembersihan rutin</li>
<li> Petugas kebersihan tambahan</li>
</ul>
<ul class="text-sm space-y-2 mb-4">
@foreach ($p['actions'] as $action)
<li class="flex items-center gap-2">
<span class="text-blue-500"></span> {{ $action }}
</li>
@endforeach
</ul>
<div class="mt-4 text-xs bg-red-100 text-red-600 p-2 rounded">
Dampak: Kepuasan meningkat signifikan
<div class="text-xs {{ $dampak }} p-2 rounded">
Dampak: {{ $p['dampak'] }}
</div>
</div>
</div>
{{-- CARD 2 --}}
<div class="border border-orange-300 rounded-xl p-5 bg-white">
<h4 class="font-semibold text-orange-600 mb-2">Prioritas 2</h4>
<h3 class="text-lg font-bold mb-3">Parkir</h3>
<ul class="text-sm space-y-2">
<li> Penataan area parkir</li>
<li> Tarif transparan</li>
<li> Petugas lebih ramah</li>
</ul>
<div class="mt-4 text-xs bg-orange-100 text-orange-600 p-2 rounded">
Dampak: Mengurangi keluhan
@empty
<div class="col-span-3 text-center text-gray-400 text-sm py-6">
Belum ada data rekomendasi. Pastikan analisis sentimen sudah dijalankan.
</div>
</div>
{{-- CARD 3 --}}
<div class="border border-yellow-300 rounded-xl p-5 bg-white">
<h4 class="font-semibold text-yellow-600 mb-2">Prioritas 3</h4>
<h3 class="text-lg font-bold mb-3">Harga</h3>
<ul class="text-sm space-y-2">
<li> Evaluasi harga tiket</li>
<li> Promo wisata</li>
<li> Diskon tertentu</li>
</ul>
<div class="mt-4 text-xs bg-yellow-100 text-yellow-600 p-2 rounded">
Dampak: Daya tarik meningkat
</div>
</div>
@endforelse
</div>

View File

@ -5,68 +5,56 @@
<div class="max-w-7xl mx-auto px-4 md:px-0 space-y-6">
<!-- HEADER -->
<h2 class="text-2xl font-bold text-gray-800">Data Ulasan</h2>
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold text-gray-800">Data Ulasan</h2>
<div class="flex gap-3">
<button class="border border-blue-500 text-blue-600 px-5 py-2.5 rounded-full hover:bg-blue-50 transition">
Ambil Data Terbaru
</button>
<form action="{{ route('proses.analisis') }}" method="POST">
@csrf
<button class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2.5 rounded-full shadow-md transition">
Proses Analisis
</button>
</form>
</div>
</div>
<!-- ============================= -->
<!-- 🔥 UPLOAD + FORMAT + SUMMARY -->
<!-- ============================= -->
<div class="bg-white rounded-xl shadow p-6">
</div>
<!-- UPLOAD + FORMAT + SUMMARY -->
<div class="bg-white rounded-3xl shadow p-6 mb-6 mt-5">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
<!-- UPLOAD -->
<div class="border-2 border-dashed border-blue-300 rounded-xl p-6 text-center">
<div class="flex justify-center mb-3">
<div class="bg-blue-100 text-blue-600 p-3 rounded-full text-xl">
☁️
</div>
<div class="bg-blue-100 text-blue-600 p-3 rounded-full text-xl">☁️</div>
</div>
<p class="text-gray-600 text-sm mb-2">
Upload file CSV / XLSX
</p>
<p class="text-gray-600 text-sm mb-2">Upload file CSV / XLSX</p>
<p class="text-gray-400 text-xs mb-3">klik tombol di bawah</p>
<form id="uploadForm" action="{{ route('ulasan.upload') }}" method="POST" enctype="multipart/form-data">
@csrf
<input type="file" name="file" id="fileInput"
accept=".csv,.xlsx,.xls"
class="hidden">
<button type="button"
onclick="document.getElementById('fileInput').click()"
<input type="file" name="file" id="fileInput" accept=".csv,.xlsx,.xls" class="hidden">
<button type="button" onclick="document.getElementById('fileInput').click()"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm shadow">
Pilih File
</button>
</form>
<!-- <a href="#" class="block mt-3 text-blue-500 text-xs hover:underline">
Download template CSV
</a> -->
</div>
<!-- FORMAT -->
<div class="bg-blue-50 rounded-xl p-5">
<h4 class="font-semibold text-blue-700 mb-3">
Format CSV yang Diperlukan
</h4>
<h4 class="font-semibold text-blue-700 mb-3">Format CSV yang Diperlukan</h4>
<ul class="space-y-2 text-sm text-gray-700">
<li> wisata, rating, ulasan, tanggal</li>
<li> Format: YYYY-MM-DD HH:MM:SS</li>
<li> Maks 10MB</li>
</ul>
</div>
<!-- SUMMARY -->
<div class="space-y-4">
<div class="flex items-center gap-3">
<div class="bg-blue-100 text-blue-600 p-3 rounded-full">📄</div>
<div>
@ -74,7 +62,6 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm sha
<p class="font-bold text-lg">{{ $totalUlasan }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-green-100 text-green-600 p-3 rounded-full">📍</div>
<div>
@ -82,7 +69,6 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm sha
<p class="font-bold text-lg">{{ $totalWisata }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="bg-orange-100 text-orange-600 p-3 rounded-full">📅</div>
<div>
@ -92,11 +78,9 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm sha
</p>
</div>
</div>
</div>
</div>
</div>
<!-- ALERT -->
@ -106,154 +90,107 @@ class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm sha
</div>
@endif
<!-- ============================= -->
<!-- 🔍 SEARCH + FILTER -->
<!-- ============================= -->
<form method="GET" action="{{ route('ulasan.index') }}" class="flex flex-col md:flex-row gap-3">
<!-- FILTER PERIODE + SEARCH -->
<form method="GET" action="{{ route('ulasan.index') }}" class="flex flex-col md:flex-row gap-3 items-center">
{{-- DROPDOWN PERIODE --}}
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 font-medium whitespace-nowrap">Periode:</span>
<select name="periode_id" onchange="this.form.submit()"
class="border rounded-lg px-3 py-2 text-sm bg-white shadow-sm">
<option value="">Semua Periode</option>
@foreach($periodeList as $p)
<option value="{{ $p->id }}" {{ request('periode_id') == $p->id ? 'selected' : '' }}>
{{ $p->nama }}
</option>
@endforeach
</select>
</div>
{{-- SEARCH --}}
<input type="text" name="search" value="{{ request('search') }}"
placeholder="Cari ulasan atau wisata..."
class="flex-1 border px-3 py-2 rounded-lg text-sm">
<!-- <select name="wisata" onchange="this.form.submit()"
class="border rounded-lg px-3 py-2 text-sm">
<button class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm">Cari</button>
<option value="">Semua Destinasi</option>
<option value="Pantai Papuma" {{ request('wisata')=='Pantai Papuma'?'selected':'' }}>Pantai Papuma</option>
<option value="Pantai Watu Ulo" {{ request('wisata')=='Pantai Watu Ulo'?'selected':'' }}>Pantai Watu Ulo</option>
<option value="Teluk Love" {{ request('wisata')=='Teluk Love'?'selected':'' }}>Teluk Love</option>
</select> -->
<button class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm">
Cari
</button>
<a href="{{ route('ulasan.index') }}"
class="bg-gray-200 px-4 py-2 rounded-lg text-sm">
Reset
</a>
<a href="{{ route('ulasan.index') }}" class="bg-gray-200 px-4 py-2 rounded-lg text-sm">Reset</a>
</form>
<!-- ============================= -->
<!-- TABLE -->
<!-- ============================= -->
<div class="bg-white rounded-xl shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-3 w-[5%]">No</th>
<th class="px-4 py-3 w-[20%]">Nama Wisata</th>
<th class="px-4 py-3 w-[10%] text-center">Rating</th>
<th class="px-4 py-3 w-[45%]">Ulasan</th>
<th class="px-4 py-3 w-[20%] text-center">Tanggal</th>
</tr>
</thead>
<tbody class="divide-y">
@forelse($mentah as $i => $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">{{ $mentah->firstItem() + $i }}</td>
<td class="px-4 py-3 font-medium">
{{ $item->wisata }}
</td>
<td class="px-4 py-3 text-center text-yellow-500">
{{ str_repeat('★', round($item->rating)) }}
<span class="text-gray-500 ml-1">{{ $item->rating }}</span>
</td>
<td class="px-4 py-3">
<div class="line-clamp-2 text-gray-600">
{{ $item->ulasan }}
</div>
</td>
<td class="px-4 py-3 text-center text-gray-500">
{{ $item->tanggal }}
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-6 text-gray-400">
Belum ada data
</td>
</tr>
@endforelse
</tbody>
</table>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-100">
<tr>
<th class="px-4 py-3 w-[5%]">No</th>
<th class="px-4 py-3 w-[20%]">Nama Wisata</th>
<th class="px-4 py-3 w-[10%] text-center">Rating</th>
<th class="px-4 py-3 w-[45%]">Ulasan</th>
<th class="px-4 py-3 w-[20%] text-center">Tanggal</th>
</tr>
</thead>
<tbody class="divide-y">
@forelse($mentah as $i => $item)
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">{{ $mentah->firstItem() + $i }}</td>
<td class="px-4 py-3 font-medium">{{ $item->wisata }}</td>
<td class="px-4 py-3 text-center text-yellow-500">
{{ str_repeat('★', round($item->rating)) }}
<span class="text-gray-500 ml-1">{{ $item->rating }}</span>
</td>
<td class="px-4 py-3">
<div class="line-clamp-2 text-gray-600">{{ $item->ulasan }}</div>
</td>
<td class="px-4 py-3 text-center text-gray-500">
{{ \Carbon\Carbon::parse($item->tanggal)->diffForHumans() }}
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-6 text-gray-400">Belum ada data</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="p-4 flex flex-col md:flex-row gap-3 justify-between items-center text-sm text-gray-500">
<div>
Menampilkan {{ $mentah->firstItem() ?? 0 }} - {{ $mentah->lastItem() ?? 0 }}
dari {{ $mentah->total() }} data
</div>
<div class="flex items-center gap-1">
@if ($mentah->onFirstPage())
<span class="px-3 py-1 bg-gray-200 rounded-lg">«</span>
@else
<a href="{{ $mentah->previousPageUrl() }}" class="px-3 py-1 bg-gray-100 rounded-lg">«</a>
@endif
<!-- KIRI (FINAL FIX) -->
<div>
Menampilkan {{ $mentah->firstItem() ?? 0 }} - {{ $mentah->lastItem() ?? 0 }}
dari {{ $mentah->total() }} data
</div>
@for ($i = 1; $i <= $mentah->lastPage(); $i++)
@if ($i == $mentah->currentPage())
<span class="px-3 py-1 bg-blue-600 text-white rounded-lg">{{ $i }}</span>
@elseif ($i <= 3 || $i > $mentah->lastPage() - 2 || abs($i - $mentah->currentPage()) <= 1)
<a href="{{ $mentah->url($i) }}" class="px-3 py-1 bg-gray-100 rounded-lg">{{ $i }}</a>
@elseif ($i == 4 || $i == $mentah->lastPage() - 3)
<span class="px-2">...</span>
@endif
@endfor
<!-- KANAN (PAGINATION) -->
<div class="flex items-center gap-1">
@if ($mentah->onFirstPage())
<span class="px-3 py-1 bg-gray-200 rounded-lg">«</span>
@else
<a href="{{ $mentah->previousPageUrl() }}"
class="px-3 py-1 bg-gray-100 rounded-lg">«</a>
@endif
@for ($i = 1; $i <= $mentah->lastPage(); $i++)
@if ($i == $mentah->currentPage())
<span class="px-3 py-1 bg-blue-600 text-white rounded-lg">{{ $i }}</span>
@elseif ($i <= 3 || $i > $mentah->lastPage() - 2 || abs($i - $mentah->currentPage()) <= 1)
<a href="{{ $mentah->url($i) }}"
class="px-3 py-1 bg-gray-100 rounded-lg">{{ $i }}</a>
@elseif ($i == 4 || $i == $mentah->lastPage() - 3)
<span class="px-2">...</span>
@endif
@endfor
@if ($mentah->hasMorePages())
<a href="{{ $mentah->nextPageUrl() }}"
class="px-3 py-1 bg-gray-100 rounded-lg">»</a>
@else
<span class="px-3 py-1 bg-gray-200 rounded-lg">»</span>
@endif
</div>
</div>
@if ($mentah->hasMorePages())
<a href="{{ $mentah->nextPageUrl() }}" class="px-3 py-1 bg-gray-100 rounded-lg">»</a>
@else
<span class="px-3 py-1 bg-gray-200 rounded-lg">»</span>
@endif
</div>
</div>
</div>
<!-- BUTTON ANALISIS -->
<div class="flex justify-end">
<form action="{{ route('ulasan.analisis') }}" method="POST">
@csrf
<button class="bg-blue-600 text-white px-6 py-3 rounded-full shadow-lg">
Proses Analisis
</button>
</form>
</div>
</div>
<!-- AUTO SUBMIT UPLOAD -->
<script>
document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById("fileInput");
const form = document.getElementById("uploadForm");
input.addEventListener("change", function () {
form.submit();
});

View File

@ -7,42 +7,61 @@
use App\Http\Controllers\AnalisisController;
use App\Http\Controllers\RekomendasiController;
/*
|--------------------------------------------------------------------------
| ROUTE AWAL
|--------------------------------------------------------------------------
*/
// hanya SATU route '/'
Route::get('/', function () {
return redirect('/login');
});
Route::get('/', function () {
return view('welcome');
});
/*
|--------------------------------------------------------------------------
| ROUTE SETELAH LOGIN
|--------------------------------------------------------------------------
*/
// Dashboard SENTARA (harus login)
Route::get('/dashboard', [DashboardController::class, 'index'])
->middleware(['auth', 'verified'])
->name('dashboard');
Route::middleware(['auth', 'verified'])->group(function () {
// Route::get('/dashboard', function () {
// return view('dashboard');
// })->middleware(['auth', 'verified'])->name('dashboard');
// ✅ DASHBOARD
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
Route::middleware('auth')->group(function () {
// ✅ PROFILE (ini yang kamu tanya)
Route::get('/profile', function () {
return view('profile.index');
})->name('profile');
// kalau mau aktifkan update:
// Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
// Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
Route::get('/ulasan', [UlasanController::class, 'index'])->name('ulasan.index');
Route::post('/ulasan/ambil', [UlasanController::class, 'ambilData'])->name('ulasan.ambil');
Route::post('/ulasan/analisis', [UlasanController::class, 'analisisData'])->name('ulasan.analisis');
Route::post('/ulasan/upload', [UlasanController::class, 'upload'])->name('ulasan.upload');
Route::get('/hasil-analisis', [AnalisisController::class, 'index'])
->name('analisis.index');
Route::get('/rekomendasi', [RekomendasiController::class, 'index'])
->name('rekomendasi.index');;
Route::get('/', function () {
return redirect()->route('login');
});
// ✅ DATA ULASAN
Route::get('/ulasan', [UlasanController::class, 'index'])
->name('ulasan.index');
// Route::post('/ulasan/ambil', [UlasanController::class, 'ambilData'])
// ->name('ulasan.ambil');
Route::post('/ulasan/upload', [UlasanController::class, 'upload'])
->name('ulasan.upload');
// ✅ PROSES ANALISIS
Route::post('/proses-analisis', [UlasanController::class, 'analisisData'])
->name('proses.analisis');
// ✅ HASIL ANALISIS
Route::get('/analisis', [AnalisisController::class, 'index'])
->name('analisis.index');
// ✅ REKOMENDASI
Route::get('/rekomendasi', function() {
return redirect('/dashboard?tab=rekomendasi');
})->name('rekomendasi.index');
});
require __DIR__.'/auth.php';
require __DIR__.'/auth.php';

View File

@ -1,6 +1,12 @@
# -*- coding: utf-8 -*-
import sys
sys.stdout.reconfigure(encoding='utf-8')
import re
import numpy as np
import pandas as pd
import pymysql
from sqlalchemy import create_engine
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
@ -10,33 +16,45 @@ from sklearn.metrics import accuracy_score, classification_report, confusion_mat
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
# ===============================
# 🔌 KONEKSI DB
# KONEKSI DB
# ===============================
conn = pymysql.connect(
engine = create_engine("mysql+pymysql://root:@localhost/sentara")
raw_conn = pymysql.connect(
host='localhost',
user='root',
password='',
database='sentara',
cursorclass=pymysql.cursors.DictCursor
)
cursor = conn.cursor()
cursor = raw_conn.cursor()
# ===============================
# 1. AMBIL DATA
# 1. AMBIL PERIODE AKTIF (TERBARU)
# ===============================
df = pd.read_sql("SELECT id, wisata, ulasan FROM ulasan", conn)
cursor.execute("SELECT id, nama FROM periode_analisis ORDER BY id DESC LIMIT 1")
periode = cursor.fetchone()
periode_id = periode['id']
periode_nama = periode['nama']
print(f"[INFO] Analisis periode: {periode_nama} (id={periode_id})")
# ===============================
# 2. CLEANING
# 2. AMBIL DATA PERIODE INI SAJA
# ===============================
df = pd.read_sql(f"SELECT id, wisata, ulasan FROM ulasan WHERE periode_id = {periode_id}", engine)
# ===============================
# 3. CLEANING
# ===============================
df = df.dropna(subset=["ulasan"])
df["ulasan"] = df["ulasan"].astype(str)
df = df[df["ulasan"].str.strip() != ""]
df = df[df["ulasan"].str.strip() != "0"]
# ===============================
# 3. PREPROCESSING
# 4. PREPROCESSING
# ===============================
stemmer = StemmerFactory().create_stemmer()
stop_factory = StopWordRemoverFactory()
@ -46,37 +64,57 @@ def clean_text(text):
text = text.lower()
text = re.sub(r"http\S+", " ", text)
text = re.sub(r"[^a-zA-Z\s]", " ", text)
text = re.sub(r"\s+", " ", text)
words = [w for w in text.split() if w not in stopwords]
text = re.sub(r"\s+", " ", text).strip()
words = [w for w in text.split() if w not in stopwords and len(w) > 2]
return stemmer.stem(" ".join(words))
df["clean"] = df["ulasan"].apply(clean_text)
df = df[df["clean"].str.strip() != ""]
# ===============================
# 4. LABEL SEMENTARA (dummy)
# 5. LABEL (RULE BASED)
# ===============================
positif_words = {
"bagus", "indah", "mantap", "keren", "cantik", "menarik", "nyaman",
"bersih", "recommended", "suka", "senang", "puas", "murah", "asyik",
"ramah", "worth", "spektakuler", "memukau", "sejuk", "baguss", "kece",
"amazing", "beautiful", "good", "nice", "best", "great", "perfect",
"recommend", "memuaskan", "menyenangkan", "view"
}
negatif_words = {
"tidak", "buruk", "mahal", "jelek", "kotor", "kecewa", "rusak",
"sempit", "panas", "bau", "berbahaya", "sepi", "bosan", "mengecewakan",
"payah", "parah", "jorok", "macet", "antri", "penuh", "sampah",
"sayang", "kurang", "susah", "sulit", "jauh", "capek", "lelah"
}
def label_rule(text):
if "tidak" in text or "buruk" in text:
return "negatif"
elif "bagus" in text or "indah" in text:
words = set(text.split())
skor_pos = len(words & positif_words)
skor_neg = len(words & negatif_words)
if skor_pos > skor_neg:
return "positif"
elif skor_neg > skor_pos:
return "negatif"
else:
return "netral"
df["label"] = df["clean"].apply(label_rule)
# ===============================
# 5. TF-IDF + MODEL
# 6. TF-IDF + MODEL
# ===============================
X = df["clean"]
y = df["label"]
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
if len(df) > 5:
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
else:
X_train, X_test, y_train, y_test = X, X, y, y
vectorizer = TfidfVectorizer()
vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1,2))
X_train_vec = vectorizer.fit_transform(X_train)
X_test_vec = vectorizer.transform(X_test)
@ -84,59 +122,65 @@ model = ComplementNB()
model.fit(X_train_vec, y_train)
# ===============================
# 6. EVALUASI
# 7. EVALUASI
# ===============================
y_pred = model.predict(X_test_vec)
acc = accuracy_score(y_test, y_pred)
report = classification_report(y_test, y_pred, output_dict=True)
report = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
cm = confusion_matrix(y_test, y_pred, labels=["negatif","netral","positif"])
# ===============================
# 7. PREDIKSI SEMUA DATA
# 8. PREDIKSI SEMUA DATA
# ===============================
X_all = vectorizer.transform(df["clean"])
df["prediksi"] = model.predict(X_all)
# ===============================
# 8. SIMPAN KE DB
# 9. SIMPAN KE DB
# ===============================
df = df.fillna("")
# kosongkan tabel
cursor.execute("DELETE FROM hasil_analisis")
cursor.execute("DELETE FROM evaluasi_model")
# Hapus data periode ini saja (bukan semua)
cursor.execute("DELETE FROM hasil_analisis WHERE periode_id = %s", (periode_id,))
cursor.execute("DELETE FROM evaluasi_model WHERE periode_id = %s", (periode_id,))
insert_query = """
INSERT INTO hasil_analisis
(wisata, ulasan_asli, ulasan_bersih, hasil_preprocessing, sentimen, probabilitas, periode_id)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
# simpan hasil per ulasan
for _, row in df.iterrows():
cursor.execute("""
INSERT INTO hasil_analisis
(wisata, ulasan_terolah, hasil_preprocessing, sentimen, probabilitas, created_at)
VALUES (%s,%s,%s,%s,%s,NOW())
""", (
row["wisata"],
row["ulasan"],
row["clean"],
row["prediksi"],
0.9
cursor.execute(insert_query, (
str(row["wisata"]),
str(row["ulasan"]),
str(row["clean"]),
str(row["clean"]),
str(row["prediksi"]),
float(0.9),
periode_id
))
# simpan evaluasi
print(f"[OK] {len(df)} ulasan berhasil disimpan untuk periode {periode_nama}")
cursor.execute("""
INSERT INTO evaluasi_model
(precision, recall, f1_score, accuracy, tp, tn, fp, fn, created_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,NOW())
(`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", (
report["weighted avg"]["precision"],
report["weighted avg"]["recall"],
report["weighted avg"]["f1-score"],
acc,
int(cm[2][2]),
int(cm[0][0]),
int(cm[0][2]),
int(cm[2][0])
float(report["weighted avg"]["precision"]),
float(report["weighted avg"]["recall"]),
float(report["weighted avg"]["f1-score"]),
float(acc),
int(cm[2][2]) if cm.shape == (3,3) else 0,
int(cm[0][0]) if cm.shape == (3,3) else 0,
int(cm[0][2]) if cm.shape == (3,3) else 0,
int(cm[2][0]) if cm.shape == (3,3) else 0,
periode_id
))
conn.commit()
conn.close()
raw_conn.commit()
raw_conn.close()
print("Analisis selesai ")
print("Analisis selesai [OK]")