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 Illuminate\Http\Request;
use App\Models\HasilAnalisis; use App\Models\HasilAnalisis;
use Illuminate\Support\Facades\DB;
class AnalisisController extends Controller class AnalisisController extends Controller
{ {
public function index(Request $request) 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') $wisataList = HasilAnalisis::select('wisata')
->distinct() ->distinct()
->pluck('wisata'); ->pluck('wisata');
// Query utama tabel // Query hasil analisis dengan filter
$query = HasilAnalisis::query(); $query = HasilAnalisis::query();
// Filter kalau user pilih destinasi tertentu
if ($request->filled('wisata')) { if ($request->filled('wisata')) {
$query->where('wisata', $request->wisata); $query->where('wisata', $request->wisata);
} }
// Data hasil analisis (pakai paginate biar rapi) $hasil = $query->latest()->paginate(10);
$hasil = $query->latest()->paginate(5);
return view('analisis.index', compact( return view('analisis.index', compact('hasil', 'wisataList', 'evaluasi'));
'hasil',
'wisataList'
));
} }
} }

View File

@ -7,138 +7,302 @@
class DashboardController extends Controller class DashboardController extends Controller
{ {
public function index(Request $request) private array $issueRules = [
{ 'Kebersihan' => [
$wisata = $request->wisata; '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') $evaluasi = DB::table('evaluasi_model')
->latest() ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->first(); ->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();
// ============================= // ================================================================
// CEK ADA ANALISIS ATAU BELUM // TOTAL ULASAN
// ============================= // ================================================================
$hasAnalisis = DB::table('hasil_analisis')->count() > 0; $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) // CEK ADA ANALISIS ATAU BELUM
// ============================= // ================================================================
$positif = DB::table('hasil_analisis') $hasAnalisis = DB::table('hasil_analisis')
->where('sentimen', 'Positif') ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) { ->count() > 0;
$q->where('wisata', $wisata);
})
->count();
$negatif = DB::table('hasil_analisis') // ================================================================
->where('sentimen', 'Negatif') // HITUNG SENTIMEN
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) { // ================================================================
$q->where('wisata', $wisata); $positif = DB::table('hasil_analisis')
}) ->where('sentimen', 'Positif')
->count(); ->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') $negatif = DB::table('hasil_analisis')
->where('sentimen', 'Netral') ->where('sentimen', 'Negatif')
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) { ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
$q->where('wisata', $wisata); ->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
}) ->count();
->count();
// ============================= $netral = DB::table('hasil_analisis')
// PERSENTASE SENTIMEN ->where('sentimen', 'Netral')
// ============================= ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
$totalSentimen = $positif + $negatif + $netral; ->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->count();
$stats = [ $totalSentimen = $positif + $negatif + $netral;
'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,
];
// ============================= $stats = [
// GRAFIK PIE (Distribusi Sentimen) 'total' => $totalUlasan,
// ============================= 'positif_persen' => $totalSentimen > 0 ? round(($positif / $totalSentimen) * 100) : 0,
$chartSentimen = $hasAnalisis 'negatif_persen' => $totalSentimen > 0 ? round(($negatif / $totalSentimen) * 100) : 0,
? DB::table('hasil_analisis') 'netral_persen' => $totalSentimen > 0 ? round(($netral / $totalSentimen) * 100) : 0,
->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();
// ============================= // ================================================================
// GRAFIK BAR (Sentimen per Destinasi) // CHART DATA
// ============================= // ================================================================
$chartDestinasi = $hasAnalisis $chartSentimen = $hasAnalisis
? DB::table('hasil_analisis') ? DB::table('hasil_analisis')
->select( ->select('sentimen', DB::raw('count(*) as total'))
'wisata', ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
DB::raw("SUM(CASE WHEN sentimen = 'Positif' THEN 1 ELSE 0 END) as positif"), ->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
DB::raw("SUM(CASE WHEN sentimen = 'Negatif' THEN 1 ELSE 0 END) as negatif"), ->groupBy('sentimen')
DB::raw("SUM(CASE WHEN sentimen = 'Netral' THEN 1 ELSE 0 END) as netral") ->orderByRaw("FIELD(sentimen, 'Positif', 'Negatif', 'Netral')")
) ->get()
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) { : collect();
$q->where('wisata', $wisata);
})
->groupBy('wisata')
->get() $chartDestinasi = $hasAnalisis
: collect(); ? DB::table('hasil_analisis')
->select(
// ============================= 'wisata',
// TOTAL DATA PER DESTINASI (DARI ULASAN) 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"),
$totalPerWisata = DB::table('ulasan') DB::raw("SUM(CASE WHEN sentimen = 'Netral' THEN 1 ELSE 0 END) as netral")
->select('wisata', 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('wisata') ->groupBy('wisata')
->get(); ->get()
: collect();
// ============================= // ================================================================
// WORD CLOUD (DARI PREPROCESSING) // TOTAL PER DESTINASI + WORD CLOUD + LAST UPDATE
// ============================= // ================================================================
$texts = DB::table('preprocessing_data')->pluck('stemming')->toArray(); $totalPerWisata = DB::table('ulasan')
$allText = count($texts) > 0 ? implode(' ', $texts) : ''; ->select('wisata', DB::raw('count(*) as total'))
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->groupBy('wisata')
->get();
// ============================= $texts = DB::table('hasil_analisis')
// LAST UPDATE ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
// ============================= ->pluck('ulasan_bersih')
$lastUpdate = DB::table('hasil_analisis') ->toArray();
->latest('created_at') $allText = count($texts) > 0 ? implode(' ', $texts) : '';
->value('created_at');
$destinasiList = DB::table('ulasan') $lastUpdate = DB::table('hasil_analisis')
->select('wisata') ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->distinct() ->latest('created_at')->value('created_at');
->pluck('wisata');
$hasil = DB::table('hasil_analisis') $destinasiList = DB::table('ulasan')
->when($wisata && $wisata != 'Semua Destinasi', function ($q) use ($wisata) { ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
$q->where('wisata', $wisata); ->select('wisata')->distinct()->pluck('wisata');
})
->paginate(10);
return view('dashboard', compact( $hasil = DB::table('hasil_analisis')
'stats', ->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
'chartSentimen', ->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
'chartDestinasi', ->paginate(10);
'totalPerWisata',
'allText', // ================================================================
'lastUpdate', // REKOMENDASI
'hasAnalisis', // ================================================================
'destinasiList', // WAJIB $ulasanNegatif = DB::table('hasil_analisis')
'hasil', ->where('sentimen', 'Negatif')
'evaluasi' // WAJIB ->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 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) public function index(Request $request)
{ {
// dropdown destinasi unik // --- 1. Dropdown destinasi ---
$destinasiList = HasilAnalisis::select('wisata') $destinasiList = DB::table('hasil_analisis')
->select('wisata')
->distinct() ->distinct()
->pluck('wisata'); ->pluck('wisata');
// query utama // --- 2. Query dengan filter destinasi ---
$query = HasilAnalisis::query(); $queryAll = HasilAnalisis::query();
$queryFilter = HasilAnalisis::query();
// filter destinasi jika dipilih
if ($request->filled('destinasi')) { if ($request->filled('destinasi')) {
$query->where('wisata', $request->destinasi); $queryFilter->where('wisata', $request->destinasi);
} }
// ambil ulasan negatif // --- 3. Hitung total & sentimen ---
$negatif = $query->where('sentimen', 'negatif') $totalUlasan = $queryFilter->count();
->pluck('ulasan_terolah') $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(); ->toArray();
// gabungkan semua ulasan jadi 1 string $text = strtolower(implode(' ', $ulasanNegatif));
$text = strtolower(implode(" ", $negatif)); $words = array_filter(preg_split('/\s+/', $text));
// token kata sederhana // --- 5. Rule-based: hitung skor tiap kategori isu ---
$words = array_filter(explode(" ", $text)); $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); $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); arsort($freq);
$kataDominan = array_slice($freq, 0, 10, true);
// ambil top 5 keyword // --- 9. Prioritas rekomendasi (top 3 isu) ---
$topKeywords = array_slice(array_keys($freq), 0, 5); $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 // --- 10. Saran perbaikan (top 3) ---
$isuUtama = $topKeywords[0] ?? "Belum ada isu"; $saranPerbaikan = [];
$kataDominan = implode(", ", $topKeywords); 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 // --- 11. Filter destinasi yang sedang aktif ---
$saran = "Perlu peningkatan layanan terkait: " . $isuUtama; $destinasiAktif = $request->input('destinasi', 'Semua Destinasi');
$destinasiList = DB::table('hasil_analisis') return view('rekomendasi.index', compact(
->select('wisata') 'destinasiList',
->distinct() 'destinasiAktif',
->pluck('wisata'); 'totalUlasan',
'totalNegatif',
'totalPositif',
return view('dashboard', compact( 'totalNetral',
'stats', 'persenNegatif',
'chartSentimen', 'tingkatKepuasan',
'chartDestinasi', 'labelKepuasan',
'totalPerWisata', 'isuUtama',
'allText', 'isuDominan',
'lastUpdate', 'isuDominanPersen',
'hasAnalisis', 'kataDominan',
'wisataList', 'saranPerbaikan',
'hasil', 'prioritas',
'destinasiList' // ✅ TAMBAH INI ));
));
} }
} }

View File

@ -12,6 +12,7 @@ class UlasanController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$periodeList = DB::table('periode_analisis')->orderBy('id', 'desc')->get();
$query = Ulasan::query(); $query = Ulasan::query();
// 🔍 SEARCH (ulasan + wisata) // 🔍 SEARCH (ulasan + wisata)
@ -29,87 +30,116 @@ public function index(Request $request)
$query->where('wisata', 'LIKE', '%' . $request->wisata . '%'); $query->where('wisata', 'LIKE', '%' . $request->wisata . '%');
} }
$mentah = $query->latest()->paginate(20)->withQueryString(); // ← filter periode
if ($request->filled('periode_id')) {
// OPTIONAL $query->where('periode_id', $request->periode_id);
$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(),
]);
}
} }
return redirect()->route('ulasan.index') $mentah = $query->orderBy('tanggal', 'desc')->paginate(20)->withQueryString();
->with('success', 'Upload berhasil dan data masuk!');
// 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() public function upload(Request $request)
{ {
$pythonPath = '"C:\\Users\\Mufrida Farah\\AppData\\Local\\Programs\\Python\\Python312\\python.exe"'; $request->validate([
$scriptPath = '"C:\\laragon\\www\\Sentara\\scraper\\preprocessing.py"'; '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') $periode = DB::table('periode_analisis')
->with('success', 'Analisis berhasil dijalankan'); ->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,18 +2,17 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
class HasilAnalisis extends Model class HasilAnalisis extends Model
{ {
use HasFactory;
protected $table = 'hasil_analisis'; protected $table = 'hasil_analisis';
protected $fillable = [ protected $fillable = [
'wisata', 'wisata',
'ulasan_terolah', 'ulasan_asli', // ← fix dari ulasan_terolah
'ulasan_bersih', // ← tambah ini
'hasil_preprocessing',
'sentimen', 'sentimen',
'probabilitas' 'probabilitas'
]; ];

View File

@ -21,6 +21,7 @@ public function register(): void
public function boot(): void public function boot(): void
{ {
Paginator::useTailwind(); 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. Berikut adalah hasil evaluasi model dan detail analisis sentimen terhadap ulasan wisata.
</p> </p>
</div> </div>
<!-- <div class="text-sm text-gray-500">
{{ now()->format('d F Y - H:i') }}
</div> -->
</div> </div>
{{-- ================= METRICS ================= --}} {{-- ================= METRICS ================= --}}
@ -19,28 +16,28 @@
<div class="bg-white p-5 rounded-xl shadow"> <div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">Precision</p> <p class="text-gray-500 text-sm">Precision</p>
<h3 class="text-2xl font-bold text-blue-600"> <h3 class="text-2xl font-bold text-blue-600">
{{ $evaluasi->precision ?? 0 }} {{ number_format($evaluasi->precision ?? 0, 2) }}
</h3> </h3>
</div> </div>
<div class="bg-white p-5 rounded-xl shadow"> <div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">Recall</p> <p class="text-gray-500 text-sm">Recall</p>
<h3 class="text-2xl font-bold text-green-600"> <h3 class="text-2xl font-bold text-green-600">
{{ $evaluasi->recall ?? 0 }} {{ number_format($evaluasi->recall ?? 0, 2) }}
</h3> </h3>
</div> </div>
<div class="bg-white p-5 rounded-xl shadow"> <div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">F1 Score</p> <p class="text-gray-500 text-sm">F1 Score</p>
<h3 class="text-2xl font-bold text-purple-600"> <h3 class="text-2xl font-bold text-purple-600">
{{ $evaluasi->f1_score ?? 0 }} {{ number_format($evaluasi->f1_score ?? 0, 2) }}
</h3> </h3>
</div> </div>
<div class="bg-white p-5 rounded-xl shadow"> <div class="bg-white p-5 rounded-xl shadow">
<p class="text-gray-500 text-sm">Akurasi</p> <p class="text-gray-500 text-sm">Akurasi</p>
<h3 class="text-2xl font-bold text-indigo-600"> <h3 class="text-2xl font-bold text-indigo-600">
{{ $evaluasi->accuracy ?? 0 }} {{ number_format($evaluasi->accuracy ?? 0, 2) }}
</h3> </h3>
</div> </div>
@ -66,8 +63,8 @@
<tr> <tr>
<th class="bg-gray-50">Positif</th> <th class="bg-gray-50">Positif</th>
<td class="bg-green-100">{{ $evaluasi->tp ?? 0 }}</td> <td class="bg-green-100">{{ $evaluasi->tp ?? 0 }}</td>
<td class="bg-red-100">{{ $evaluasi->fn ?? 0 }}</td> <td class="bg-red-100">{{ $evaluasi->fp ?? 0 }}</td>
<td class="bg-yellow-100">0</td> <td class="bg-yellow-100">{{ $evaluasi->fn ?? 0 }}</td>
</tr> </tr>
<tr> <tr>
<th class="bg-gray-50">Negatif</th> <th class="bg-gray-50">Negatif</th>
@ -96,29 +93,28 @@
<th>Precision</th> <th>Precision</th>
<th>Recall</th> <th>Recall</th>
<th>F1</th> <th>F1</th>
<th>Support</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td class="text-green-600 font-semibold">Positif</td> <td class="text-green-600 font-semibold">Positif</td>
<td>{{ $evaluasi->precision ?? 0 }}</td> <td>{{ number_format($evaluasi->precision ?? 0, 2) }}</td>
<td>{{ $evaluasi->recall ?? 0 }}</td> <td>{{ number_format($evaluasi->recall ?? 0, 2) }}</td>
<td>{{ $evaluasi->f1_score ?? 0 }}</td> <td>{{ number_format($evaluasi->f1_score ?? 0, 2) }}</td>
<td>-</td> <td>-</td>
</tr> </tr>
<tr> <tr>
<td class="text-red-600 font-semibold">Negatif</td> <td class="text-red-600 font-semibold">Negatif</td>
<td>{{ $evaluasi->precision ?? 0 }}</td> <td>{{ number_format($evaluasi->precision ?? 0, 2) }}</td>
<td>{{ $evaluasi->recall ?? 0 }}</td> <td>{{ number_format($evaluasi->recall ?? 0, 2) }}</td>
<td>{{ $evaluasi->f1_score ?? 0 }}</td> <td>{{ number_format($evaluasi->f1_score ?? 0, 2) }}</td>
<td>-</td> <td>-</td>
</tr> </tr>
<tr> <tr>
<td class="text-yellow-600 font-semibold">Netral</td> <td class="text-yellow-600 font-semibold">Netral</td>
<td>{{ $evaluasi->precision ?? 0 }}</td> <td>{{ number_format($evaluasi->precision ?? 0, 2) }}</td>
<td>{{ $evaluasi->recall ?? 0 }}</td> <td>{{ number_format($evaluasi->recall ?? 0, 2) }}</td>
<td>{{ $evaluasi->f1_score ?? 0 }}</td> <td>{{ number_format($evaluasi->f1_score ?? 0, 2) }}</td>
<td>-</td> <td>-</td>
</tr> </tr>
</tbody> </tbody>
@ -128,25 +124,23 @@
</div> </div>
{{-- ================= FILTER ================= --}} {{-- ================= 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"> <select name="wisata" class="border rounded px-3 py-2">
<option value="">Semua Destinasi</option> <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) <button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">
<option value="{{ $d }}" {{ request('wisata') == $d ? 'selected' : '' }}> Filter
{{ $d }} </button>
</option>
@endforeach
</select>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded"> </form>
Filter
</button>
</form>
{{-- ================= TABLE ================= --}} {{-- ================= TABLE ================= --}}
<div class="bg-white rounded-xl shadow p-4"> <div class="bg-white rounded-xl shadow p-4">
@ -158,102 +152,100 @@
<tr> <tr>
<th>No</th> <th>No</th>
<th>Wisata</th> <th>Wisata</th>
<th>Ulasan</th> <th>Ulasan Asli</th>
<th>Ulasan Bersih</th>
<th>Sentimen</th> <th>Sentimen</th>
<th>Probabilitas</th> <th>Probabilitas</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@forelse($hasil as $index => $item) @forelse($hasil as $item)
<tr class="border-b"> <tr class="border-b">
<td>{{ $loop->iteration }}</td> <td>{{ ($hasil->currentPage() - 1) * $hasil->perPage() + $loop->iteration }}</td>
<td>{{ $item->wisata }}</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> <td>
<span class=" <span class="px-2 py-1 rounded text-xs
px-2 py-1 rounded text-xs {{ strtolower($item->sentimen) == 'positif' ? 'bg-green-100 text-green-600' : '' }}
{{ $item->sentimen == 'Positif' ? 'bg-green-100 text-green-600' : '' }} {{ strtolower($item->sentimen) == 'negatif' ? 'bg-red-100 text-red-600' : '' }}
{{ $item->sentimen == 'Negatif' ? 'bg-red-100 text-red-600' : '' }} {{ strtolower($item->sentimen) == 'netral' ? 'bg-yellow-100 text-yellow-600' : '' }}
{{ $item->sentimen == 'Netral' ? 'bg-yellow-100 text-yellow-600' : '' }}
"> ">
{{ $item->sentimen }} {{ ucfirst($item->sentimen) }}
</span> </span>
</td> </td>
<td>{{ number_format($item->probabilitas ?? 0, 2) }}</td>
<td>{{ $item->probabilitas ?? '-' }}</td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="5" class="text-center py-4 text-gray-500"> <td colspan="6" class="text-center py-4 text-gray-500">Tidak ada data</td>
Tidak ada data
</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
</table> </table>
{{-- PAGINATION --}} {{-- ================= PAGINATION ================= --}}
<div class="mt-6 flex items-center justify-between text-sm text-gray-500"> @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 class="mt-4 flex items-center justify-between text-sm text-gray-600">
<div>
Menampilkan {{ $hasil->firstItem() }} - {{ $hasil->lastItem() }}
dari {{ $hasil->total() }} data
</div>
{{-- PAGINATION --}} {{-- Info --}}
{{-- PAGINATION --}} <p>Menampilkan {{ $hasil->firstItem() }} - {{ $hasil->lastItem() }} dari {{ $hasil->total() }} data</p>
<div class="flex items-center gap-1">
{{-- PREV --}} {{-- Tombol --}}
@if ($hasil->onFirstPage()) <div class="flex items-center gap-1">
<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
{{-- 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 {{-- Halaman 1 (jika tidak ada di window) --}}
$query = request()->query(); @if(!$window->contains(1))
$query['page'] = $page; <a href="{{ $hasil->url(1) }}&{{ $query }}" class="px-3 py-1 rounded border hover:bg-blue-50 text-blue-600">1</a>
$customUrl = url()->current() . '?' . http_build_query($query); @if($window->min() > 2)
@endphp <span class="px-2 py-1 text-gray-400">...</span>
@endif
@endif
@if ($page == $hasil->currentPage()) {{-- Window --}}
<span class="px-3 py-1 rounded bg-blue-600 text-white"> @foreach($window as $page)
{{ $page }} @if($page == $current)
</span> <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"> @if($hasil->hasMorePages())
{{ $page }} <a href="{{ $hasil->nextPageUrl() }}&{{ $query }}" class="px-3 py-1 rounded border hover:bg-blue-50 text-blue-600">&raquo;</a>
</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) </div>
<span class="px-2">...</span> </div>
@endif @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>
</div> </div>

View File

@ -11,264 +11,243 @@
</div> </div>
@endif @endif
<div x-data="{ tab: 'dashboard' }" class="mb-6"> {{-- DROPDOWN PERIODE --}}
<div class="flex items-center justify-between mb-4">
<!-- TAB --> <div class="flex items-center gap-3">
<div class="bg-blue-700 rounded-xl p-1 flex space-x-2 shadow mb-6"> <span class="text-sm text-gray-600 font-medium">Periode Analisis:</span>
<form method="GET" action="{{ route('dashboard') }}" id="periode-form">
<button @click="tab='dashboard'" <input type="hidden" name="tab" id="tab-hidden" value="{{ request('tab', 'dashboard') }}">
:class="tab==='dashboard' <input type="hidden" name="wisata" value="{{ request('wisata') }}">
? 'bg-white text-blue-700 shadow' <input type="hidden" name="destinasi" value="{{ request('destinasi') }}">
: 'text-white hover:bg-blue-600'" <select name="periode_id" onchange="document.getElementById('periode-form').submit()"
class="flex-1 py-3 rounded-lg text-sm font-medium transition"> class="border rounded-lg px-3 py-2 text-sm bg-white shadow-sm">
Dashboard Sentara @foreach($periodeList as $p)
</button> <option value="{{ $p->id }}" {{ ($periodeAktif && $periodeAktif->id == $p->id) ? 'selected' : '' }}>
{{ $p->nama }}
<button @click="tab='analisis'" </option>
:class="tab==='analisis' @endforeach
? 'bg-white text-blue-700 shadow' </select>
: 'text-white hover:bg-blue-600'" </form>
class="flex-1 py-3 rounded-lg text-sm font-medium transition"> </div>
Hasil Analisis Sentimen @if($periodeAktif)
</button> <span class="text-xs text-gray-400">
Data dianalisis: {{ \Carbon\Carbon::parse($periodeAktif->created_at)->format('d M Y') }}
<button @click="tab='rekomendasi'" </span>
:class="tab==='rekomendasi' @endif
? '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>
<div x-show="tab === 'dashboard'"> <div x-data="{ tab: new URLSearchParams(window.location.search).get('tab') || 'dashboard' }" class="mb-6">
{{-- 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"> <!-- TAB -->
<p class="text-gray-500 text-sm">Total Ulasan</p> <div class="bg-blue-700 rounded-xl p-1 flex space-x-2 shadow mb-6">
<h3 class="text-2xl font-bold mt-2 text-gray-900"> <button @click="tab='dashboard'; document.getElementById('tab-hidden').value='dashboard'"
{{ $stats['total'] }} :class="tab==='dashboard' ? 'bg-white text-blue-700 shadow' : 'text-white hover:bg-blue-600'"
</h3> 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> </div>
<div class="bg-white rounded-xl shadow p-5"> <!-- ================= DASHBOARD ================= -->
<p class="text-gray-500 text-sm">Sentimen Positif</p> <div x-show="tab === 'dashboard'">
<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"> {{-- Statistik Cards --}}
<p class="text-gray-500 text-sm">Sentimen Negatif</p> <div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<h3 class="text-2xl font-bold mt-2 text-red-600"> <div class="bg-white rounded-xl shadow p-5">
{{ $stats['negatif_persen'] }}% <p class="text-gray-500 text-sm">Total Ulasan</p>
</h3> <h3 class="text-2xl font-bold mt-2 text-gray-900">{{ $stats['total'] }}</h3>
</div> </div>
<div class="bg-white rounded-xl shadow p-5">
<div class="bg-white rounded-xl shadow p-5"> <p class="text-gray-500 text-sm">Sentimen Positif</p>
<p class="text-gray-500 text-sm">Sentimen Netral</p> <h3 class="text-2xl font-bold mt-2 text-green-600">{{ $stats['positif_persen'] }}%</h3>
<h3 class="text-2xl font-bold mt-2 text-yellow-500"> </div>
{{ $stats['netral_persen'] }}% <div class="bg-white rounded-xl shadow p-5">
</h3> <p class="text-gray-500 text-sm">Sentimen Negatif</p>
</div> <h3 class="text-2xl font-bold mt-2 text-red-600">{{ $stats['negatif_persen'] }}%</h3>
</div>
</div> <div class="bg-white rounded-xl shadow p-5">
<p class="text-gray-500 text-sm">Sentimen Netral</p>
{{-- Chart Section --}} <h3 class="text-2xl font-bold mt-2 text-yellow-500">{{ $stats['netral_persen'] }}%</h3>
<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 -->
<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>
</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
</div> </div>
</div>
<!-- WORD CLOUD --> {{-- Chart Section --}}
<div class="bg-white rounded-xl shadow p-6 h-[320px]"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<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> {{-- 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> </div>
<!-- ================= ANALISIS ================= -->
<div x-show="tab === 'analisis'" id="analisis-content">
@include('analisis.index')
</div> </div>
<!-- ================= REKOMENDASI ================= --> </div>
<div x-show="tab === 'rekomendasi'" id="rekomendasi-content">
@include('rekomendasi.index') {{-- 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>
</div> {{-- SCRIPTS --}}
{{-- ========================= --}}
{{-- SCRIPT SECTION --}}
{{-- ========================= --}}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <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 src="https://cdnjs.cloudflare.com/ajax/libs/wordcloud2.js/1.1.2/wordcloud2.min.js"></script>
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
// =============================
// PIE CHART // PIE CHART
// =============================
const pieData = JSON.parse('{!! json_encode($chartSentimen) !!}'); const pieData = JSON.parse('{!! json_encode($chartSentimen) !!}');
const pieCanvas = document.getElementById('pieChart');
const pieLabels = pieData.map(item => item.sentimen); if (window.pieChartInstance) window.pieChartInstance.destroy();
const pieValues = pieData.map(item => item.total); window.pieChartInstance = new Chart(pieCanvas, {
new Chart(document.getElementById('pieChart'), {
type: 'pie', type: 'pie',
data: { data: {
labels: pieLabels, labels: pieData.map(i => i.sentimen),
datasets: [{ 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 // WORD CLOUD
// ============================= const text = `{!! addslashes($allText) !!}`;
const text = `{!! $allText !!}`; if (text.length > 0) {
if(text.length > 0){
const words = text.split(" ");
const wordCount = {}; const wordCount = {};
words.forEach(word => { text.split(" ").forEach(word => {
if(word.length > 3){ if (word.length > 3) wordCount[word] = (wordCount[word] || 0) + 1;
wordCount[word] = (wordCount[word] || 0) + 1;
}
});
const list = Object.entries(wordCount);
WordCloud(document.getElementById('wordCloud'), {
list: list
}); });
WordCloud(document.getElementById('wordCloud'), { list: Object.entries(wordCount) });
} }
}); });
</script> </script>
<script>
const tab = new URLSearchParams(window.location.search).get('tab');
if (tab === 'analisis') {
showTab('analisis');
}
</script>
<script> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const container = document.getElementById('analisis-content'); const container = document.getElementById('analisis-content');
// ================= FILTER AJAX ================= // FILTER AJAX
document.addEventListener('submit', function(e){ document.addEventListener('submit', function(e){
if(e.target.id === 'filter-form'){ if(e.target.id === 'filter-form'){
e.preventDefault(); e.preventDefault();
const params = new URLSearchParams(new FormData(e.target)).toString();
const formData = new FormData(e.target);
const params = new URLSearchParams(formData).toString();
fetch('/dashboard?' + params) fetch('/dashboard?' + params)
.then(res => res.text()) .then(res => res.text())
.then(html => { .then(html => {
const parser = new DOMParser(); const doc = new DOMParser().parseFromString(html, 'text/html');
const doc = parser.parseFromString(html, 'text/html'); container.innerHTML = doc.getElementById('analisis-content').innerHTML;
const newContent = doc.getElementById('analisis-content');
container.innerHTML = newContent.innerHTML;
}); });
} }
}); });
// ================= PAGINATION AJAX ================= // PAGINATION AJAX
document.addEventListener('click', function(e){ document.addEventListener('click', function(e){
const link = e.target.closest('a'); const link = e.target.closest('a');
if(link && link.href.includes('page=')){ if(link && link.href.includes('page=')){
e.preventDefault(); e.preventDefault();
fetch(link.href) fetch(link.href)
.then(res => res.text()) .then(res => res.text())
.then(html => { .then(html => {
const parser = new DOMParser(); const doc = new DOMParser().parseFromString(html, 'text/html');
const doc = parser.parseFromString(html, 'text/html'); container.innerHTML = doc.getElementById('analisis-content').innerHTML;
const newContent = doc.getElementById('analisis-content');
container.innerHTML = newContent.innerHTML;
}); });
} }
}); });
}); });
</script> </script>
@endsection @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"> <div class="space-y-6">
{{-- HEADER --}} {{-- HEADER --}}
@ -9,26 +24,29 @@
</p> </p>
</div> </div>
<div class="flex gap-3"> {{-- FILTER DESTINASI submit ke /dashboard agar tab tidak reset --}}
<select class="border rounded-lg px-3 py-2 text-sm"> <form method="GET" action="{{ route('dashboard') }}" class="flex gap-3">
<option>Semua Destinasi</option> <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>
</form>
<!-- <select class="border rounded-lg px-3 py-2 text-sm">
<option>2020 - 2025</option>
</select> -->
</div>
</div> </div>
{{-- SUMMARY --}} {{-- SUMMARY CARDS --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-5"> <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-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 class="bg-red-100 text-red-500 p-3 rounded-full text-xl">😟</div>
<div> <div>
<p class="text-sm text-gray-500">Total Ulasan Negatif</p> <p class="text-sm text-gray-500">Total Ulasan Negatif</p>
<h3 class="text-xl font-bold">312</h3> <h3 class="text-xl font-bold">{{ $totalNegatif }}</h3>
<p class="text-xs text-red-500">25,06% dari total</p> <p class="text-xs text-red-500">{{ $persenNegatif }}% dari total</p>
</div> </div>
</div> </div>
@ -36,9 +54,12 @@
<div class="bg-green-100 text-green-600 p-3 rounded-full text-xl">😊</div> <div class="bg-green-100 text-green-600 p-3 rounded-full text-xl">😊</div>
<div> <div>
<p class="text-sm text-gray-500">Tingkat Kepuasan</p> <p class="text-sm text-gray-500">Tingkat Kepuasan</p>
<h3 class="text-xl font-bold">68%</h3> <h3 class="text-xl font-bold">{{ $tingkatKepuasan }}%</h3>
<span class="text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded"> <span class="text-xs px-2 py-1 rounded
Sedang {{ $labelKepuasan === 'Baik' ? 'bg-green-100 text-green-700' :
($labelKepuasan === 'Sedang' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700') }}">
{{ $labelKepuasan }}
</span> </span>
</div> </div>
</div> </div>
@ -47,20 +68,11 @@
<div class="bg-purple-100 text-purple-600 p-3 rounded-full text-xl"></div> <div class="bg-purple-100 text-purple-600 p-3 rounded-full text-xl"></div>
<div> <div>
<p class="text-sm text-gray-500">Isu Dominan</p> <p class="text-sm text-gray-500">Isu Dominan</p>
<h3 class="text-xl font-bold">Kebersihan</h3> <h3 class="text-xl font-bold">{{ $isuDominan }}</h3>
<p class="text-xs text-gray-500">45% dari total isu</p> <p class="text-xs text-gray-500">{{ $isuDominanPersen }}% dari total isu</p>
</div> </div>
</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> </div>
{{-- GRID ANALISIS --}} {{-- GRID ANALISIS --}}
@ -70,158 +82,120 @@
<div class="bg-white rounded-xl shadow p-5"> <div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-700">Isu Utama (Top 5)</h3> <h3 class="font-semibold mb-4 text-gray-700">Isu Utama (Top 5)</h3>
@php @forelse ($isuUtama as $i => $isu)
$isu = [ @php
['nama'=>'Kebersihan','persen'=>45,'warna'=>'red'], $warnaClass = match($isu['color']) {
['nama'=>'Parkir','persen'=>30,'warna'=>'orange'], 'red' => 'bg-red-500',
['nama'=>'Harga / Tiket','persen'=>15,'warna'=>'yellow'], 'orange' => 'bg-orange-500',
['nama'=>'Fasilitas','persen'=>6,'warna'=>'green'], 'yellow' => 'bg-yellow-400',
['nama'=>'Keramaian','persen'=>4,'warna'=>'blue'], 'green' => 'bg-green-500',
]; default => 'bg-blue-500',
@endphp };
@endphp
@foreach($isu as $i => $item)
<div class="mb-4"> <div class="mb-4">
<div class="flex justify-between text-sm mb-1"> <div class="flex justify-between text-sm mb-1">
<span>{{ $i+1 }}. {{ $item['nama'] }}</span> <span>{{ $i + 1 }}. {{ $isu['nama'] }}</span>
<span>{{ $item['persen'] }}%</span> <span class="font-semibold">{{ $isu['persen'] }}%</span>
</div> </div>
<div class="w-full bg-gray-200 h-2 rounded"> <div class="w-full bg-gray-200 h-2 rounded">
<div class="h-2 rounded <div class="h-2 rounded {{ $warnaClass }}" style="width: {{ $isu['persen'] }}%"></div>
@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> </div>
</div> </div>
@endforeach @empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu.</p>
@endforelse
</div> </div>
{{-- KEYWORD --}} {{-- KATA KUNCI DOMINAN --}}
<div class="bg-white rounded-xl shadow p-5"> <div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-700">Kata Kunci Dominan</h3> <h3 class="font-semibold mb-4 text-gray-700">Kata Kunci Dominan</h3>
@php @php $maxFreq = !empty($kataDominan) ? max($kataDominan) : 1; @endphp
$keywords = [
['kotor',15],
['parkir',10],
['mahal',7],
['sampah',6],
['toilet',5],
];
@endphp
@foreach($keywords as $k) @forelse (array_slice($kataDominan, 0, 5, true) as $kata => $jumlah)
<div class="mb-3"> <div class="mb-3">
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span>{{ $k[0] }}</span> <span>{{ $kata }}</span>
<span>{{ $k[1] }}x</span> <span class="font-semibold text-blue-600">{{ $jumlah }}x</span>
</div> </div>
<div class="w-full bg-gray-200 h-2 rounded mt-1"> <div class="w-full bg-gray-200 h-2 rounded mt-1">
<div class="bg-blue-500 h-2 rounded" <div class="bg-blue-500 h-2 rounded" style="width: {{ round(($jumlah / $maxFreq) * 100) }}%"></div>
style="width: {{ $k[1]*5 }}%"></div>
</div> </div>
</div> </div>
@endforeach @empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada kata kunci.</p>
@endforelse
</div> </div>
{{-- SARAN --}} {{-- SARAN PERBAIKAN --}}
<div class="bg-white rounded-xl shadow p-5"> <div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-700">Saran Perbaikan</h3> <h3 class="font-semibold mb-4 text-gray-700">Saran Perbaikan</h3>
<div class="space-y-4 text-sm"> <div class="space-y-4 text-sm">
@forelse ($saranPerbaikan as $saran)
<div class="flex gap-3"> <div class="flex gap-3">
<div class="text-green-500 text-xl">🧹</div> <div class="text-xl">{{ $saran['icon'] }}</div>
<div> <div>
<p class="font-semibold">Perbaikan Kebersihan</p> <p class="font-semibold">{{ $saran['nama'] }}</p>
<p class="text-gray-600"> <p class="text-gray-600">{{ $saran['tip'] }}</p>
Tambah tempat sampah & jadwal pembersihan rutin. </div>
</p>
</div> </div>
</div> @empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada saran.</p>
<div class="flex gap-3"> @endforelse
<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>
</div> </div>
</div> </div>
</div> </div>
{{-- PRIORITAS --}} {{-- PRIORITAS REKOMENDASI --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{{-- CARD 1 --}} @forelse ($prioritas as $p)
<div class="border border-red-300 rounded-xl p-5 bg-white"> @php
<h4 class="font-semibold text-red-600 mb-2">Prioritas 1</h4> $border = match($p['color']) {
<h3 class="text-lg font-bold mb-3">Kebersihan</h3> '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"> <ul class="text-sm space-y-2 mb-4">
<li> Tambah tempat sampah</li> @foreach ($p['actions'] as $action)
<li> Jadwal pembersihan rutin</li> <li class="flex items-center gap-2">
<li> Petugas kebersihan tambahan</li> <span class="text-blue-500"></span> {{ $action }}
</ul> </li>
@endforeach
</ul>
<div class="mt-4 text-xs bg-red-100 text-red-600 p-2 rounded"> <div class="text-xs {{ $dampak }} p-2 rounded">
Dampak: Kepuasan meningkat signifikan Dampak: {{ $p['dampak'] }}
</div>
</div> </div>
</div> @empty
<div class="col-span-3 text-center text-gray-400 text-sm py-6">
{{-- CARD 2 --}} Belum ada data rekomendasi. Pastikan analisis sentimen sudah dijalankan.
<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
</div> </div>
</div> @endforelse
{{-- 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>
</div> </div>

View File

@ -5,68 +5,56 @@
<div class="max-w-7xl mx-auto px-4 md:px-0 space-y-6"> <div class="max-w-7xl mx-auto px-4 md:px-0 space-y-6">
<!-- HEADER --> <!-- 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>
<!-- ============================= --> </div>
<!-- 🔥 UPLOAD + FORMAT + SUMMARY -->
<!-- ============================= -->
<div class="bg-white rounded-xl shadow p-6">
<!-- 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"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 items-center">
<!-- UPLOAD --> <!-- UPLOAD -->
<div class="border-2 border-dashed border-blue-300 rounded-xl p-6 text-center"> <div class="border-2 border-dashed border-blue-300 rounded-xl p-6 text-center">
<div class="flex justify-center mb-3"> <div class="flex justify-center mb-3">
<div class="bg-blue-100 text-blue-600 p-3 rounded-full text-xl"> <div class="bg-blue-100 text-blue-600 p-3 rounded-full text-xl">☁️</div>
☁️
</div>
</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> <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"> <form id="uploadForm" action="{{ route('ulasan.upload') }}" method="POST" enctype="multipart/form-data">
@csrf @csrf
<input type="file" name="file" id="fileInput" accept=".csv,.xlsx,.xls" class="hidden">
<input type="file" name="file" id="fileInput" <button type="button" onclick="document.getElementById('fileInput').click()"
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"> class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm shadow">
Pilih File Pilih File
</button> </button>
</form> </form>
<!-- <a href="#" class="block mt-3 text-blue-500 text-xs hover:underline">
Download template CSV
</a> -->
</div> </div>
<!-- FORMAT --> <!-- FORMAT -->
<div class="bg-blue-50 rounded-xl p-5"> <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"> <ul class="space-y-2 text-sm text-gray-700">
<li> wisata, rating, ulasan, tanggal</li> <li> wisata, rating, ulasan, tanggal</li>
<li> Format: YYYY-MM-DD HH:MM:SS</li> <li> Format: YYYY-MM-DD HH:MM:SS</li>
<li> Maks 10MB</li> <li> Maks 10MB</li>
</ul> </ul>
</div> </div>
<!-- SUMMARY --> <!-- SUMMARY -->
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="bg-blue-100 text-blue-600 p-3 rounded-full">📄</div> <div class="bg-blue-100 text-blue-600 p-3 rounded-full">📄</div>
<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> <p class="font-bold text-lg">{{ $totalUlasan }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="bg-green-100 text-green-600 p-3 rounded-full">📍</div> <div class="bg-green-100 text-green-600 p-3 rounded-full">📍</div>
<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> <p class="font-bold text-lg">{{ $totalWisata }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="bg-orange-100 text-orange-600 p-3 rounded-full">📅</div> <div class="bg-orange-100 text-orange-600 p-3 rounded-full">📅</div>
<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> </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- ALERT --> <!-- 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> </div>
@endif @endif
<!-- ============================= --> <!-- FILTER PERIODE + SEARCH -->
<!-- 🔍 SEARCH + FILTER --> <form method="GET" action="{{ route('ulasan.index') }}" class="flex flex-col md:flex-row gap-3 items-center">
<!-- ============================= -->
<form method="GET" action="{{ route('ulasan.index') }}" class="flex flex-col md:flex-row gap-3">
{{-- 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') }}" <input type="text" name="search" value="{{ request('search') }}"
placeholder="Cari ulasan atau wisata..." placeholder="Cari ulasan atau wisata..."
class="flex-1 border px-3 py-2 rounded-lg text-sm"> class="flex-1 border px-3 py-2 rounded-lg text-sm">
<!-- <select name="wisata" onchange="this.form.submit()" <button class="bg-blue-500 text-white px-4 py-2 rounded-lg text-sm">Cari</button>
class="border rounded-lg px-3 py-2 text-sm">
<option value="">Semua Destinasi</option> <a href="{{ route('ulasan.index') }}" class="bg-gray-200 px-4 py-2 rounded-lg text-sm">Reset</a>
<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>
</form> </form>
<!-- ============================= -->
<!-- TABLE --> <!-- TABLE -->
<!-- ============================= -->
<div class="bg-white rounded-xl shadow overflow-hidden"> <div class="bg-white rounded-xl shadow overflow-hidden">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-sm">
<table class="w-full text-sm"> <thead class="bg-gray-100">
<thead class="bg-gray-100"> <tr>
<tr> <th class="px-4 py-3 w-[5%]">No</th>
<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-[20%]">Nama Wisata</th> <th class="px-4 py-3 w-[10%] text-center">Rating</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-[45%]">Ulasan</th> <th class="px-4 py-3 w-[20%] text-center">Tanggal</th>
<th class="px-4 py-3 w-[20%] text-center">Tanggal</th> </tr>
</tr> </thead>
</thead> <tbody class="divide-y">
@forelse($mentah as $i => $item)
<tbody class="divide-y"> <tr class="hover:bg-gray-50">
<td class="px-4 py-3">{{ $mentah->firstItem() + $i }}</td>
@forelse($mentah as $i => $item) <td class="px-4 py-3 font-medium">{{ $item->wisata }}</td>
<tr class="hover:bg-gray-50"> <td class="px-4 py-3 text-center text-yellow-500">
{{ str_repeat('★', round($item->rating)) }}
<td class="px-4 py-3">{{ $mentah->firstItem() + $i }}</td> <span class="text-gray-500 ml-1">{{ $item->rating }}</span>
</td>
<td class="px-4 py-3 font-medium"> <td class="px-4 py-3">
{{ $item->wisata }} <div class="line-clamp-2 text-gray-600">{{ $item->ulasan }}</div>
</td> </td>
<td class="px-4 py-3 text-center text-gray-500">
<td class="px-4 py-3 text-center text-yellow-500"> {{ \Carbon\Carbon::parse($item->tanggal)->diffForHumans() }}
{{ str_repeat('★', round($item->rating)) }} </td>
<span class="text-gray-500 ml-1">{{ $item->rating }}</span> </tr>
</td> @empty
<tr>
<td class="px-4 py-3"> <td colspan="5" class="text-center py-6 text-gray-400">Belum ada data</td>
<div class="line-clamp-2 text-gray-600"> </tr>
{{ $item->ulasan }} @endforelse
</div> </tbody>
</td> </table>
<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> </div>
<div class="p-4 flex flex-col md:flex-row gap-3 justify-between items-center text-sm text-gray-500"> <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) --> @for ($i = 1; $i <= $mentah->lastPage(); $i++)
<div> @if ($i == $mentah->currentPage())
Menampilkan {{ $mentah->firstItem() ?? 0 }} - {{ $mentah->lastItem() ?? 0 }} <span class="px-3 py-1 bg-blue-600 text-white rounded-lg">{{ $i }}</span>
dari {{ $mentah->total() }} data @elseif ($i <= 3 || $i > $mentah->lastPage() - 2 || abs($i - $mentah->currentPage()) <= 1)
</div> <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) --> @if ($mentah->hasMorePages())
<div class="flex items-center gap-1"> <a href="{{ $mentah->nextPageUrl() }}" class="px-3 py-1 bg-gray-100 rounded-lg">»</a>
@else
@if ($mentah->onFirstPage()) <span class="px-3 py-1 bg-gray-200 rounded-lg">»</span>
<span class="px-3 py-1 bg-gray-200 rounded-lg">«</span> @endif
@else </div>
<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>
</div> </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> <script>
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById("fileInput"); const input = document.getElementById("fileInput");
const form = document.getElementById("uploadForm"); const form = document.getElementById("uploadForm");
input.addEventListener("change", function () { input.addEventListener("change", function () {
form.submit(); form.submit();
}); });

View File

@ -7,41 +7,60 @@
use App\Http\Controllers\AnalisisController; use App\Http\Controllers\AnalisisController;
use App\Http\Controllers\RekomendasiController; use App\Http\Controllers\RekomendasiController;
/*
|--------------------------------------------------------------------------
| ROUTE AWAL
|--------------------------------------------------------------------------
*/
// hanya SATU route '/'
Route::get('/', function () { Route::get('/', function () {
return redirect('/login'); return redirect('/login');
}); });
Route::get('/', function () { /*
return view('welcome'); |--------------------------------------------------------------------------
}); | ROUTE SETELAH LOGIN
|--------------------------------------------------------------------------
*/
// Dashboard SENTARA (harus login) Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])
->middleware(['auth', 'verified'])
->name('dashboard');
// Route::get('/dashboard', function () { // ✅ DASHBOARD
// return view('dashboard'); Route::get('/dashboard', [DashboardController::class, 'index'])
// })->middleware(['auth', 'verified'])->name('dashboard'); ->name('dashboard');
Route::middleware('auth')->group(function () { // ✅ PROFILE (ini yang kamu tanya)
Route::get('/profile', function () { Route::get('/profile', function () {
return view('profile.index'); return view('profile.index');
})->name('profile'); })->name('profile');
// kalau mau aktifkan update:
// Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); // Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
// Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); // 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');
}); });

View File

@ -1,6 +1,12 @@
# -*- coding: utf-8 -*-
import sys
sys.stdout.reconfigure(encoding='utf-8')
import re import re
import numpy as np
import pandas as pd import pandas as pd
import pymysql import pymysql
from sqlalchemy import create_engine
from sklearn.model_selection import train_test_split from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer 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.Stemmer.StemmerFactory import StemmerFactory
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory 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', host='localhost',
user='root', user='root',
password='', password='',
database='sentara', database='sentara',
cursorclass=pymysql.cursors.DictCursor cursorclass=pymysql.cursors.DictCursor
) )
cursor = raw_conn.cursor()
cursor = 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 = df.dropna(subset=["ulasan"])
df["ulasan"] = df["ulasan"].astype(str) df["ulasan"] = df["ulasan"].astype(str)
df = df[df["ulasan"].str.strip() != ""] df = df[df["ulasan"].str.strip() != ""]
df = df[df["ulasan"].str.strip() != "0"]
# =============================== # ===============================
# 3. PREPROCESSING # 4. PREPROCESSING
# =============================== # ===============================
stemmer = StemmerFactory().create_stemmer() stemmer = StemmerFactory().create_stemmer()
stop_factory = StopWordRemoverFactory() stop_factory = StopWordRemoverFactory()
@ -46,37 +64,57 @@ def clean_text(text):
text = text.lower() text = text.lower()
text = re.sub(r"http\S+", " ", text) text = re.sub(r"http\S+", " ", text)
text = re.sub(r"[^a-zA-Z\s]", " ", text) text = re.sub(r"[^a-zA-Z\s]", " ", text)
text = re.sub(r"\s+", " ", text) text = re.sub(r"\s+", " ", text).strip()
words = [w for w in text.split() if w not in stopwords and len(w) > 2]
words = [w for w in text.split() if w not in stopwords]
return stemmer.stem(" ".join(words)) return stemmer.stem(" ".join(words))
df["clean"] = df["ulasan"].apply(clean_text) 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): def label_rule(text):
if "tidak" in text or "buruk" in text: words = set(text.split())
return "negatif" skor_pos = len(words & positif_words)
elif "bagus" in text or "indah" in text: skor_neg = len(words & negatif_words)
if skor_pos > skor_neg:
return "positif" return "positif"
elif skor_neg > skor_pos:
return "negatif"
else: else:
return "netral" return "netral"
df["label"] = df["clean"].apply(label_rule) df["label"] = df["clean"].apply(label_rule)
# =============================== # ===============================
# 5. TF-IDF + MODEL # 6. TF-IDF + MODEL
# =============================== # ===============================
X = df["clean"] X = df["clean"]
y = df["label"] y = df["label"]
X_train, X_test, y_train, y_test = train_test_split( if len(df) > 5:
X, y, test_size=0.2, random_state=42, stratify=y 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_train_vec = vectorizer.fit_transform(X_train)
X_test_vec = vectorizer.transform(X_test) X_test_vec = vectorizer.transform(X_test)
@ -84,59 +122,65 @@ model = ComplementNB()
model.fit(X_train_vec, y_train) model.fit(X_train_vec, y_train)
# =============================== # ===============================
# 6. EVALUASI # 7. EVALUASI
# =============================== # ===============================
y_pred = model.predict(X_test_vec) y_pred = model.predict(X_test_vec)
acc = accuracy_score(y_test, y_pred) 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"]) 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"]) X_all = vectorizer.transform(df["clean"])
df["prediksi"] = model.predict(X_all) df["prediksi"] = model.predict(X_all)
# =============================== # ===============================
# 8. SIMPAN KE DB # 9. SIMPAN KE DB
# =============================== # ===============================
df = df.fillna("")
# kosongkan tabel # Hapus data periode ini saja (bukan semua)
cursor.execute("DELETE FROM hasil_analisis") cursor.execute("DELETE FROM hasil_analisis WHERE periode_id = %s", (periode_id,))
cursor.execute("DELETE FROM evaluasi_model") 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(): for _, row in df.iterrows():
cursor.execute(""" cursor.execute(insert_query, (
INSERT INTO hasil_analisis str(row["wisata"]),
(wisata, ulasan_terolah, hasil_preprocessing, sentimen, probabilitas, created_at) str(row["ulasan"]),
VALUES (%s,%s,%s,%s,%s,NOW()) str(row["clean"]),
""", ( str(row["clean"]),
row["wisata"], str(row["prediksi"]),
row["ulasan"], float(0.9),
row["clean"], periode_id
row["prediksi"],
0.9
)) ))
# simpan evaluasi print(f"[OK] {len(df)} ulasan berhasil disimpan untuk periode {periode_nama}")
cursor.execute(""" cursor.execute("""
INSERT INTO evaluasi_model INSERT INTO evaluasi_model
(precision, recall, f1_score, accuracy, tp, tn, fp, fn, created_at) (`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
""", ( """, (
report["weighted avg"]["precision"], float(report["weighted avg"]["precision"]),
report["weighted avg"]["recall"], float(report["weighted avg"]["recall"]),
report["weighted avg"]["f1-score"], float(report["weighted avg"]["f1-score"]),
acc, float(acc),
int(cm[2][2]), int(cm[2][2]) if cm.shape == (3,3) else 0,
int(cm[0][0]), int(cm[0][0]) if cm.shape == (3,3) else 0,
int(cm[0][2]), int(cm[0][2]) if cm.shape == (3,3) else 0,
int(cm[2][0]) int(cm[2][0]) if cm.shape == (3,3) else 0,
periode_id
)) ))
conn.commit() raw_conn.commit()
conn.close() raw_conn.close()
print("Analisis selesai ") print("Analisis selesai [OK]")