perubahan

This commit is contained in:
MufridaFaraDiani27 2026-06-10 02:43:04 +07:00
parent 5be35f3851
commit e622cda9f3
23 changed files with 2437 additions and 2585 deletions

View File

@ -10,6 +10,7 @@ class AnalisisController extends Controller
{
public function index(Request $request)
{
;
$periodeList = DB::table('periode_analisis as p')
->whereExists(function ($q) {
$q->select(DB::raw(1))
@ -55,7 +56,8 @@ public function index(Request $request)
$query->where('wisata', $request->wisata);
}
$hasil = $query->orderBy('id', 'desc')->paginate(10);
$hasil = $query->orderBy('created_at', 'desc')->paginate(10);
$standalone = true;
return view('analisis.index', compact(
@ -64,7 +66,9 @@ public function index(Request $request)
'evaluasi',
'standalone',
'jumlahKelasAnalisis',
'totalHasilAnalisis'
'totalHasilAnalisis',
'periodeAktif', // ← tambahkan
'periodeList', // ← tambahkan
));
}
}

View File

@ -8,6 +8,7 @@
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Carbon\Carbon;
use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller
{

View File

@ -60,330 +60,412 @@ class DashboardController extends Controller
],
];
public function index(Request $request)
{
$tab = $request->input('tab', 'dashboard');
$wisata = $tab === 'analisis' ? $request->wisata : null;
$destinasi = $tab === 'rekomendasi' ? $request->destinasi : null;
public function index(Request $request)
{
$tab = $request->input('tab', 'dashboard');
$wisata = $tab === 'analisis' ? $request->wisata : null;
$destinasi = $tab === 'rekomendasi' ? $request->destinasi : null;
// ================================================================
// PERIODE BULAN YANG DIPILIH
// ================================================================
$periodeBulan = $request->input('periode_bulan', now()->format('Y-m'));
$currentMonth = now()->month;
$currentYear = now()->year;
[$tahun, $bulan] = explode('-', $periodeBulan);
// ================================================================
// AMBIL SEMUA PERIODE YANG PUNYA DATA
// ================================================================
$periodeList = DB::table('periode_analisis as p')
->whereExists(function ($q) {
$q->select(DB::raw(1))->from('ulasan as u')->whereColumn('u.periode_id', 'p.id');
})
->orWhereExists(function ($q) {
$q->select(DB::raw(1))->from('hasil_analisis as h')->whereColumn('h.periode_id', 'p.id');
})
->orderBy('p.tahun', 'desc')
->orderBy('p.bulan', 'desc')
->select('p.*')
->get();
// ================================================================
// AMBIL SEMUA PERIODE & PERIODE AKTIF
// ================================================================
$periodeList = DB::table('periode_analisis as p')
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('ulasan as u')
->whereColumn('u.periode_id', 'p.id');
})
->orWhereExists(function ($q) {
$q->select(DB::raw(1))
->from('hasil_analisis as h')
->whereColumn('h.periode_id', 'p.id');
})
->orderBy('p.id', 'desc')
->select('p.*')
->get();
// ================================================================
// TENTUKAN PERIODE AKTIF
// Dashboard, Hasil Analisis, Rekomendasi:
// - Kalau request manual → pakai itu
// - Kalau tidak → cari bulan ini; kalau belum ada data → fallback ke bulan terakhir
// ================================================================
if ($request->filled('periode_id')) {
// Prioritas utama: periode_id langsung (dikirim saat filter destinasi/wisata)
$periodeAktif = $periodeList->firstWhere('id', (int) $request->periode_id);
} elseif ($request->filled('periode_bulan')) {
[$tahun, $bulan] = explode('-', $request->periode_bulan);
$periodeAktif = $periodeList->first(fn($p) =>
$p->tahun == (int) $tahun && $p->bulan == (int) $bulan
);
} else {
// Cari bulan ini
$periodeAktif = $periodeList->first(fn($p) =>
$p->bulan == $currentMonth && $p->tahun == $currentYear
);
// Belum ada data bulan ini → fallback ke bulan terakhir yang ada data
if (!$periodeAktif) {
$periodeAktif = $periodeList->first(); // sudah urut desc
}
}
// Cocokkan pakai kolom bulan & tahun
$periodeAktif = $periodeList->first(function ($p) use ($tahun, $bulan) {
return $p->tahun == (int)$tahun && $p->bulan == (int)$bulan;
});
$noDataPesan = null;
if (!$periodeAktif) {
$noDataPesan = 'Belum ada data.';
}
// Jika tidak ada data di bulan tsb
$noDataPesan = null;
if (!$periodeAktif) {
$namaBulanDipilih = \Carbon\Carbon::createFromDate($tahun, $bulan, 1)
->locale('id')->isoFormat('MMMM YYYY');
$noDataPesan = "Tidak ada data untuk bulan {$namaBulanDipilih}.";
}
$periodeId = $periodeAktif->id ?? null;
$periodeId = $periodeAktif->id ?? null;
// ================================================================
// EVALUASI MODEL
// ================================================================
$evaluasi = DB::table('evaluasi_model')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest()
->first();
$evaluasi = DB::table('evaluasi_model')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest()->first();
// ================================================================
// 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();
// ================================================================
// CEK ADA ANALISIS ATAU BELUM
// ================================================================
$hasAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->count() > 0;
// ================================================================
// 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();
$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();
$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();
$totalSentimen = $positif + $negatif + $netral;
$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,
];
// ================================================================
// 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();
$rawSentimen = 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')
->pluck('total', 'sentimen');
$chartSentimen = collect([
['sentimen' => 'positif', 'total' => $rawSentimen['positif'] ?? 0],
['sentimen' => 'negatif', 'total' => $rawSentimen['negatif'] ?? 0],
['sentimen' => 'netral', 'total' => $rawSentimen['netral'] ?? 0],
]);
$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")
)
// ================================================================
// 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();
// ================================================================
// CEK ADA ANALISIS ATAU BELUM
// ================================================================
$hasAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->count() > 0;
// ================================================================
// 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();
$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();
$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();
$totalSentimen = $positif + $negatif + $netral;
$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,
];
// ================================================================
// CHART DATA
// ================================================================
$rawSentimen = 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')
->pluck('total', 'sentimen');
$chartSentimen = collect([
['sentimen' => 'positif', 'total' => $rawSentimen['positif'] ?? 0],
['sentimen' => 'negatif', 'total' => $rawSentimen['negatif'] ?? 0],
['sentimen' => 'netral', 'total' => $rawSentimen['netral'] ?? 0],
]);
$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()
: collect();
// ================================================================
// 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()
: collect();
->get();
// ================================================================
// 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();
$texts = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->pluck('ulasan_bersih')
->toArray();
$allText = count($texts) > 0 ? implode(' ', $texts) : '';
$texts = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->pluck('ulasan_bersih')
->toArray();
$allText = count($texts) > 0 ? implode(' ', $texts) : '';
$lastUpdate = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest('created_at')
->value('created_at');
$lastUpdate = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest('created_at')->value('created_at');
$destinasiList = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->select('wisata')->distinct()->pluck('wisata');
$wisataList = $destinasiList;
$destinasiList = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->select('wisata')->distinct()->pluck('wisata');
$wisataList = $destinasiList;
$totalDestinasiAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->distinct('wisata')->count('wisata');
$totalDestinasiAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->distinct('wisata')
->count('wisata');
$totalDestinasiBerkeluhan = DB::table('hasil_analisis')
->where('sentimen', 'negatif')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->distinct('wisata')->count('wisata');
$totalDestinasiBerkeluhan = DB::table('hasil_analisis')
->where('sentimen', 'negatif')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->distinct('wisata')
->count('wisata');
$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))
->orderBy('created_at', 'desc')
->paginate(10);
$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))
->orderByDesc('ulasan_id')
->paginate(10);
$jumlahKelasAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->distinct('sentimen')->count('sentimen');
$jumlahKelasAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->distinct('sentimen')
->count('sentimen');
$totalHasilAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->count();
$totalHasilAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
->count();
// ================================================================
// 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();
// ================================================================
// 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));
$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 = [];
foreach ($this->issueRules as $kategori => $rule) {
$skor = 0;
foreach ($rule['keywords'] as $kw) {
$skor += substr_count($textRek, $kw);
}
$issueSkor[$kategori] = $skor;
}
$issueSkor[$kategori] = $skor;
}
arsort($issueSkor);
$issueSkorAktif = array_filter($issueSkor, fn($skor) => $skor > 0);
$totalSkorIsu = array_sum($issueSkorAktif) ?: 1;
arsort($issueSkor);
$issueSkorAktif = array_filter($issueSkor, fn($skor) => $skor > 0);
$totalSkorIsu = array_sum($issueSkorAktif) ?: 1;
$isuUtama = [];
foreach (array_slice($issueSkorAktif, 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'],
$isuUtama = [];
foreach (array_slice($issueSkorAktif, 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($issueSkorAktif, 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($issueSkorAktif, 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;
// --- Variabel tambahan untuk tab Rekomendasi ---
$netralRek = DB::table('hasil_analisis')
->where('sentimen', 'netral')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
->count();
$totalUlasanRek = $positifRek + $negatifRek + $netralRek;
$totalPositif = $positifRek;
$totalNetral = $netralRek;
$persenPositif = $totalUlasanRek > 0 ? round(($positifRek / $totalUlasanRek) * 100) : 0;
$persenNetral = $totalUlasanRek > 0 ? round(($netralRek / $totalUlasanRek) * 100) : 0;
// Isu negatif (sudah ada dari $isuUtama)
$isuNegatif = $isuUtama;
// Isu netral (rule-based dari ulasan netral)
$ulasanNetralRek = DB::table('hasil_analisis')
->where('sentimen', 'netral')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
->pluck('ulasan_bersih')
->toArray();
$textNetral = strtolower(implode(' ', $ulasanNetralRek));
$skorNetral = [];
foreach ($this->issueRules as $kategori => $rule) {
$skor = 0;
foreach ($rule['keywords'] as $kw) { $skor += substr_count($textNetral, $kw); }
$skorNetral[$kategori] = $skor;
}
arsort($skorNetral);
$totalSkorNetral = array_sum($skorNetral) ?: 1;
$isuNetral = [];
foreach (array_slice($skorNetral, 0, 5, true) as $nama => $skor) {
$isuNetral[] = [
'nama' => $nama,
'skor' => $skor,
'persen' => round(($skor / $totalSkorNetral) * 100),
'color' => $this->issueRules[$nama]['color'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
// Isu positif (rule-based dari ulasan positif)
$ulasanPositifRek = 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))
->pluck('ulasan_bersih')
->toArray();
$textPositif = strtolower(implode(' ', $ulasanPositifRek));
$skorPositif = [];
foreach ($this->issueRules as $kategori => $rule) {
$skor = 0;
foreach ($rule['keywords'] as $kw) { $skor += substr_count($textPositif, $kw); }
$skorPositif[$kategori] = $skor;
}
arsort($skorPositif);
$totalSkorPositif = array_sum($skorPositif) ?: 1;
$isuPositif = [];
foreach (array_slice($skorPositif, 0, 5, true) as $nama => $skor) {
$isuPositif[] = [
'nama' => $nama,
'skor' => $skor,
'persen' => round(($skor / $totalSkorPositif) * 100),
'color' => $this->issueRules[$nama]['color'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
// ================================================================
// RETURN VIEW
// ================================================================
return view('dashboard', compact(
'stats',
'chartSentimen',
'chartDestinasi',
'totalPerWisata',
'allText',
'lastUpdate',
'hasAnalisis',
'destinasiList',
'wisataList',
'totalDestinasiAnalisis',
'totalDestinasiBerkeluhan',
'hasil',
'jumlahKelasAnalisis',
'totalHasilAnalisis',
'evaluasi',
'totalNegatif',
'persenNegatif',
'persenPositif',
'persenNetral',
'tingkatKepuasan',
'labelKepuasan',
'isuUtama',
'isuNegatif',
'isuNetral',
'isuPositif',
'isuDominan',
'isuDominanPersen',
'kataDominan',
'saranPerbaikan',
'prioritas',
'periodeId',
'totalPositif',
'totalNetral',
'periodeList',
'periodeAktif',
'noDataPesan',
'currentMonth',
'currentYear',
))->with([
'scraperDestinations' => $this->scraperDestinations(),
]);
}
$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($issueSkorAktif, 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($issueSkorAktif, 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',
'wisataList',
'totalDestinasiAnalisis',
'totalDestinasiBerkeluhan',
'hasil',
'jumlahKelasAnalisis',
'totalHasilAnalisis',
'evaluasi',
'totalNegatif',
'persenNegatif',
'tingkatKepuasan',
'labelKepuasan',
'isuUtama',
'isuDominan',
'isuDominanPersen',
'kataDominan',
'saranPerbaikan',
'prioritas',
'periodeList',
'periodeAktif',
'noDataPesan', // ← tambahan baru
))->with([
'scraperDestinations' => $this->scraperDestinations(),
]);
}
private function scraperDestinations(): array
{
return [

View File

@ -27,6 +27,7 @@ public function downloadBulanan($periode)
$data['evaluasi'] =
DB::table('evaluasi_model')
->where('periode_id', $periode)
->orderByDesc('id')
->first()
?? (object)['accuracy' => 0];
@ -158,10 +159,10 @@ public function downloadTahunan(Request $request)
// AKURASI
$akurasi =
DB::table('evaluasi_model')
->latest('id')
->value('accuracy')
?? 0;
->whereIn('periode_id', $periodeIds)
->avg('accuracy') ?? 0;
// TOTAL WISATA
$totalWisata =
DB::table('hasil_analisis')

View File

@ -74,10 +74,37 @@ public function index(Request $request)
->distinct()
->pluck('wisata');
// --- 2. Query dengan filter destinasi ---
// --- 2. Query dengan filter destinasi & periode ---
$queryAll = HasilAnalisis::query();
$queryFilter = HasilAnalisis::query();
// Resolve periode dari request (periode_bulan atau periode_id)
$periodeId = null;
if ($request->filled('periode_id')) {
$periodeId = (int) $request->periode_id;
} elseif ($request->filled('periode_bulan')) {
[$tahunParam, $bulanParam] = explode('-', $request->periode_bulan);
$periodeRow = DB::table('periode_analisis')
->where('bulan', (int) $bulanParam)
->where('tahun', (int) $tahunParam)
->first();
$periodeId = $periodeRow?->id;
}
// Kalau tidak ada periode di request → pakai periode bulan ini
if (!$periodeId) {
$periodeRow = DB::table('periode_analisis')
->where('bulan', now()->month)
->where('tahun', now()->year)
->first();
$periodeId = $periodeRow?->id;
}
if ($periodeId) {
$queryAll->where('periode_id', $periodeId);
$queryFilter->where('periode_id', $periodeId);
}
if ($request->filled('destinasi')) {
$queryFilter->where('wisata', $request->destinasi);
}
@ -89,7 +116,19 @@ public function index(Request $request)
$totalNetral = (clone $queryFilter)->where('sentimen', 'netral')->count();
$persenNegatif = $totalUlasan > 0 ? round(($totalNegatif / $totalUlasan) * 100, 2) : 0;
$tingkatKepuasan = $totalUlasan > 0 ? round(($totalPositif / $totalUlasan) * 100) : 0;
$persenPositif = $totalUlasan > 0 ? round(($totalPositif / $totalUlasan) * 100) : 0;
$persenNetral = $totalUlasan > 0 ? round(($totalNetral / $totalUlasan) * 100) : 0;
$tingkatKepuasan = $persenPositif;
// --- 3a. Statistik destinasi ---
$totalDestinasiAnalisis = $queryAll->distinct('wisata')->count('wisata');
$totalDestinasiBerkeluhan = (clone $queryAll)->where('sentimen', 'negatif')
->distinct('wisata')->count('wisata');
// --- 3b. Periode aktif ---
$periodeAktif = $periodeId
? DB::table('periode_analisis')->find($periodeId)
: null;
// Label kepuasan
$labelKepuasan = match(true) {
@ -147,6 +186,80 @@ public function index(Request $request)
];
}
// --- 6a. Isu negatif (untuk kolom tabel negatif di tampilan) ---
$isuNegatif = [];
foreach (array_slice($issueSkor, 0, 5, true) as $nama => $skor) {
$isuNegatif[] = [
'nama' => $nama,
'skor' => $skor,
'persen' => round(($skor / $totalSkorIsu) * 100),
'color' => $this->issueRules[$nama]['color'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
// --- 6b. Isu netral (rule-based dari ulasan netral) ---
$ulasanNetral = (clone $queryFilter)
->where('sentimen', 'netral')
->get(['ulasan_bersih', 'ulasan'])
->map(fn($r) => $r->ulasan_bersih ?? $r->ulasan ?? '')
->filter()
->toArray();
$textNetral = strtolower(implode(' ', $ulasanNetral));
$skorNetral = [];
foreach ($this->issueRules as $kategori => $rule) {
$skor = 0;
foreach ($rule['keywords'] as $kw) {
$skor += substr_count($textNetral, $kw);
}
$skorNetral[$kategori] = $skor;
}
arsort($skorNetral);
$totalSkorNetral = array_sum($skorNetral) ?: 1;
$isuNetral = [];
foreach (array_slice($skorNetral, 0, 5, true) as $nama => $skor) {
$isuNetral[] = [
'nama' => $nama,
'skor' => $skor,
'persen' => round(($skor / $totalSkorNetral) * 100),
'color' => $this->issueRules[$nama]['color'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
// --- 6c. Isu positif (rule-based dari ulasan positif) ---
$ulasanPositif = (clone $queryFilter)
->where('sentimen', 'positif')
->get(['ulasan_bersih', 'ulasan'])
->map(fn($r) => $r->ulasan_bersih ?? $r->ulasan ?? '')
->filter()
->toArray();
$textPositif = strtolower(implode(' ', $ulasanPositif));
$skorPositif = [];
foreach ($this->issueRules as $kategori => $rule) {
$skor = 0;
foreach ($rule['keywords'] as $kw) {
$skor += substr_count($textPositif, $kw);
}
$skorPositif[$kategori] = $skor;
}
arsort($skorPositif);
$totalSkorPositif = array_sum($skorPositif) ?: 1;
$isuPositif = [];
foreach (array_slice($skorPositif, 0, 5, true) as $nama => $skor) {
$isuPositif[] = [
'nama' => $nama,
'skor' => $skor,
'persen' => round(($skor / $totalSkorPositif) * 100),
'color' => $this->issueRules[$nama]['color'],
'icon' => $this->issueRules[$nama]['icon'],
];
}
// --- 7. Isu dominan (ranking 1) ---
// FIX: guard jika tidak ada ulasan negatif sama sekali
if (empty($isuUtama) || $isuUtama[0]['skor'] === 0) {
@ -210,14 +323,23 @@ public function index(Request $request)
return view('rekomendasi.index', compact(
'destinasiList',
'destinasiAktif',
'periodeId',
'totalUlasan',
'totalNegatif',
'totalPositif',
'totalNetral',
'persenNegatif',
'persenPositif',
'persenNetral',
'tingkatKepuasan',
'labelKepuasan',
'totalDestinasiAnalisis',
'totalDestinasiBerkeluhan',
'periodeAktif',
'isuUtama',
'isuNegatif',
'isuNetral',
'isuPositif',
'isuDominan',
'isuDominanPersen',
'kataDominan',

View File

@ -12,100 +12,85 @@
class UlasanController extends Controller
{
public function index(Request $request)
{
$periodeList = DB::table('periode_analisis as p')
->whereExists(function ($q) {
$q->select(DB::raw(1))
->from('ulasan as u')
->whereColumn('u.periode_id', 'p.id');
})
->orWhereExists(function ($q) {
$q->select(DB::raw(1))
->from('hasil_analisis as h')
->whereColumn('h.periode_id', 'p.id');
})
->orderBy('p.tahun', 'desc')
->orderBy('p.bulan', 'desc')
->select('p.*')
->get();
{
$currentMonth = now()->month;
$currentYear = now()->year;
$availablePeriodeIds = $periodeList->pluck('id')->map(fn($id) => (string) $id);
// Ambil semua periode yang punya data (ulasan atau hasil analisis)
$periodeList = DB::table('periode_analisis as p')
->whereExists(function ($q) {
$q->select(DB::raw(1))->from('ulasan as u')->whereColumn('u.periode_id', 'p.id');
})
->orWhereExists(function ($q) {
$q->select(DB::raw(1))->from('hasil_analisis as h')->whereColumn('h.periode_id', 'p.id');
})
->orderBy('p.tahun', 'desc')
->orderBy('p.bulan', 'desc')
->select('p.*')
->get();
$requestedPeriodeId = $request->filled('periode_id')
? (string) $request->periode_id
: null;
// Kalau request manual dari dropdown → pakai itu
if ($request->filled('periode_id')) {
$periodeId = (int) $request->periode_id;
$periodeAktif = $periodeList->firstWhere('id', $periodeId)
?? DB::table('periode_analisis')->find($periodeId);
} else {
// Cari / buat periode bulan ini (meski belum ada data → tetap tampil kosong)
$periodeBulanIni = DB::table('periode_analisis')
->where('bulan', $currentMonth)
->where('tahun', $currentYear)
->first();
// cari periode bulan & tahun sekarang
$currentMonth = now()->month;
$currentYear = now()->year;
if (!$periodeBulanIni) {
$periodeId = DB::table('periode_analisis')->insertGetId([
'nama' => now()->locale('id')->isoFormat('MMMM YYYY'),
'bulan' => $currentMonth,
'tahun' => $currentYear,
'created_at' => now(),
'updated_at' => now(),
]);
$periodeAktif = DB::table('periode_analisis')->find($periodeId);
} else {
$periodeId = $periodeBulanIni->id;
$periodeAktif = $periodeBulanIni;
}
}
$currentPeriode = $periodeList->first(function ($periode) use ($currentMonth, $currentYear) {
return $periode->bulan == $currentMonth
&& $periode->tahun == $currentYear;
});
// Query ulasan berdasarkan periode aktif
$query = Ulasan::query()->where('periode_id', $periodeId);
// kalau bulan sekarang tidak ada → ambil periode terakhir
$defaultPeriodeId = $currentPeriode->id
?? $periodeList->sortByDesc('id')->first()->id
?? null;
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('ulasan', 'LIKE', "%$search%")
->orWhere('wisata', 'LIKE', "%$search%");
});
}
// prioritas:
// 1. request manual dari dropdown
// 2. periode bulan sekarang
// 3. periode terakhir
$periodeId = $request->filled('periode_id')
&& $availablePeriodeIds->contains($requestedPeriodeId)
? $requestedPeriodeId
: $defaultPeriodeId;
if ($request->filled('wisata')) {
$query->where('wisata', 'LIKE', '%' . $request->wisata . '%');
}
$periodeAktif = $periodeId
? $periodeList->firstWhere('id', (int) $periodeId)
: null;
$filteredQuery = clone $query;
$totalUlasan = (clone $filteredQuery)->count();
$totalWisata = (clone $filteredQuery)->distinct('wisata')->count('wisata');
$lastUpdate = (clone $filteredQuery)->latest()->value('created_at');
$query = Ulasan::query();
$mentah = $query->orderBy('tanggal', 'desc')->paginate(20)->withQueryString();
// 🔍 SEARCH (ulasan + wisata)
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('ulasan', 'LIKE', "%$search%")
->orWhere('wisata', 'LIKE', "%$search%");
});
return view('ulasan.index', compact(
'mentah',
'totalUlasan',
'totalWisata',
'lastUpdate',
'periodeList',
'periodeId',
'periodeAktif',
'currentMonth',
'currentYear'
))->with(['scraperDestinations' => $this->scraperDestinations()]);
}
// 🔍 FILTER WISATA
if ($request->filled('wisata')) {
$query->where('wisata', 'LIKE', '%' . $request->wisata . '%');
}
// ← default/reset menampilkan periode terbaru
if ($periodeId) {
$query->where('periode_id', $periodeId);
}
$filteredQuery = clone $query;
$totalUlasan = (clone $filteredQuery)->count();
$totalWisata = (clone $filteredQuery)->distinct('wisata')->count('wisata');
$lastUpdate = (clone $filteredQuery)->latest()->value('created_at');
$mentah = $query->orderBy('tanggal', 'desc')->paginate(20)->withQueryString();
return view('ulasan.index', compact(
'mentah',
'totalUlasan',
'totalWisata',
'lastUpdate',
'periodeList',
'periodeId',
'periodeAktif',
'defaultPeriodeId'
))->with([
'scraperDestinations' => $this->scraperDestinations(),
]);
}
public function riwayat()
{
$riwayat = DB::table('periode_analisis as p')
@ -249,62 +234,65 @@ public function importRiwayat(Request $request)
}
public function ambilData(Request $request)
{
$this->allowLongRunningRequest();
{
$this->allowLongRunningRequest();
$validated = $request->validate([
'periode_bulan' => ['nullable', 'date_format:Y-m'],
'wisata' => ['nullable', 'string'],
'redirect_to' => ['nullable', 'in:dashboard,ulasan'],
]);
$validated = $request->validate([
'periode_bulan' => ['nullable', 'date_format:Y-m'],
'wisata' => ['nullable', 'string'],
'redirect_to' => ['nullable', 'in:dashboard,ulasan'],
]);
$periodeBulan = \Carbon\Carbon::createFromFormat(
'Y-m',
$validated['periode_bulan'] ?? now()->format('Y-m')
);
$startDate = $periodeBulan->copy()->startOfMonth()->toDateString();
$endDate = $periodeBulan->copy()->endOfMonth()->toDateString();
$periodeBulan = \Carbon\Carbon::createFromFormat(
'Y-m',
$validated['periode_bulan'] ?? now()->format('Y-m')
);
$startDate = $periodeBulan->copy()->startOfMonth()->toDateString();
$endDate = $periodeBulan->copy()->endOfMonth()->toDateString();
$scrapingArguments = [
'--start-date', $startDate,
'--end-date', $endDate,
];
$scrapingArguments = [
'--start-date', $startDate,
'--end-date', $endDate,
];
if (!empty($validated['wisata'])) {
$scrapingArguments[] = '--wisata';
$scrapingArguments[] = $validated['wisata'];
}
$scraping = $this->runPythonScript(base_path('scraper/scraping_pipeline.py'), 1800, $scrapingArguments);
if (!$scraping['success']) {
return redirect()->route('ulasan.index')
->with('error', 'Scraping gagal: ' . $scraping['output']);
}
$lokasiLabel = empty($validated['wisata']) ? 'semua lokasi' : $validated['wisata'];
$redirectParameters = [
'periode_id' => $periode->id ?? null,
];
if (!empty($validated['wisata'])) {
$redirectParameters['wisata'] = $validated['wisata'];
}
if (($validated['redirect_to'] ?? 'dashboard') === 'ulasan') {
return redirect()->route('ulasan.index', array_filter($redirectParameters))
->with('success', 'Data berhasil diambil' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '.');
}
if (!empty($validated['wisata'])) {
$redirectParameters['tab'] = 'analisis';
}
return redirect()->route('ulasan.index', array_filter($redirectParameters))
->with('success', 'Scraping berhasil dilakukan untuk ' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '. Silakan jalankan proses analisis.');
if (!empty($validated['wisata'])) {
$scrapingArguments[] = '--wisata';
$scrapingArguments[] = $validated['wisata'];
}
$scraping = $this->runPythonScript(base_path('scraper/scraping_pipeline.py'), 1800, $scrapingArguments);
if (!$scraping['success']) {
return redirect()->route('ulasan.index')
->with('error', 'Scraping gagal: ' . $scraping['output']);
}
// ✅ FIX: Ambil periode_id dari DB setelah scraping selesai
$periode = DB::table('periode_analisis')
->where('bulan', $periodeBulan->month)
->where('tahun', $periodeBulan->year)
->first();
$lokasiLabel = empty($validated['wisata']) ? 'semua lokasi' : $validated['wisata'];
$redirectParameters = [
'periode_id' => $periode->id ?? null,
];
if (!empty($validated['wisata'])) {
$redirectParameters['wisata'] = $validated['wisata'];
}
if (($validated['redirect_to'] ?? 'ulasan') === 'ulasan') {
return redirect()->route('ulasan.index', array_filter($redirectParameters))
->with('success', 'Data berhasil diambil untuk ' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '.');
}
$redirectParameters['tab'] = 'analisis';
return redirect()->route('dashboard', array_filter($redirectParameters))
->with('success', 'Scraping berhasil untuk ' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '. Silakan jalankan proses analisis.');
}
public function kosongkanPeriode(Request $request)
{
$periodeId = $request->validate([
@ -399,6 +387,18 @@ private function runPythonScript(string $scriptPath, int $timeout, array $argume
'TMP' => getenv('TMP') ?: 'C:\\Users\\Mufrida Farah\\AppData\\Local\\Temp',
]);
// $envVars = array_merge($_SERVER, $_ENV, array_filter([
// 'PATH' => getenv('PATH') ?: '/usr/bin:/usr/local/bin:/bin',
// 'HOME' => getenv('HOME') ?: (getenv('USERPROFILE') ?: null),
// 'TEMP' => getenv('TEMP') ?: (getenv('TMP') ?: sys_get_temp_dir()),
// 'TMP' => getenv('TMP') ?: (getenv('TEMP') ?: sys_get_temp_dir()),
// // Windows only — diisi kalau ada, di Linux otomatis null (dibuang array_filter)
// 'SystemRoot' => getenv('SystemRoot') ?: null,
// 'windir' => getenv('windir') ?: null,
// 'USERPROFILE' => getenv('USERPROFILE') ?: null,
// 'LOCALAPPDATA' => getenv('LOCALAPPDATA') ?: null,
// ]));
// 3. Masukkan $envVars ke parameter ketiga Process
$process = new Process(array_merge([$pythonPath, $scriptPath], $arguments), base_path(), $envVars);
$process->setTimeout($timeout);

View File

@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// ── STEP 1: Seragamkan tipe data ──
Schema::table('ulasan', function (Blueprint $table) {
$table->unsignedBigInteger('id')->change();
$table->unsignedBigInteger('periode_id')->nullable()->change();
});
Schema::table('hasil_analisis', function (Blueprint $table) {
$table->unsignedBigInteger('periode_id')->nullable()->change();
$table->unsignedBigInteger('ulasan_id')->nullable()->change();
});
Schema::table('evaluasi_model', function (Blueprint $table) {
$table->unsignedBigInteger('periode_id')->nullable()->change();
});
// ── STEP 2: Tambah foreign key ──
Schema::table('ulasan', function (Blueprint $table) {
$table->foreign('periode_id')
->references('id')
->on('periode_analisis')
->onDelete('cascade');
});
Schema::table('hasil_analisis', function (Blueprint $table) {
$table->foreign('periode_id')
->references('id')
->on('periode_analisis')
->onDelete('cascade');
$table->foreign('ulasan_id')
->references('id')
->on('ulasan')
->onDelete('set null');
});
Schema::table('evaluasi_model', function (Blueprint $table) {
$table->foreign('periode_id')
->references('id')
->on('periode_analisis')
->onDelete('cascade');
});
}
public function down(): void
{
Schema::table('ulasan', function (Blueprint $table) {
$table->dropForeign(['periode_id']);
});
Schema::table('hasil_analisis', function (Blueprint $table) {
$table->dropForeign(['periode_id']);
$table->dropForeign(['ulasan_id']);
});
Schema::table('evaluasi_model', function (Blueprint $table) {
$table->dropForeign(['periode_id']);
});
}
};

View File

@ -96,12 +96,7 @@ class="bg-blue-600 text-white px-4 rounded h-[46px]">
</form>
<a href="{{ route('riwayat.index') }}"
class="inline-flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white px-5 rounded-lg shadow transition h-[46px]">
<i class="fas fa-history mr-2"></i>
Lihat Riwayat
</a>
</div>

View File

@ -3,6 +3,8 @@
$totalDestinasiAnalisis ??= 0;
$totalDestinasiBerkeluhan ??= 0;
$totalNegatif ??= 0;
$totalPositif ??= 0;
$totalNetral ??= 0;
$totalUlasan ??= 0;
$persenNegatif ??= 0;
$tingkatKepuasan ??= 0;
@ -15,6 +17,37 @@
$prioritas ??= [];
@endphp
@php
// $persenPositif, $persenNetral, $isuNegatif, $isuNetral, $isuPositif sudah dikirim dari controller
// Icon map untuk prioritas cards
$iconMap = [
'harga' => '🎫',
'tiket' => '🎫',
'kebersihan'=> '🧹',
'parkir' => '🚗',
'fasilitas' => '🏗️',
'keramaian' => '👥',
'akses' => '🛣️',
'jalan' => '🛣️',
'default' => '⚙️',
];
@endphp
<style>
.gauge-ring {
transform: rotate(-90deg);
transform-origin: center;
}
.gauge-wrap { position: relative; display: inline-block; }
.gauge-inner {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -30%);
text-align: center;
line-height: 1.1;
}
</style>
<div class="space-y-6">
{{-- HEADER --}}
@ -22,7 +55,7 @@
<div>
<h2 class="text-2xl font-bold text-gray-800">Rekomendasi Layanan</h2>
<p class="text-sm text-gray-500">
Rekomendasi dibuat dari ulasan negatif agar saran perbaikan fokus pada keluhan pengunjung.
Rekomendasi dibuat dari hasil analisis sentimen untuk perbaikan layanan destinasi wisata.
</p>
<p class="text-xs text-gray-400 mt-1">
{{ $totalDestinasiAnalisis }} destinasi dianalisis, {{ $totalDestinasiBerkeluhan }} destinasi memiliki ulasan negatif.
@ -32,7 +65,10 @@
{{-- 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">
<input type="hidden" name="periode_id" value="{{ $periodeAktif->id ?? request('periode_id') }}">
<input type="hidden" name="periode_id" value="{{ $periodeId ?? $periodeAktif->id ?? request('periode_id') }}">
@if(request('periode_bulan'))
<input type="hidden" name="periode_bulan" value="{{ request('periode_bulan') }}">
@endif
<select name="destinasi" onchange="this.form.submit()"
class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none focus:ring-2 focus:ring-blue-400">
<option value="">Semua Destinasi</option>
@ -45,174 +81,238 @@ class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none
</form>
</div>
{{-- SUMMARY CARDS --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{{-- SUMMARY CARDS 3 gauge cards seperti di screenshot --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<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>
{{-- Tingkat Positif --}}
<div class="bg-white rounded-xl shadow p-5 flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Destinasi Dianalisis</p>
<h3 class="text-xl font-bold">{{ $totalDestinasiAnalisis }}</h3>
<p class="text-xs text-gray-500">{{ $totalDestinasiBerkeluhan }} punya keluhan</p>
<p class="text-sm font-semibold text-green-600">Tingkat Positif</p>
<h3 class="text-4xl font-bold text-green-600 mt-1">{{ $persenPositif }}%</h3>
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 font-medium">Positif</span>
<p class="text-xs text-gray-400 mt-2">{{ $totalPositif }} ulasan</p>
</div>
<div class="gauge-wrap">
<svg width="90" height="70" viewBox="0 0 90 70">
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#22c55e" stroke-width="8" stroke-linecap="round"
stroke-dasharray="{{ round($persenPositif * 1.099) }} 110"
style="transition: stroke-dasharray 0.8s ease"/>
</svg>
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😊</div>
</div>
</div>
<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>
{{-- Tingkat Negatif --}}
<div class="bg-white rounded-xl shadow p-5 flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Total Ulasan Negatif</p>
<h3 class="text-xl font-bold">{{ $totalNegatif }}</h3>
<p class="text-xs text-red-500">{{ $persenNegatif }}% dari total</p>
<p class="text-sm font-semibold text-red-500">Tingkat Negatif</p>
<h3 class="text-4xl font-bold text-red-500 mt-1">{{ $persenNegatif }}%</h3>
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-red-100 text-red-600 font-medium">Negatif</span>
<p class="text-xs text-gray-400 mt-2">{{ $totalNegatif }} ulasan</p>
</div>
<div class="gauge-wrap">
<svg width="90" height="70" viewBox="0 0 90 70">
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#fee2e2" stroke-width="8" stroke-linecap="round"/>
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#ef4444" stroke-width="8" stroke-linecap="round"
stroke-dasharray="{{ round($persenNegatif * 1.099) }} 110"
style="transition: stroke-dasharray 0.8s ease"/>
</svg>
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😟</div>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
<div class="bg-green-100 text-green-600 p-3 rounded-full text-xl">😊</div>
{{-- Tingkat Netral --}}
<div class="bg-white rounded-xl shadow p-5 flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Tingkat Kepuasan</p>
<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>
<p class="text-sm font-semibold text-gray-500">Tingkat Netral</p>
<h3 class="text-4xl font-bold text-gray-600 mt-1">{{ $persenNetral }}%</h3>
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-600 font-medium">Netral</span>
<p class="text-xs text-gray-400 mt-2">{{ $totalNetral }} ulasan</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
<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">{{ $isuDominan }}</h3>
<p class="text-xs text-gray-500">{{ $isuDominanPersen }}% dari total isu</p>
<div class="gauge-wrap">
<svg width="90" height="70" viewBox="0 0 90 70">
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#9ca3af" stroke-width="8" stroke-linecap="round"
stroke-dasharray="{{ round($persenNetral * 1.099) }} 110"
style="transition: stroke-dasharray 0.8s ease"/>
</svg>
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😐</div>
</div>
</div>
</div>
{{-- GRID ANALISIS --}}
{{-- TABEL ISU 3 KOLOM (Negatif / Netral / Positif) --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- ISU UTAMA --}}
{{-- ISU NEGATIF UTAMA --}}
<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-red-600">Isu Negatif Utama (Top 5)</h3>
@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
@forelse ($isuNegatif as $i => $isu)
@if($i >= 5) @break @endif
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span>{{ $i + 1 }}. {{ $isu['nama'] }}</span>
<span class="font-semibold">{{ $isu['persen'] }}%</span>
<span class="text-gray-700">{{ $i + 1 }}. {{ $isu['nama'] }}</span>
<span class="font-semibold text-gray-800">
{{ $isu['persen'] }}%
@if(isset($isu['jumlah'])) ({{ $isu['jumlah'] }}) @endif
</span>
</div>
<div class="w-full bg-red-100 h-2 rounded">
<div class="h-2 rounded bg-red-500" style="width: {{ $isu['persen'] }}%"></div>
</div>
</div>
@empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu negatif.</p>
@endforelse
<p class="text-xs text-red-400 mt-2">
Persentase berdasarkan dari total ulasan negatif ({{ $totalNegatif }} ulasan)
</p>
</div>
{{-- ISU NETRAL UTAMA --}}
<div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-600">Isu Netral Utama (Top 5)</h3>
@forelse ($isuNetral as $i => $isu)
@if($i >= 5) @break @endif
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-700">{{ $i + 1 }}. {{ $isu['nama'] }}</span>
<span class="font-semibold text-gray-800">
{{ $isu['persen'] }}%
@if(isset($isu['jumlah'])) ({{ $isu['jumlah'] }}) @endif
</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded">
<div class="h-2 rounded {{ $warnaClass }}" style="width: {{ $isu['persen'] }}%"></div>
<div class="h-2 rounded bg-gray-500" style="width: {{ $isu['persen'] }}%"></div>
</div>
</div>
@empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu.</p>
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu netral.</p>
@endforelse
<p class="text-xs text-gray-400 mt-2">
Persentase berdasarkan dari total ulasan netral ({{ $totalNetral }} ulasan)
</p>
</div>
{{-- KATA KUNCI DOMINAN --}}
{{-- ASPEK POSITIF UTAMA --}}
<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-green-600">Aspek Positif Utama (Top 5)</h3>
@php $maxFreq = !empty($kataDominan) ? max($kataDominan) : 1; @endphp
@forelse (array_slice($kataDominan, 0, 5, true) as $kata => $jumlah)
<div class="mb-3">
<div class="flex justify-between text-sm">
<span>{{ $kata }}</span>
<span class="font-semibold text-blue-600">{{ $jumlah }}x</span>
@forelse ($isuPositif as $i => $isu)
@if($i >= 5) @break @endif
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-700">{{ $i + 1 }}. {{ $isu['nama'] }}</span>
<span class="font-semibold text-gray-800">
{{ $isu['persen'] }}%
@if(isset($isu['jumlah'])) ({{ $isu['jumlah'] }}) @endif
</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded mt-1">
<div class="bg-blue-500 h-2 rounded" style="width: {{ round(($jumlah / $maxFreq) * 100) }}%"></div>
<div class="w-full bg-green-100 h-2 rounded">
<div class="h-2 rounded bg-green-500" style="width: {{ $isu['persen'] }}%"></div>
</div>
</div>
@empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada kata kunci.</p>
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu positif.</p>
@endforelse
</div>
{{-- 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">
@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>
@empty
<p class="text-sm text-gray-400 text-center py-4">Belum ada saran.</p>
@endforelse
</div>
<p class="text-xs text-green-400 mt-2">
Persentase berdasarkan dari total ulasan positif ({{ $totalPositif }} ulasan)
</p>
</div>
</div>
{{-- PRIORITAS REKOMENDASI --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 class="text-lg font-bold text-gray-800 mb-1">Prioritas Rekomendasi Perbaikan Layanan</h3>
<p class="text-sm text-gray-500 mb-4">Prioritas disusun berdasarkan frekuensi isu negatif dan dampaknya terhadap kepuasan pengunjung.</p>
@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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<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>
@forelse ($prioritas as $p)
@php
$border = match($p['color']) {
'red' => 'border-red-200',
'orange' => 'border-orange-200',
'yellow' => 'border-yellow-200',
'green' => 'border-green-200',
default => 'border-blue-200',
};
$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-50 text-red-500 border border-red-200',
'orange' => 'bg-orange-50 text-orange-500 border border-orange-200',
'yellow' => 'bg-yellow-50 text-yellow-600 border border-yellow-200',
'green' => 'bg-green-50 text-green-700 border border-green-200',
default => 'bg-blue-50 text-blue-600 border border-blue-200',
};
$badgeBg = match($p['color']) {
'red' => 'bg-red-500',
'orange' => 'bg-orange-400',
'yellow' => 'bg-yellow-400',
'green' => 'bg-green-500',
default => 'bg-blue-500',
};
$namaLower = strtolower($p['nama']);
$icon = '⚙️';
foreach ($iconMap as $key => $val) {
if (str_contains($namaLower, $key)) { $icon = $val; break; }
}
@endphp
<div class="border {{ $border }} rounded-xl p-5 bg-white">
<div class="flex items-start gap-4 mb-3">
<div class="{{ $badgeBg }} text-white rounded-full w-8 h-8 flex items-center justify-center font-bold text-sm flex-shrink-0">
{{ $p['rank'] }}
</div>
<div>
<p class="text-xs font-semibold {{ $title }}">
Prioritas {{ $p['rank'] === 1 ? 'Utama' : ($p['rank'] === 2 ? 'Kedua' : 'Ketiga') }}
</p>
<h3 class="text-base font-bold text-gray-800">{{ $p['nama'] }}</h3>
</div>
<div class="ml-auto text-3xl">{{ $icon }}</div>
</div>
<div class="text-xs {{ $dampak }} p-2 rounded">
Dampak: {{ $p['dampak'] }}
<ul class="text-sm space-y-2 mb-4">
@foreach ($p['actions'] as $action)
<li class="flex items-start gap-2 text-gray-700">
<span class="text-blue-500 mt-0.5"></span> {{ $action }}
</li>
@endforeach
</ul>
<div class="text-xs {{ $dampak }} p-2 rounded-lg">
Alasan: {{ $p['dampak'] }}
</div>
</div>
</div>
@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>
@endforelse
@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>
@endforelse
</div>
</div>
{{-- FOOTER NOTE --}}
<div class="bg-blue-50 border border-blue-100 rounded-xl px-5 py-3 flex items-start gap-3 text-sm text-gray-600">
<span class="text-blue-500 mt-0.5"></span>
<span>Rekomendasi dihasilkan secara otomatis berdasarkan hasil analisis sentimen dan kata kunci dominan dari ulasan wisatawan.</span>
</div>
</div>

View File

@ -1,154 +1,158 @@
# -*- coding: utf-8 -*-
"""
analisis.py SENTARA
=====================
Sistem Analisis Sentimen Ulasan Wisatawan
Metode : Naive Bayes (ComplementNB) + TF-IDF
Label : Dari teks ulasan (bukan rating)
"""
import json
import argparse
import os
import re
import sqlite3
import sys
import warnings
import sqlite3
from pathlib import Path
from urllib.parse import quote_plus
import joblib
import numpy as np
import pandas as pd
import pymysql
from sqlalchemy import create_engine
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import (
accuracy_score,
classification_report,
f1_score,
)
from sklearn.model_selection import (
GridSearchCV,
StratifiedKFold,
cross_val_score,
train_test_split,
)
from sklearn.naive_bayes import ComplementNB
from sklearn.pipeline import Pipeline
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import ComplementNB
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
warnings.filterwarnings("ignore")
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
# ═══════════════════════════════════════════════════════════════
# KONSTANTA & PATH
# ═══════════════════════════════════════════════════════════════
SEED = 42
BASE_DIR = Path(__file__).resolve().parents[1]
ARTEFAK = BASE_DIR / "artefak"
ARTEFAK.mkdir(exist_ok=True)
MODEL_PATH = ARTEFAK / "sentimen_naive_bayes.pkl"
METADATA_PATH = ARTEFAK / "model_metadata.json"
# ═══════════════════════════════════════════════════════════════
# LOGGER
# ═══════════════════════════════════════════════════════════════
def log(level, message):
print(f"[{level}] {message}", flush=True)
def fail(message, code=1):
log("ERROR", message)
sys.exit(code)
# ═══════════════════════════════════════════════════════════════
# KAMUS NORMALISASI LENGKAP (dari dataset.ipynb)
# ═══════════════════════════════════════════════════════════════
NORMALISASI = {
# Negasi — JANGAN hapus
r"\b(ga|gak|gk|nggak|ngga|ngak|engga|enggak)\b": "tidak",
r"\b(blm|blom|blum)\b": "belum",
r"\b(bkn|bukn)\b": "bukan",
r"\b(jgn|jangan|jgan)\b": "jangan",
# Kata ganti
r"\b(ak|aq|gw|gue|gua)\b": "aku",
r"\b(km|loe|lu|elo|lo)\b": "kamu",
r"\b(sy|sya)\b": "saya",
# Kata kerja
r"\b(bs|bsa)\b": "bisa",
r"\b(liat|lht)\b": "lihat",
r"\b(mkn|mkan|maem)\b": "makan",
r"\b(pake|pk|pke)\b": "pakai",
r"\b(tau|taw|tw)\b": "tahu",
r"\b(dtg)\b": "datang",
r"\b(lgsg|lgsung)\b": "langsung",
# Kata sifat
r"\b(bgs|bgus|nice|good|top)\b": "bagus",
r"\b(josss|joss|sipp|sip)\b": "mantap",
r"\b(byk|bnyk)\b": "banyak",
r"\b(lbh|lbih)\b": "lebih",
r"\b(bener|bnr)\b": "benar",
# Kata sambung & keterangan
r"\b(aja|sja|ae)\b": "saja",
r"\b(bgt|bangettt|bangett)\b": "banget",
r"\b(br|bru)\b": "baru",
r"\b(cmn|cuma|cuman)\b": "cuma",
r"\b(dgn|dngn|dg)\b": "dengan",
r"\b(dl|dlu)\b": "dulu",
r"\b(dlm|dalem)\b": "dalam",
r"\b(dr|dri)\b": "dari",
r"\b(emg|emang)\b": "memang",
r"\b(gt|gitu|bgitu)\b": "begitu",
r"\b(hbs|hbis)\b": "habis",
r"\b(hrs|hrus)\b": "harus",
r"\b(jd|jdi)\b": "jadi",
r"\b(jg|jga)\b": "juga",
r"\b(kdg|kdang)\b": "kadang",
r"\b(klo|kalo|kl)\b": "kalau",
r"\b(krn|karna)\b": "karena",
r"\b(kyk|kek|kya)\b": "seperti",
r"\b(lg|lgi)\b": "lagi",
r"\b(mgkn|mngkin)\b": "mungkin",
r"\b(msi|msh|msih)\b": "masih",
r"\b(pd|pda)\b": "pada",
r"\b(sdh|udh|udah|uda)\b": "sudah",
r"\b(skrg|skrng)\b": "sekarang",
r"\b(sllu|slalu)\b": "selalu",
r"\b(sm|ama)\b": "sama",
r"\b(smpai|ampe|smpe)\b": "sampai",
r"\b(smua)\b": "semua",
r"\b(srg|sring)\b": "sering",
r"\b(tp|tpi)\b": "tapi",
r"\b(trs|trus)\b": "lalu",
r"\b(ttp|ttep)\b": "tetap",
r"\b(utk|untk)\b": "untuk",
r"\b(yg|yng)\b": "yang",
r"\b(pdhl|pdhal)\b": "padahal",
r"\b(tmpt|tempt)\b": "tempat",
r"\b(tmn|temen)\b": "teman",
r"\b(org|orng)\b": "orang",
# Konteks pariwisata
r"\b(recommended|recomended)\b": "rekomendasi",
r"\b(healing)\b": "rekreasi",
r"\b(htm)\b": "harga tiket masuk",
r"\b(overall)\b": "secara keseluruhan",
r"\b(spot foto)\b": "lokasi foto",
# Hapus tawa & makian
r"\b(wkwk+|haha+|hehe+)\b": "",
r"\b(bjir|anjay|anjir)\b": "",
def parse_args():
parser = argparse.ArgumentParser(description="Analisis sentimen ulasan untuk satu periode.")
parser.add_argument("--periode-id", type=int, help="ID periode yang dianalisis. Default: periode terbaru.")
return parser.parse_args()
def read_laravel_env():
env = {}
env_file = BASE_DIR / ".env"
if not env_file.exists():
return env
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
value = value.strip().strip('"').strip("'")
env.setdefault(key.strip(), value)
return env
def env_value(env, key, default=""):
value = os.getenv(key, env.get(key, default))
if value in {None, "", "null", "None"}:
return default
return value
def db_config():
env = read_laravel_env()
connection = env_value(env, "DB_CONNECTION", "mysql")
if connection == "sqlite":
database = env_value(env, "DB_DATABASE", str(BASE_DIR / "database" / "database.sqlite"))
database_path = Path(database)
if not database_path.is_absolute():
database_path = BASE_DIR / database
return {
"connection": connection,
"database": str(database_path),
}
if connection not in {"mysql", "mariadb"}:
fail(f"DB_CONNECTION={connection} belum didukung oleh analisis.py. Gunakan sqlite/mysql/mariadb.")
return {
"connection": connection,
"host": env_value(env, "DB_HOST", "127.0.0.1"),
"port": int(env_value(env, "DB_PORT", "3306")),
"database": env_value(env, "DB_DATABASE", "sistem_analisis"),
"user": env_value(env, "DB_USERNAME", "root"),
"password": env_value(env, "DB_PASSWORD", ""),
}
def make_connections(config):
if config["connection"] == "sqlite":
engine = create_engine(f"sqlite:///{config['database']}")
conn = sqlite3.connect(config["database"])
conn.row_factory = sqlite3.Row
return engine, conn
engine_url = (
"mysql+pymysql://"
f"{quote_plus(config['user'])}:{quote_plus(config['password'])}"
f"@{config['host']}:{config['port']}/{config['database']}?charset=utf8mb4"
)
engine = create_engine(engine_url)
conn = pymysql.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
return engine, conn
def is_sqlite_connection(conn):
return isinstance(conn, sqlite3.Connection)
def prepare_sql(conn, sql):
if is_sqlite_connection(conn):
return sql.replace("%s", "?").replace("NOW()", "CURRENT_TIMESTAMP")
return sql
def execute(cursor, conn, sql, params=()):
cursor.execute(prepare_sql(conn, sql), params)
def table_columns(cursor, conn, table):
if is_sqlite_connection(conn):
cursor.execute(f"PRAGMA table_info({table})")
return {row["name"] for row in cursor.fetchall()}
cursor.execute(f"SHOW COLUMNS FROM {table}")
return {row["Field"] for row in cursor.fetchall()}
SLANG_MAP = {
"ga": "tidak",
"gak": "tidak",
"gk": "tidak",
"nggak": "tidak",
"ngga": "tidak",
"ngak": "tidak",
"bgt": "banget",
"yg": "yang",
"tp": "tapi",
}
# ═══════════════════════════════════════════════════════════════
# KAMUS SENTIMEN (pseudo-label fallback)
# ═══════════════════════════════════════════════════════════════
POSITIF_WORDS = {
"bagus","indah","cantik","keren","mantap","asri","bersih","nyaman","rapi",
"adem","sejuk","segar","menarik","spektakuler","eksotis","unik","istimewa",
@ -168,300 +172,168 @@ NEGATIF_WORDS = {
"licin","curam","sampah","tidak puas","kapok","ogah","zonk","tipu","pungli",
}
NEGASI = {"tidak","bukan","jangan","belum","tanpa","kurang","ga","gak","nggak"}
# ═══════════════════════════════════════════════════════════════
# INISIALISASI NLP
# ═══════════════════════════════════════════════════════════════
stemmer = StemmerFactory().create_stemmer()
stopwords = set(StopWordRemoverFactory().get_stop_words())
stopwords.discard("tidak")
stopwords.discard("bukan")
stopwords.discard("jangan")
log("INFO", "Memuat stemmer dan stopword Sastrawi...")
_stemmer = StemmerFactory().create_stemmer()
_sw_raw = set(StopWordRemoverFactory().get_stop_words())
# Pertahankan kata negasi — kritis untuk sentimen
STOPWORDS = _sw_raw - NEGASI
log("INFO", f"Stopword: {len(_sw_raw)} kata | Negasi dipertahankan: {NEGASI}")
# ═══════════════════════════════════════════════════════════════
# PREPROCESSING
# ═══════════════════════════════════════════════════════════════
def normalisasi_teks(text):
for pattern, replacement in NORMALISASI.items():
text = re.sub(pattern, replacement, text)
return text
def normalize_rating(value):
if pd.isna(value):
return None
match = re.search(r"([1-5])", str(value))
return int(match.group(1)) if match else None
#preprocessing dengan stemming dan stopword removal, serta normalisasi kata ga/gak/nggak menjadi tidak, dan bgt menjadi banget. Hanya kata yang lebih dari 2 karakter yang diproses untuk mengurangi noise.
def preprocess_text(text):
"""Pipeline preprocessing 6 tahap."""
text = str(text).lower()
text = re.sub(r"https?://\S+|www\.\S+", " ", text) # hapus URL
text = re.sub(r"@\w+|#\w+", " ", text) # hapus mention/hashtag
text = re.sub(r"[^a-z\s]", " ", text) # hapus non-alfabet
text = re.sub(r"https?://\S+|www\.\S+", " ", text)
text = re.sub(r"[^a-z\s]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
text = normalisasi_teks(text) # normalisasi kamus
tokens = [t for t in text.split() if t not in STOPWORDS and len(t) > 2]
return _stemmer.stem(" ".join(tokens)).strip()
# ═══════════════════════════════════════════════════════════════
# PSEUDO-LABELING (fallback jika tidak ada model tersimpan)
# ═══════════════════════════════════════════════════════════════
words = [SLANG_MAP.get(word, word) for word in text.split()]
words = [word for word in words if word not in stopwords and len(word) > 2]
return stemmer.stem(" ".join(words)).strip()
# def label_by_keyword(clean_text):
# words = set(clean_text.split())
# positive_score = len(words & POSITIF_WORDS)
# negative_score = len(words & NEGATIF_WORDS)
# if positive_score > negative_score:
# return "positif"
# if negative_score > positive_score:
# return "negatif"
# return "netral"
def label_by_keyword(clean_text):
tokens = clean_text.split()
pos = neg = 0
i = 0
while i < len(tokens):
sebelum_negasi = (i > 0 and tokens[i-1] in NEGASI)
w = tokens[i]
if w in POSITIF_WORDS:
neg += 1 if sebelum_negasi else 0
pos += 0 if sebelum_negasi else 1
elif w in NEGATIF_WORDS:
pos += 1 if sebelum_negasi else 0
neg += 0 if sebelum_negasi else 1
i += 1
words = clean_text.split()
positive_score = sum(1 for word in words if word in POSITIF_WORDS)
negative_score = sum(1 for word in words if word in NEGATIF_WORDS)
if positive_score > negative_score:
return "positif"
elif negative_score > positive_score:
return "negatif"
if pos > neg: return "positif"
if neg > pos: return "negatif"
return "netral"
# def make_pseudo_label(row):
# rating = normalize_rating(row.get("rating"))
# if rating is not None:
# if rating >= 4:
# return "positif"
# if rating == 3:
# return "netral"
# return "negatif"
# return label_by_keyword(row["ulasan_bersih"])
# berdasarkan ulasan
def make_pseudo_label(row):
return label_by_keyword(row["ulasan_bersih"])
# ═══════════════════════════════════════════════════════════════
# LOAD / TRAIN MODEL
# ═══════════════════════════════════════════════════════════════
def load_saved_model():
"""Muat model tersimpan jika ada."""
if MODEL_PATH.exists():
log("INFO", f"Model tersimpan ditemukan → dimuat dari {MODEL_PATH}")
return joblib.load(MODEL_PATH)
return None
def rating_confidence(value):
rating = normalize_rating(value)
if rating is None:
return None
if rating in {1, 5}:
return 1.0
if rating in {2, 4}:
return 0.85
return 0.7
def train_and_save_model(df):
"""
Latih ComplementNB dengan:
- 5-Fold Stratified Cross Validation
- GridSearchCV hyperparameter tuning
- Evaluasi Macro F1 + classification report
"""
log("INFO", "=" * 55)
log("INFO", "TRAINING MODEL NAIVE BAYES (ComplementNB)")
log("INFO", "=" * 55)
X = df["ulasan_bersih"].values
y = df["label"].values
def apply_rating_priority(row, model_classes=None):
sentiment_result = row["sentimen"]
probability = float(row["probabilitas"])
label_counts = pd.Series(y).value_counts()
log("INFO", "Distribusi label: " + str(label_counts.to_dict()))
# Jika probabilitas model sangat rendah (di bawah 0.5), baru gunakan rating
if probability < 0.5:
rating_label = make_pseudo_label(row)
return rating_label, 0.5
return sentiment_result, probability
# Minimal 2 kelas dan tiap kelas >= 5
if label_counts.size < 2 or label_counts.min() < 2:
log("WARNING", "Data tidak cukup untuk training — pakai pseudo-label langsung.")
return None, 0, 0
# rating_label = make_pseudo_label(row)
# rating = normalize_rating(row.get("rating"))
# if rating is None:
# return row["sentimen"], float(row["probabilitas"])
# Split 80/20
can_stratify = label_counts.min() >= 2
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=SEED,
stratify=y if can_stratify else None
)
log("INFO", f"Split — Train: {len(X_train)} | Test: {len(X_test)}")
if row["sentimen"] != rating_label:
log(
"INFO",
f"Override sentimen berdasarkan rating {rating}: model={row['sentimen']} -> final={rating_label}",
)
kfold = StratifiedKFold(n_splits=min(5, label_counts.min()), shuffle=True, random_state=SEED)
probability = rating_confidence(rating)
if model_classes is not None and rating_label in model_classes:
try:
class_index = list(model_classes).index(rating_label)
probability = max(float(row["probabilitas_by_class"][class_index]), probability)
except Exception:
pass
# ── 5-Fold Cross Validation ──────────────────────────────
log("INFO", "Menjalankan 5-Fold Cross Validation...")
pipe_cv = Pipeline([
("tfidf", TfidfVectorizer(ngram_range=(1,2), min_df=1, sublinear_tf=True)),
("clf", ComplementNB()),
])
cv_scores = cross_val_score(pipe_cv, X_train, y_train, cv=kfold, scoring="f1_macro", n_jobs=-1)
log("INFO", f"CV Macro F1 per fold : {[round(s,4) for s in cv_scores]}")
log("INFO", f"CV Macro F1 rata-rata: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}")
return rating_label, probability
# ── GridSearchCV Tuning ───────────────────────────────────
log("INFO", "GridSearchCV tuning hiperparameter...")
param_grid = {
"tfidf__ngram_range": [(1,1),(1,2)],
"tfidf__min_df": [1, 2, 3],
"tfidf__sublinear_tf": [True, False],
"clf__alpha": [0.1, 0.5, 1.0, 2.0],
"clf__norm": [True, False],
}
pipe_gs = Pipeline([
("tfidf", TfidfVectorizer()),
("clf", ComplementNB()),
])
gs = GridSearchCV(pipe_gs, param_grid, cv=kfold, scoring="f1_macro", n_jobs=-1, refit=True)
gs.fit(X_train, y_train)
best = gs.best_estimator_
log("INFO", f"Parameter terbaik : {gs.best_params_}")
log("INFO", f"Best CV Macro F1 : {gs.best_score_:.4f}")
# ── Evaluasi Final ────────────────────────────────────────
y_pred = best.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
macro_f1 = f1_score(y_test, y_pred, average="macro", zero_division=0)
report = classification_report(y_test, y_pred, zero_division=0)
log("INFO", f"Akurasi : {accuracy:.4f} ({accuracy*100:.2f}%)")
log("INFO", f"Macro F1-Score: {macro_f1:.4f}")
log("INFO", f"\nClassification Report:\n{report}")
# ── Simpan model & metadata ───────────────────────────────
joblib.dump(best, MODEL_PATH)
report_dict = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
metadata = {
"model": "ComplementNB (Naive Bayes)",
"best_params": gs.best_params_,
"cv_macro_f1": {"mean": round(cv_scores.mean(),4), "std": round(cv_scores.std(),4)},
"evaluasi_test": {
"accuracy": round(accuracy,4),
"macro_f1": round(macro_f1,4),
},
"distribusi_label": pd.Series(y).value_counts().to_dict(),
}
with open(METADATA_PATH, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=4, ensure_ascii=False)
log("INFO", f"Model disimpan → {MODEL_PATH}")
return best, accuracy, macro_f1
# ═══════════════════════════════════════════════════════════════
# DATABASE
# ═══════════════════════════════════════════════════════════════
def read_laravel_env():
env = {}
env_file = BASE_DIR / ".env"
if not env_file.exists():
return env
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
value = value.strip().strip('"').strip("'")
env.setdefault(key.strip(), value)
return env
def env_value(env, key, default=""):
value = os.getenv(key, env.get(key, default))
if value in {None, "", "null", "None"}:
return default
return value
def db_config():
env = read_laravel_env()
conn = env_value(env, "DB_CONNECTION", "mysql")
if conn == "sqlite":
db = env_value(env, "DB_DATABASE", str(BASE_DIR / "database" / "database.sqlite"))
db_path = Path(db)
if not db_path.is_absolute():
db_path = BASE_DIR / db
return {"connection": conn, "database": str(db_path)}
if conn not in {"mysql", "mariadb"}:
fail(f"DB_CONNECTION={conn} belum didukung.")
return {
"connection": conn,
"host": env_value(env, "DB_HOST", "127.0.0.1"),
"port": int(env_value(env, "DB_PORT", "3306")),
"database": env_value(env, "DB_DATABASE", "sistem_analisis"),
"user": env_value(env, "DB_USERNAME", "root"),
"password": env_value(env, "DB_PASSWORD", ""),
}
def make_connections(config):
if config["connection"] == "sqlite":
engine = create_engine(f"sqlite:///{config['database']}")
conn = sqlite3.connect(config["database"])
conn.row_factory = sqlite3.Row
return engine, conn
url = (
"mysql+pymysql://"
f"{quote_plus(config['user'])}:{quote_plus(config['password'])}"
f"@{config['host']}:{config['port']}/{config['database']}?charset=utf8mb4"
)
engine = create_engine(url)
conn = pymysql.connect(
host=config["host"], port=config["port"],
user=config["user"], password=config["password"],
database=config["database"], charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
return engine, conn
def is_sqlite(conn):
return isinstance(conn, sqlite3.Connection)
def prepare_sql(conn, sql):
if is_sqlite(conn):
return sql.replace("%s", "?").replace("NOW()", "CURRENT_TIMESTAMP")
return sql
def execute(cursor, conn, sql, params=()):
cursor.execute(prepare_sql(conn, sql), params)
def table_columns(cursor, conn, table):
if is_sqlite(conn):
cursor.execute(f"PRAGMA table_info({table})")
return {row["name"] for row in cursor.fetchall()}
cursor.execute(f"SHOW COLUMNS FROM {table}")
return {row["Field"] for row in cursor.fetchall()}
# ═══════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════
def main():
import argparse
parser = argparse.ArgumentParser(description="SENTARA — Analisis Sentimen")
parser.add_argument("--periode-id", type=int, default=None)
args = parser.parse_args()
args = parse_args()
config = db_config()
if config["connection"] == "sqlite":
log("INFO", f"Menggunakan database SQLite {config['database']}")
else:
log("INFO", f"Menggunakan database {config['database']} di {config['host']}:{config['port']}")
log("INFO", "=" * 55)
log("INFO", "SENTARA — Sistem Analisis Sentimen Wisata Jember")
log("INFO", "=" * 55)
config = db_config()
engine, raw_conn = make_connections(config)
cursor = raw_conn.cursor()
cursor = raw_conn.cursor()
try:
# ── Cari periode ──────────────────────────────────────
if args.periode_id:
execute(cursor, raw_conn,
"SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1",
(args.periode_id,))
execute(cursor, raw_conn, "SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1", (args.periode_id,))
else:
execute(cursor, raw_conn, """
SELECT p.id, p.nama FROM periode_analisis p
WHERE EXISTS (SELECT 1 FROM ulasan u WHERE u.periode_id = p.id)
ORDER BY p.id DESC LIMIT 1
SELECT p.id, p.nama
FROM periode_analisis p
WHERE EXISTS (
SELECT 1 FROM ulasan u WHERE u.periode_id = p.id
)
ORDER BY p.id DESC
LIMIT 1
""")
periode = cursor.fetchone()
if not periode:
fail("Belum ada periode dengan ulasan.")
fail("Belum ada periode yang memiliki ulasan. Jalankan Ambil Data terlebih dahulu.")
periode_id = periode["id"]
periode_id = periode["id"]
periode_nama = periode["nama"]
log("INFO", f"Periode: {periode_nama} (id={periode_id})")
log("INFO", f"Analisis periode terbaru: {periode_nama} (periode_id={periode_id})")
# ── Ambil ulasan baru (belum dianalisis) ──────────────
df = pd.read_sql(
prepare_sql(raw_conn,
prepare_sql(
raw_conn,
"SELECT u.id, u.wisata, u.reviewer, u.rating, u.ulasan, u.tanggal, u.periode_id "
"FROM ulasan u WHERE u.periode_id = %s "
"AND NOT EXISTS (SELECT 1 FROM hasil_analisis h WHERE h.ulasan_id = u.id)"
#hanya data baru saja yang dianalisis
"FROM ulasan u WHERE u.periode_id = %s AND NOT EXISTS ( SELECT 1 FROM hasil_analisis h WHERE h.ulasan_id = u.id )"
),
engine, params=(periode_id,),
engine,
params=(periode_id,),
)
if df.empty:
fail(f"Tidak ada ulasan baru untuk periode_id={periode_id}.")
fail(f"Tidak ada data ulasan untuk periode_id={periode_id}.")
# ── Validasi & bersihkan ──────────────────────────────
df = df.dropna(subset=["ulasan"]).copy()
df["ulasan"] = df["ulasan"].astype(str)
df = df[df["ulasan"].str.strip().ne("")]
@ -470,82 +342,105 @@ def main():
df = df[df["ulasan"].str.len() > 5]
if df.empty:
fail("Data kosong setelah validasi.")
fail("Data ulasan kosong setelah validasi teks.")
# ── Preprocessing ─────────────────────────────────────
log("INFO", "Preprocessing teks (6 tahap)...")
df["ulasan_bersih"] = df["ulasan"].apply(preprocess_text)
df = df[df["ulasan_bersih"].str.strip().ne("")].copy()
log("INFO", f"Data valid setelah preprocessing: {len(df)} baris")
# ── Pseudo-label ──────────────────────────────────────
if df.empty:
fail("Data kosong setelah preprocessing. Tidak ada teks yang bisa dianalisis.")
df["label"] = df.apply(make_pseudo_label, axis=1)
label_counts = df["label"].value_counts()
log("INFO", "Distribusi pseudo-label: " + str(label_counts.to_dict()))
log("INFO", "Distribusi pseudo-label: " + ", ".join(f"{k}={v}" for k, v in label_counts.items()))
# ── Load atau Train model ─────────────────────────────
saved_pipeline = load_saved_model()
use_model = True
if label_counts.size < 2:
use_model = False
log("WARNING", "Jumlah kelas kurang dari 2. Prediksi memakai pseudo-label langsung tanpa training model.")
if saved_pipeline is not None:
# Pakai model tersimpan (dari training manual sebelumnya)
log("INFO", "Menggunakan model tersimpan untuk prediksi.")
best_pipeline = saved_pipeline
# Baca metadata akurasi
accuracy = macro_f1 = 0.0
if METADATA_PATH.exists():
with open(METADATA_PATH, encoding="utf-8") as f:
meta = json.load(f)
accuracy = meta.get("evaluasi_test", {}).get("accuracy", 0)
macro_f1 = meta.get("evaluasi_test", {}).get("macro_f1", 0)
log("INFO", f"Akurasi model tersimpan : {accuracy:.4f}")
log("INFO", f"Macro F1 model tersimpan: {macro_f1:.4f}")
report_dict = {"weighted avg": {"precision": accuracy, "recall": accuracy, "f1-score": macro_f1}}
else:
# Tidak ada model → train dari pseudo-label
log("WARNING",
"Model belum tersimpan. Training dari pseudo-label.\n"
"Untuk akurasi lebih baik, latih dengan dataset berlabel manual:\n"
"python analisis.py --mode train --dataset data_berlabel.csv")
best_pipeline, accuracy, macro_f1 = train_and_save_model(df)
can_stratify = label_counts.min() >= 2
if not can_stratify:
log("WARNING", "Ada kelas dengan jumlah data kurang dari 2. Split evaluasi dibuat tanpa stratify.")
if best_pipeline is None:
# Fallback: langsung pakai pseudo-label
df["sentimen"] = df["label"]
df["probabilitas"] = 0.60
accuracy = macro_f1 = 0.0
report_dict = {"weighted avg": {"precision": 0, "recall": 0, "f1-score": 0}}
report = {"weighted avg": {"precision": 0, "recall": 0, "f1-score": 0}}
accuracy = 0
if use_model:
X = df["ulasan_bersih"]
y = df["label"]
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 if can_stratify else None,
)
else:
report_dict = {"weighted avg": {"precision": accuracy, "recall": accuracy, "f1-score": macro_f1}}
log("WARNING", "Data kurang dari 5 baris. Evaluasi memakai data latih yang sama.")
X_train, X_test, y_train, y_test = X, X, y, y
# ── Prediksi ──────────────────────────────────────────
if best_pipeline is not None:
log("INFO", "Memprediksi sentimen...")
df["sentimen"] = best_pipeline.predict(df["ulasan_bersih"])
prob_matrix = best_pipeline.predict_proba(df["ulasan_bersih"])
df["probabilitas"] = prob_matrix.max(axis=1).clip(max=0.99)
vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_train_vec = vectorizer.fit_transform(X_train)
X_test_vec = vectorizer.transform(X_test)
# Post-processing: probabilitas rendah → fallback kamus
mask_low = df["probabilitas"] < 0.45
if mask_low.sum() > 0:
log("INFO", f"{mask_low.sum()} ulasan probabilitas rendah → fallback ke kamus")
df.loc[mask_low, "sentimen"] = df.loc[mask_low, "ulasan_bersih"].apply(label_by_keyword)
df.loc[mask_low, "probabilitas"] = 0.50
dist = df["sentimen"].value_counts().to_dict()
log("INFO", f"Distribusi sentimen hasil: {dist}")
#ComplementNB lebih cocok untuk data yang tidak seimbang, dan sering memberikan hasil lebih baik pada teks dibanding MultinomialNB
model = ComplementNB()
model.fit(X_train_vec, y_train)
model_dir = BASE_DIR / "storage" / "models"
model_dir.mkdir(parents=True, exist_ok=True)
joblib.dump(model, model_dir / f"model_periode_{periode_id}.pkl")
joblib.dump(vectorizer, model_dir / f"vectorizer_periode_{periode_id}.pkl")
log("INFO", f"Model disimpan: model_periode_{periode_id}.pkl")
y_pred = model.predict(X_test_vec)
report = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
accuracy = accuracy_score(y_test, y_pred)
X_all_vec = vectorizer.transform(df["ulasan_bersih"])
df["sentimen"] = model.predict(X_all_vec)
probability_matrix = model.predict_proba(X_all_vec) #probabilitasnya
df["probabilitas"] = probability_matrix.max(axis=1)
df["probabilitas"] = df["probabilitas"].clip(upper=0.99)
df["probabilitas_by_class"] = list(probability_matrix)
final_results = df.apply(lambda row: apply_rating_priority(row, model.classes_), axis=1)
df["sentimen"] = [result[0] for result in final_results]
df["probabilitas"] = [result[1] for result in final_results]
df = df.drop(columns=["probabilitas_by_class"])
else:
df["sentimen"] = df["label"]
df["probabilitas"] = df["rating"].apply(lambda rating: rating_confidence(rating) or 0.7)
log("INFO", "Evaluasi memakai pseudo-label dari rating/rule otomatis, bukan label manual.")
# execute(cursor, raw_conn, "DELETE FROM hasil_analisis WHERE periode_id = %s", (periode_id,))
# execute(cursor, raw_conn, "DELETE FROM evaluasi_model WHERE periode_id = %s", (periode_id,))
# ── Simpan ke hasil_analisis ──────────────────────────
hasil_columns = table_columns(cursor, raw_conn, "hasil_analisis")
insert_columns = [
"ulasan_id","wisata","ulasan_asli","ulasan_bersih",
"hasil_preprocessing","sentimen","probabilitas",
"periode_id","created_at","updated_at",
"ulasan_id",
"wisata",
"ulasan_asli",
"ulasan_bersih",
"hasil_preprocessing",
"sentimen",
"probabilitas",
"periode_id",
"created_at",
"updated_at",
]
if "ulasan_terolah" in hasil_columns:
insert_columns.insert(3, "ulasan_terolah")
placeholders = ", ".join(["%s"] * (len(insert_columns) - 2) + ["NOW()", "NOW()"])
insert_sql = f"INSERT INTO hasil_analisis ({', '.join(insert_columns)}) VALUES ({placeholders})"
insert_hasil = f"""
INSERT INTO hasil_analisis ({", ".join(insert_columns)})
VALUES ({placeholders})
"""
for _, row in df.fillna("").iterrows():
values = [
@ -560,24 +455,33 @@ def main():
]
if "ulasan_terolah" in hasil_columns:
values.insert(3, str(row["ulasan_bersih"]))
execute(cursor, raw_conn, insert_sql, tuple(values))
# ── Simpan ke evaluasi_model ──────────────────────────
weighted = report_dict.get("weighted avg", {})
execute(cursor, raw_conn, """
execute(cursor, raw_conn, insert_hasil, tuple(values))
weighted = report.get("weighted avg", {})
execute(
cursor,
raw_conn,
"""
INSERT INTO evaluasi_model
(`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
""", (
float(weighted.get("precision", 0)),
float(weighted.get("recall", 0)),
float(weighted.get("f1-score", macro_f1)),
float(accuracy),
0, 0, 0, 0, periode_id,
))
""",
(
float(weighted.get("precision", 0)),
float(weighted.get("recall", 0)),
float(weighted.get("f1-score", 0)),
float(accuracy),
0,
0,
0,
0,
periode_id,
),
)
raw_conn.commit()
log("OK", f"{len(df)} hasil analisis disimpan untuk periode '{periode_nama}'.")
log("OK", f"{len(df)} hasil analisis disimpan untuk periode {periode_nama}.")
log("OK", "Analisis selesai.")
except SystemExit:

View File

@ -1,485 +0,0 @@
# -*- coding: utf-8 -*-
import argparse
import os
import re
import sys
import sqlite3
from pathlib import Path
from urllib.parse import quote_plus
import pandas as pd
import pymysql
from sqlalchemy import create_engine
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import ComplementNB
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
BASE_DIR = Path(__file__).resolve().parents[1]
def log(level, message):
print(f"[{level}] {message}", flush=True)
def fail(message, code=1):
log("ERROR", message)
sys.exit(code)
def parse_args():
parser = argparse.ArgumentParser(description="Analisis sentimen ulasan untuk satu periode.")
parser.add_argument("--periode-id", type=int, help="ID periode yang dianalisis. Default: periode terbaru.")
return parser.parse_args()
def read_laravel_env():
env = {}
env_file = BASE_DIR / ".env"
if not env_file.exists():
return env
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
value = value.strip().strip('"').strip("'")
env.setdefault(key.strip(), value)
return env
def env_value(env, key, default=""):
value = os.getenv(key, env.get(key, default))
if value in {None, "", "null", "None"}:
return default
return value
def db_config():
env = read_laravel_env()
connection = env_value(env, "DB_CONNECTION", "mysql")
if connection == "sqlite":
database = env_value(env, "DB_DATABASE", str(BASE_DIR / "database" / "database.sqlite"))
database_path = Path(database)
if not database_path.is_absolute():
database_path = BASE_DIR / database
return {
"connection": connection,
"database": str(database_path),
}
if connection not in {"mysql", "mariadb"}:
fail(f"DB_CONNECTION={connection} belum didukung oleh analisis.py. Gunakan sqlite/mysql/mariadb.")
return {
"connection": connection,
"host": env_value(env, "DB_HOST", "127.0.0.1"),
"port": int(env_value(env, "DB_PORT", "3306")),
"database": env_value(env, "DB_DATABASE", "sistem_analisis"),
"user": env_value(env, "DB_USERNAME", "root"),
"password": env_value(env, "DB_PASSWORD", ""),
}
def make_connections(config):
if config["connection"] == "sqlite":
engine = create_engine(f"sqlite:///{config['database']}")
conn = sqlite3.connect(config["database"])
conn.row_factory = sqlite3.Row
return engine, conn
engine_url = (
"mysql+pymysql://"
f"{quote_plus(config['user'])}:{quote_plus(config['password'])}"
f"@{config['host']}:{config['port']}/{config['database']}?charset=utf8mb4"
)
engine = create_engine(engine_url)
conn = pymysql.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
return engine, conn
def is_sqlite_connection(conn):
return isinstance(conn, sqlite3.Connection)
def prepare_sql(conn, sql):
if is_sqlite_connection(conn):
return sql.replace("%s", "?").replace("NOW()", "CURRENT_TIMESTAMP")
return sql
def execute(cursor, conn, sql, params=()):
cursor.execute(prepare_sql(conn, sql), params)
def table_columns(cursor, conn, table):
if is_sqlite_connection(conn):
cursor.execute(f"PRAGMA table_info({table})")
return {row["name"] for row in cursor.fetchall()}
cursor.execute(f"SHOW COLUMNS FROM {table}")
return {row["Field"] for row in cursor.fetchall()}
SLANG_MAP = {
"ga": "tidak",
"gak": "tidak",
"gk": "tidak",
"nggak": "tidak",
"ngga": "tidak",
"ngak": "tidak",
"bgt": "banget",
"yg": "yang",
"tp": "tapi",
}
POSITIF_WORDS = {
"bagus", "indah", "mantap", "keren", "cantik", "menarik", "nyaman",
"bersih", "recommended", "rekomendasi", "suka", "senang", "puas",
"murah", "asyik", "ramah", "worth", "spektakuler", "memukau",
"sejuk", "kece", "amazing", "beautiful", "good", "nice", "best",
"great", "perfect", "recommend", "memuaskan", "menyenangkan", "view",
"seru", "enak", "adem",
}
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",
}
stemmer = StemmerFactory().create_stemmer()
stopwords = set(StopWordRemoverFactory().get_stop_words())
stopwords.discard("tidak")
stopwords.discard("bukan")
stopwords.discard("jangan")
def normalize_rating(value):
if pd.isna(value):
return None
match = re.search(r"([1-5])", str(value))
return int(match.group(1)) if match else None
def preprocess_text(text):
text = str(text).lower()
text = re.sub(r"https?://\S+|www\.\S+", " ", text)
text = re.sub(r"[^a-z\s]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
words = [SLANG_MAP.get(word, word) for word in text.split()]
words = [word for word in words if word not in stopwords and len(word) > 2]
return stemmer.stem(" ".join(words)).strip()
# def label_by_keyword(clean_text):
# words = set(clean_text.split())
# positive_score = len(words & POSITIF_WORDS)
# negative_score = len(words & NEGATIF_WORDS)
# if positive_score > negative_score:
# return "positif"
# if negative_score > positive_score:
# return "negatif"
# return "netral"
def label_by_keyword(clean_text):
words = clean_text.split()
positive_score = sum(1 for word in words if word in POSITIF_WORDS)
negative_score = sum(1 for word in words if word in NEGATIF_WORDS)
if positive_score > negative_score:
return "positif"
elif negative_score > positive_score:
return "negatif"
return "netral"
# def make_pseudo_label(row):
# rating = normalize_rating(row.get("rating"))
# if rating is not None:
# if rating >= 4:
# return "positif"
# if rating == 3:
# return "netral"
# return "negatif"
# return label_by_keyword(row["ulasan_bersih"])
def make_pseudo_label(row):
return label_by_keyword(row["ulasan_bersih"])
def rating_confidence(value):
rating = normalize_rating(value)
if rating is None:
return None
if rating in {1, 5}:
return 1.0
if rating in {2, 4}:
return 0.85
return 0.7
def apply_rating_priority(row, model_classes=None):
sentiment_result = row["sentimen"]
probability = float(row["probabilitas"])
# Jika probabilitas model sangat rendah (di bawah 0.5), baru gunakan rating
if probability < 0.5:
rating_label = make_pseudo_label(row)
return rating_label, 0.5
return sentiment_result, probability
# rating_label = make_pseudo_label(row)
# rating = normalize_rating(row.get("rating"))
# if rating is None:
# return row["sentimen"], float(row["probabilitas"])
if row["sentimen"] != rating_label:
log(
"INFO",
f"Override sentimen berdasarkan rating {rating}: model={row['sentimen']} -> final={rating_label}",
)
probability = rating_confidence(rating)
if model_classes is not None and rating_label in model_classes:
try:
class_index = list(model_classes).index(rating_label)
probability = max(float(row["probabilitas_by_class"][class_index]), probability)
except Exception:
pass
return rating_label, probability
def main():
args = parse_args()
config = db_config()
if config["connection"] == "sqlite":
log("INFO", f"Menggunakan database SQLite {config['database']}")
else:
log("INFO", f"Menggunakan database {config['database']} di {config['host']}:{config['port']}")
engine, raw_conn = make_connections(config)
cursor = raw_conn.cursor()
try:
if args.periode_id:
execute(cursor, raw_conn, "SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1", (args.periode_id,))
else:
execute(cursor, raw_conn, """
SELECT p.id, p.nama
FROM periode_analisis p
WHERE EXISTS (
SELECT 1 FROM ulasan u WHERE u.periode_id = p.id
)
ORDER BY p.id DESC
LIMIT 1
""")
periode = cursor.fetchone()
if not periode:
fail("Belum ada periode yang memiliki ulasan. Jalankan Ambil Data terlebih dahulu.")
periode_id = periode["id"]
periode_nama = periode["nama"]
log("INFO", f"Analisis periode terbaru: {periode_nama} (periode_id={periode_id})")
df = pd.read_sql(
prepare_sql(
raw_conn,
"SELECT u.id, u.wisata, u.reviewer, u.rating, u.ulasan, u.tanggal, u.periode_id "
"FROM ulasan u WHERE u.periode_id = %s AND NOT EXISTS ( SELECT 1 FROM hasil_analisis h WHERE h.ulasan_id = u.id )"
),
engine,
params=(periode_id,),
)
if df.empty:
fail(f"Tidak ada data ulasan untuk periode_id={periode_id}.")
df = df.dropna(subset=["ulasan"]).copy()
df["ulasan"] = df["ulasan"].astype(str)
df = df[df["ulasan"].str.strip().ne("")]
df = df[df["ulasan"].str.strip().ne("0")]
df = df[~df["ulasan"].str.contains(r"\[Tanpa teks\]", na=False)]
df = df[df["ulasan"].str.len() > 5]
if df.empty:
fail("Data ulasan kosong setelah validasi teks.")
df["ulasan_bersih"] = df["ulasan"].apply(preprocess_text)
df = df[df["ulasan_bersih"].str.strip().ne("")].copy()
if df.empty:
fail("Data kosong setelah preprocessing. Tidak ada teks yang bisa dianalisis.")
df["label"] = df.apply(make_pseudo_label, axis=1)
label_counts = df["label"].value_counts()
log("INFO", "Distribusi pseudo-label: " + ", ".join(f"{k}={v}" for k, v in label_counts.items()))
use_model = True
if label_counts.size < 2:
use_model = False
log("WARNING", "Jumlah kelas kurang dari 2. Prediksi memakai pseudo-label langsung tanpa training model.")
can_stratify = label_counts.min() >= 2
if not can_stratify:
log("WARNING", "Ada kelas dengan jumlah data kurang dari 2. Split evaluasi dibuat tanpa stratify.")
report = {"weighted avg": {"precision": 0, "recall": 0, "f1-score": 0}}
accuracy = 0
if use_model:
X = df["ulasan_bersih"]
y = df["label"]
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 if can_stratify else None,
)
else:
log("WARNING", "Data kurang dari 5 baris. Evaluasi memakai data latih yang sama.")
X_train, X_test, y_train, y_test = X, X, y, y
vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_train_vec = vectorizer.fit_transform(X_train)
X_test_vec = vectorizer.transform(X_test)
model = ComplementNB()
model.fit(X_train_vec, y_train)
y_pred = model.predict(X_test_vec)
report = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
accuracy = accuracy_score(y_test, y_pred)
X_all_vec = vectorizer.transform(df["ulasan_bersih"])
df["sentimen"] = model.predict(X_all_vec)
probability_matrix = model.predict_proba(X_all_vec)
df["probabilitas"] = probability_matrix.max(axis=1)
df["probabilitas"] = df["probabilitas"].clip(upper=0.99)
df["probabilitas_by_class"] = list(probability_matrix)
final_results = df.apply(lambda row: apply_rating_priority(row, model.classes_), axis=1)
df["sentimen"] = [result[0] for result in final_results]
df["probabilitas"] = [result[1] for result in final_results]
df = df.drop(columns=["probabilitas_by_class"])
else:
df["sentimen"] = df["label"]
df["probabilitas"] = df["rating"].apply(lambda rating: rating_confidence(rating) or 0.7)
log("INFO", "Evaluasi memakai pseudo-label dari rating/rule otomatis, bukan label manual.")
# execute(cursor, raw_conn, "DELETE FROM hasil_analisis WHERE periode_id = %s", (periode_id,))
# execute(cursor, raw_conn, "DELETE FROM evaluasi_model WHERE periode_id = %s", (periode_id,))
hasil_columns = table_columns(cursor, raw_conn, "hasil_analisis")
insert_columns = [
"ulasan_id",
"wisata",
"ulasan_asli",
"ulasan_bersih",
"hasil_preprocessing",
"sentimen",
"probabilitas",
"periode_id",
"created_at",
"updated_at",
]
if "ulasan_terolah" in hasil_columns:
insert_columns.insert(3, "ulasan_terolah")
placeholders = ", ".join(["%s"] * (len(insert_columns) - 2) + ["NOW()", "NOW()"])
insert_hasil = f"""
INSERT INTO hasil_analisis ({", ".join(insert_columns)})
VALUES ({placeholders})
"""
for _, row in df.fillna("").iterrows():
values = [
int(row["id"]),
str(row["wisata"]),
str(row["ulasan"]),
str(row["ulasan_bersih"]),
str(row["ulasan_bersih"]),
str(row["sentimen"]).lower(),
float(row["probabilitas"]),
periode_id,
]
if "ulasan_terolah" in hasil_columns:
values.insert(3, str(row["ulasan_bersih"]))
execute(cursor, raw_conn, insert_hasil, tuple(values))
weighted = report.get("weighted avg", {})
execute(
cursor,
raw_conn,
"""
INSERT INTO evaluasi_model
(`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
""",
(
float(weighted.get("precision", 0)),
float(weighted.get("recall", 0)),
float(weighted.get("f1-score", 0)),
float(accuracy),
0,
0,
0,
0,
periode_id,
),
)
raw_conn.commit()
log("OK", f"{len(df)} hasil analisis disimpan untuk periode {periode_nama}.")
log("OK", "Analisis selesai.")
except SystemExit:
raw_conn.rollback()
raise
except Exception as exc:
raw_conn.rollback()
fail(f"Analisis gagal: {exc}")
finally:
raw_conn.close()
engine.dispose()
if __name__ == "__main__":
main()

View File

@ -1,329 +0,0 @@
# -*- coding: utf-8 -*-
import re
import sys
import pickle
from pathlib import Path
from urllib.parse import quote_plus
import pandas as pd
import pymysql
from sqlalchemy import create_engine
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
import nltk
from nltk.corpus import stopwords
# =========================
# UTF-8
# =========================
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# =========================
# NLTK
# =========================
nltk.download('stopwords')
# =========================
# BASE DIRECTORY
# =========================
BASE_DIR = Path(__file__).resolve().parents[1]
# =========================
# LOAD MODEL
# =========================
with open('model/model_sentiment.pkl', 'rb') as file:
model = pickle.load(file)
with open('model/tfidf_vectorizer.pkl', 'rb') as file:
tfidf = pickle.load(file)
# =========================
# PREPROCESSING
# =========================
stop_words = set(stopwords.words('indonesian'))
factory = StemmerFactory()
stemmer = factory.create_stemmer()
def preprocess_text(text):
# handle null
if text is None:
return ''
# ubah string
text = str(text)
# lowercase
text = text.lower()
# hapus angka
text = re.sub(r'\d+', '', text)
# hapus simbol
text = re.sub(r'[^\w\s]', '', text)
# hapus spasi berlebih
text = re.sub(r'\s+', ' ', text).strip()
# tokenizing
words = text.split()
# stopword removal
words = [word for word in words if word not in stop_words]
# stemming
words = [stemmer.stem(word) for word in words]
return ' '.join(words)
# =========================
# READ LARAVEL .ENV
# =========================
def read_laravel_env():
env = {}
env_file = BASE_DIR / '.env'
if not env_file.exists():
return env
for line in env_file.read_text(encoding='utf-8').splitlines():
line = line.strip()
if not line:
continue
if line.startswith('#'):
continue
if '=' not in line:
continue
key, value = line.split('=', 1)
env[key.strip()] = value.strip()
return env
env = read_laravel_env()
DB_HOST = env.get('DB_HOST', '127.0.0.1')
DB_PORT = env.get('DB_PORT', '3306')
DB_DATABASE = env.get('DB_DATABASE', 'sistem_analisis')
DB_USERNAME = env.get('DB_USERNAME', 'root')
DB_PASSWORD = env.get('DB_PASSWORD', '')
# =========================
# DATABASE CONNECTION
# =========================
engine_url = (
"mysql+pymysql://"
f"{quote_plus(DB_USERNAME)}:{quote_plus(DB_PASSWORD)}"
f"@{DB_HOST}:{DB_PORT}/{DB_DATABASE}?charset=utf8mb4"
)
engine = create_engine(engine_url)
# =========================
# VALIDASI ARGUMENT
# =========================
if len(sys.argv) < 2:
print("periode_id tidak ditemukan")
sys.exit()
periode_id = sys.argv[1]
# =========================
# AMBIL DATA ULASAN
# =========================
query = f"""
SELECT
id,
wisata,
ulasan,
rating,
tanggal,
periode_id
FROM ulasan
WHERE periode_id = {periode_id}
AND id NOT IN (
SELECT ulasan_id
FROM hasil_analisis
)
"""
df = pd.read_sql(query, engine)
# =========================
# VALIDASI DATA
# =========================
if df.empty:
print("Tidak ada data baru untuk dianalisis.")
sys.exit()
# =========================
# FILTER DATA KOTOR
# =========================
clean_rows = []
for _, row in df.iterrows():
ulasan = str(row['ulasan']).strip()
# skip kosong
if ulasan == '':
continue
# skip null text
if ulasan.lower() == '[tanpa teks]':
continue
# skip pendek
if len(ulasan) < 5:
continue
# skip angka semua
if ulasan.isdigit():
continue
clean_rows.append(row)
# =========================
# DATAFRAME BERSIH
# =========================
if len(clean_rows) == 0:
print("Tidak ada data bersih untuk dianalisis.")
sys.exit()
df = pd.DataFrame(clean_rows)
# =========================
# HAPUS DUPLIKAT
# =========================
df = df.drop_duplicates(subset=['ulasan'])
# =========================
# PREPROCESSING
# =========================
df['ulasan_bersih'] = df['ulasan'].apply(preprocess_text)
# =========================
# HAPUS YANG JADI KOSONG
# =========================
df = df[df['ulasan_bersih'].str.strip() != '']
# =========================
# VALIDASI ULANG
# =========================
if df.empty:
print("Tidak ada data valid setelah preprocessing.")
sys.exit()
# =========================
# TF-IDF
# =========================
X = tfidf.transform(df['ulasan_bersih'])
# =========================
# PREDIKSI SENTIMEN
# =========================
df['sentimen'] = model.predict(X)
probability_matrix = model.predict_proba(X)
df['probabilitas'] = probability_matrix.max(axis=1)
# =========================
# SIMPAN KE DATABASE
# =========================
conn = engine.raw_connection()
cursor = conn.cursor()
jumlah_berhasil = 0
for _, row in df.iterrows():
try:
sql = """
INSERT INTO hasil_analisis
(
ulasan_id,
wisata,
ulasan_asli,
ulasan_terolah,
ulasan_bersih,
hasil_preprocessing,
sentimen,
probabilitas,
periode_id,
created_at,
updated_at
)
VALUES
(
%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW(),NOW()
)
"""
values = (
int(row['id']),
str(row['wisata']),
str(row['ulasan']),
str(row['ulasan_bersih']),
str(row['ulasan_bersih']),
str(row['ulasan_bersih']),
str(row['sentimen']),
float(row['probabilitas']),
int(row['periode_id'])
)
cursor.execute(sql, values)
jumlah_berhasil += 1
except Exception as e:
print(f"Gagal insert data ID {row['id']} : {e}")
conn.commit()
cursor.close()
conn.close()
# =========================
# OUTPUT
# =========================
print(f"{jumlah_berhasil} data berhasil dianalisis!")

View File

@ -2,6 +2,7 @@
import argparse
import os
import tempfile
import hashlib
from networkx import config
import undetected_chromedriver as uc

View File

@ -1,5 +0,0 @@
<?php $__env->startSection('title', __('Not Found')); ?>
<?php $__env->startSection('code', '404'); ?>
<?php $__env->startSection('message', __('Not Found')); ?>
<?php echo $__env->make('errors::minimal', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\SistemAnalisisSentimen\vendor\laravel\framework\src\Illuminate\Foundation\Exceptions/views/404.blade.php ENDPATH**/ ?>

View File

@ -0,0 +1,117 @@
<?php if($paginator->hasPages()): ?>
<nav role="navigation" aria-label="<?php echo e(__('Pagination Navigation')); ?>" class="flex items-center justify-between">
<div class="flex justify-between flex-1 sm:hidden">
<?php if($paginator->onFirstPage()): ?>
<span class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
<?php echo __('pagination.previous'); ?>
</span>
<?php else: ?>
<a href="<?php echo e($paginator->previousPageUrl()); ?>" class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
<?php echo __('pagination.previous'); ?>
</a>
<?php endif; ?>
<?php if($paginator->hasMorePages()): ?>
<a href="<?php echo e($paginator->nextPageUrl()); ?>" class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 rounded-md hover:text-gray-500 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 dark:focus:border-blue-700 dark:active:bg-gray-700 dark:active:text-gray-300">
<?php echo __('pagination.next'); ?>
</a>
<?php else: ?>
<span class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 rounded-md dark:text-gray-600 dark:bg-gray-800 dark:border-gray-600">
<?php echo __('pagination.next'); ?>
</span>
<?php endif; ?>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p class="text-sm text-gray-700 leading-5 dark:text-gray-400">
<?php echo __('Showing'); ?>
<?php if($paginator->firstItem()): ?>
<span class="font-medium"><?php echo e($paginator->firstItem()); ?></span>
<?php echo __('to'); ?>
<span class="font-medium"><?php echo e($paginator->lastItem()); ?></span>
<?php else: ?>
<?php echo e($paginator->count()); ?>
<?php endif; ?>
<?php echo __('of'); ?>
<span class="font-medium"><?php echo e($paginator->total()); ?></span>
<?php echo __('results'); ?>
</p>
</div>
<div>
<span class="relative z-0 inline-flex rtl:flex-row-reverse shadow-sm rounded-md">
<?php if($paginator->onFirstPage()): ?>
<span aria-disabled="true" aria-label="<?php echo e(__('pagination.previous')); ?>">
<span class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-l-md leading-5 dark:bg-gray-800 dark:border-gray-600" aria-hidden="true">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
</span>
<?php else: ?>
<a href="<?php echo e($paginator->previousPageUrl()); ?>" rel="prev" class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('pagination.previous')); ?>">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</a>
<?php endif; ?>
<?php $__currentLoopData = $elements; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $element): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<?php if(is_string($element)): ?>
<span aria-disabled="true">
<span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 cursor-default leading-5 dark:bg-gray-800 dark:border-gray-600"><?php echo e($element); ?></span>
</span>
<?php endif; ?>
<?php if(is_array($element)): ?>
<?php $__currentLoopData = $element; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $page => $url): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<?php if($page == $paginator->currentPage()): ?>
<span aria-current="page">
<span class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default leading-5 dark:bg-gray-800 dark:border-gray-600"><?php echo e($page); ?></span>
</span>
<?php else: ?>
<a href="<?php echo e($url); ?>" class="relative inline-flex items-center px-4 py-2 -ml-px text-sm font-medium text-gray-700 bg-white border border-gray-300 leading-5 hover:text-gray-500 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-700 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400 dark:hover:text-gray-300 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('Go to page :page', ['page' => $page])); ?>">
<?php echo e($page); ?>
</a>
<?php endif; ?>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
<?php endif; ?>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
<?php if($paginator->hasMorePages()): ?>
<a href="<?php echo e($paginator->nextPageUrl()); ?>" rel="next" class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-md leading-5 hover:text-gray-400 focus:z-10 focus:outline-none focus:ring ring-gray-300 focus:border-blue-300 active:bg-gray-100 active:text-gray-500 transition ease-in-out duration-150 dark:bg-gray-800 dark:border-gray-600 dark:active:bg-gray-700 dark:focus:border-blue-800" aria-label="<?php echo e(__('pagination.next')); ?>">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</a>
<?php else: ?>
<span aria-disabled="true" aria-label="<?php echo e(__('pagination.next')); ?>">
<span class="relative inline-flex items-center px-2 py-2 -ml-px text-sm font-medium text-gray-500 bg-white border border-gray-300 cursor-default rounded-r-md leading-5 dark:bg-gray-800 dark:border-gray-600" aria-hidden="true">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</span>
</span>
<?php endif; ?>
</span>
</div>
</div>
</nav>
<?php endif; ?>
<?php /**PATH C:\laragon\www\SistemAnalisisSentimen\vendor\laravel\framework\src\Illuminate\Pagination/resources/views/tailwind.blade.php ENDPATH**/ ?>

View File

@ -0,0 +1,377 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title><?php echo e($judulLaporan); ?></title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: #f1f5f9;
color: #1f2937;
margin: 0;
padding: 24px;
}
/* ── Sticky action bar pojok kanan atas ── */
.action-bar {
position: fixed;
top: 16px;
right: 24px;
display: flex;
gap: 8px;
z-index: 999;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
transition: opacity .2s;
}
.btn:hover { opacity: .85; }
.btn-primary { background: #1d4ed8; color: white; }
.btn-secondary { background: white; color: #374151; border: 1px solid #d1d5db; }
.container {
max-width: 960px;
margin: 0 auto;
background: white;
border-radius: 16px;
padding: 28px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
}
/* LOGO */
.logo { font-size: 26px; font-weight: 800; color: #1e3a8a; margin-bottom: 20px; }
/* HERO */
.hero {
border: 1px solid #dbeafe;
background: linear-gradient(135deg, #eff6ff, #f7f8ff);
border-radius: 12px;
padding: 22px 24px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.hero-title { font-size: 22px; font-weight: 800; color: #132b70; line-height: 1.3; }
.hero-sub { margin-top: 6px; color: #374151; font-size: 13px; }
.hero-right {
min-width: 160px;
border: 1px solid #dbeafe;
border-radius: 10px;
padding: 14px 16px;
background: white;
font-size: 13px;
line-height: 1.8;
}
/* SECTION TITLE */
.section-title {
font-weight: 700;
font-size: 13px;
color: #1e3a8a;
letter-spacing: .5px;
margin: 24px 0 14px;
text-transform: uppercase;
}
/* CARDS */
.cards { display: flex; gap: 12px; margin-bottom: 24px; }
.card {
flex: 1;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
}
.card .card-label { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
.card .card-value { font-size: 26px; font-weight: 800; margin: 0; }
.card .card-unit { font-size: 12px; color: #9ca3af; margin-top: 4px; }
.blue { background: #eff6ff; border-color: #bfdbfe; }
.green { background: #f0fdf4; border-color: #bbf7d0; }
.yellow { background: #fffbeb; border-color: #fde68a; }
.purple { background: #faf5ff; border-color: #e9d5ff; }
/* CHARTS */
.chart-row { display: flex; gap: 16px; margin-bottom: 24px; align-items: flex-start; }
.box-pie {
width: 260px;
flex-shrink: 0;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
}
.box-bar {
flex: 1;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
min-width: 0;
}
.box-pie h4, .box-bar h4 {
margin: 0 0 12px;
font-size: 12px;
color: #1e3a8a;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .4px;
width: 100%;
}
/* TABLE */
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
table th { background: #f8fafc; font-size: 12px; font-weight: 600; }
table th, table td { border: 1px solid #e5e7eb; padding: 9px 11px; font-size: 12px; }
.badge {
background: #dcfce7;
color: #15803d;
border-radius: 20px;
padding: 3px 10px;
font-weight: 600;
font-size: 11px;
}
/* KESIMPULAN */
.kesimpulan {
border: 1px solid #dbeafe;
background: #eff6ff;
border-radius: 10px;
padding: 16px 18px;
margin-top: 20px;
font-size: 13px;
}
/* FOOTER */
.footer {
margin-top: 24px;
padding-top: 14px;
border-top: 1px solid #e5e7eb;
font-size: 12px;
display: flex;
justify-content: space-between;
color: #6b7280;
}
/* Sembunyikan action bar saat print */
@media print {
body { background: white; padding: 0; }
.action-bar { display: none !important; }
}
</style>
</head>
<body>
<div class="action-bar" id="actionBar">
<button class="btn btn-secondary" onclick="cetakHalaman()">🖨️ Cetak</button>
</div>
<div class="container" id="isiLaporan">
<div class="logo">SENTARA</div>
<div class="hero">
<div class="hero-left">
<div class="hero-title">LAPORAN ANALISIS SENTIMEN<br>PER BULAN</div>
<div class="hero-sub">SISTEM ANALISIS SENTIMEN WISATA JEMBER</div>
</div>
<div class="hero-right">
<b>Periode</b><br>
<?php echo e($periode->nama ?? '-'); ?><br><br>
<b>Tanggal Cetak</b><br>
<?php echo e(now()->format('d M Y H:i')); ?>
</div>
</div>
<div class="section-title">Ringkasan</div>
<div class="cards">
<div class="card blue">
<div class="card-label">Total Ulasan</div>
<p class="card-value"><?php echo e($totalUlasanKotor); ?></p>
<div class="card-unit">Ulasan Masuk</div>
</div>
<div class="card green">
<div class="card-label">Hasil Analisis</div>
<p class="card-value"><?php echo e($totalUlasan); ?></p>
<div class="card-unit">Data Bersih</div>
</div>
<div class="card yellow">
<div class="card-label">Wisata Dibahas</div>
<p class="card-value"><?php echo e(count($perWisata)); ?></p>
<div class="card-unit">Destinasi</div>
</div>
<div class="card purple">
<div class="card-label">Akurasi Analisis</div>
<p class="card-value"><?php echo e(number_format(($evaluasi->accuracy ?? 0) * 100, 2)); ?>%</p>
<div class="card-unit">Model</div>
</div>
</div>
<div class="section-title">Visualisasi</div>
<div class="chart-row">
<div class="box-pie">
<h4>Distribusi Sentimen</h4>
<canvas id="pieChart" width="220" height="220"></canvas>
</div>
<div class="box-bar">
<h4>Sentimen per Destinasi</h4>
<canvas id="barChart"></canvas>
</div>
</div>
<div class="section-title">Top Wisata Berdasarkan Sentimen Positif</div>
<table>
<tr>
<th>No</th>
<th>Wisata</th>
<th>Total</th>
<th>Positif</th>
<th>Netral</th>
<th>Negatif</th>
<th>% Positif</th>
</tr>
<?php $__currentLoopData = $perWisata; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $i => $w): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<tr>
<td><?php echo e($i + 1); ?></td>
<td><?php echo e($w->wisata); ?></td>
<td><?php echo e($w->total); ?></td>
<td><?php echo e($w->positif); ?></td>
<td><?php echo e($w->netral); ?></td>
<td><?php echo e($w->negatif); ?></td>
<td><span class="badge"><?php echo e($w->total > 0 ? round(($w->positif / $w->total) * 100, 2) : 0); ?>%</span></td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</table>
<div class="kesimpulan">
<b>KESIMPULAN</b>
<p style="margin:8px 0 0">
Pada periode <b><?php echo e($periode->nama ?? ''); ?></b>,
terdapat <b><?php echo e($totalUlasanKotor); ?></b> ulasan masuk dengan
<b><?php echo e($totalUlasan); ?></b> data berhasil dianalisis.
Sentimen positif mendominasi dengan persentase <b><?php echo e($persen['positif'] ?? 0); ?>%</b>,
diikuti netral <b><?php echo e($persen['netral'] ?? 0); ?>%</b>
dan negatif <b><?php echo e($persen['negatif'] ?? 0); ?>%</b>.
</p>
</div>
<div class="footer">
<div>SENTARA Sistem Analisis Sentimen Wisata Jember</div>
<div>Dicetak oleh: Admin &nbsp;|&nbsp; <?php echo e(now()->format('d M Y H:i')); ?></div>
</div>
</div>
<script>
// ── PIE / DOUGHNUT CHART ──
new Chart(document.getElementById('pieChart'), {
type: 'doughnut',
data: {
labels: ['Positif', 'Netral', 'Negatif'],
datasets: [{
data: [<?php echo e($totalPositif); ?>, <?php echo e($totalNetral); ?>, <?php echo e($totalNegatif); ?>],
backgroundColor: ['#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 2,
borderColor: '#fff'
}]
},
options: {
responsive: false,
animation: false,
plugins: {
legend: { position: 'bottom', labels: { font: { size: 11 }, padding: 10 } }
}
}
});
// ── BAR CHART SENTIMEN PER DESTINASI ──
const wisataLabels = <?php echo json_encode($perWisata->pluck('wisata'), 15, 512) ?>;
const wisataPos = <?php echo json_encode($perWisata->pluck('positif'), 15, 512) ?>;
const wisataNet = <?php echo json_encode($perWisata->pluck('netral'), 15, 512) ?>;
const wisataNeg = <?php echo json_encode($perWisata->pluck('negatif'), 15, 512) ?>;
new Chart(document.getElementById('barChart'), {
type: 'bar',
data: {
labels: wisataLabels,
datasets: [
{ label: 'Positif', data: wisataPos, backgroundColor: '#22c55e' },
{ label: 'Netral', data: wisataNet, backgroundColor: '#f59e0b' },
{ label: 'Negatif', data: wisataNeg, backgroundColor: '#ef4444' }
]
},
options: {
responsive: true,
maintainAspectRatio: true,
animation: false,
plugins: {
legend: { position: 'bottom', labels: { font: { size: 11 } } }
},
scales: {
y: { beginAtZero: true, ticks: { font: { size: 11 } } },
x: { ticks: { font: { size: 10 } } }
}
}
});
// ── CETAK ──
function cetakHalaman() {
window.print();
}
// ── SIMPAN PDF ──
function simpanPDF() {
const el = document.getElementById('isiLaporan');
const bar = document.getElementById('actionBar');
bar.style.display = 'none';
html2pdf().set({
margin: 10,
filename: 'Laporan-<?php echo e(Str::slug($periode->nama ?? "bulanan")); ?>.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
}).from(el).save().then(() => {
bar.style.display = 'flex';
});
}
</script>
</body>
</html><?php /**PATH C:\laragon\www\SistemAnalisisSentimen\resources\views/laporan/bulanan.blade.php ENDPATH**/ ?>

View File

@ -1,605 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Laporan Tahunan</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, sans-serif;
background: #f3f5fb;
padding: 24px;
color: #1f2937;
}
/* ACTION BAR */
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 960px;
margin: 0 auto 16px;
}
.logo-text {
font-size: 22px;
font-weight: 900;
color: #1e3a8a;
letter-spacing: -1px;
}
.logo-text span { color: #ef4444; }
.btn-group { display: flex; gap: 8px; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.12);
}
.btn-blue { background: #1d4ed8; color: white; }
.btn-white { background: white; color: #374151; border: 1px solid #d1d5db; }
.btn:hover { opacity: .88; }
/* CONTAINER */
.container {
max-width: 960px;
margin: 0 auto;
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0,0,0,0.07);
}
/* HERO */
.hero {
border: 1px solid #dbeafe;
background: linear-gradient(135deg, #eff6ff, #f7f8ff);
border-radius: 12px;
padding: 18px 20px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.hero-left { display: flex; gap: 16px; align-items: center; }
.hero-icon {
width: 52px;
height: 52px;
background: #1d4ed8;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-icon svg { width: 28px; height: 28px; fill: white; }
.hero-title { font-size: 18px; font-weight: 900; color: #132b70; line-height: 1.3; }
.hero-sub { font-size: 11px; color: #6b7280; margin-top: 4px; }
.hero-right { font-size: 12px; text-align: right; line-height: 1.8; }
.hero-right .year { font-size: 22px; font-weight: 800; color: #1e3a8a; }
/* SECTION TITLE */
.section-title {
font-size: 12px;
font-weight: 700;
color: #1e3a8a;
text-transform: uppercase;
letter-spacing: .6px;
margin: 20px 0 10px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 14px;
background: #1d4ed8;
border-radius: 2px;
}
/* CARDS */
.cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.card {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 14px 16px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.card-info .card-label { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
.card-info .card-value { font-size: 28px; font-weight: 800; line-height: 1; }
.card-info .card-unit { font-size: 11px; color: #9ca3af; margin-top: 4px; }
.card-icon {
width: 36px; height: 36px;
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.blue { background: #eff6ff; border-color: #bfdbfe; }
.green { background: #f0fdf4; border-color: #bbf7d0; }
.yellow { background: #fffbeb; border-color: #fde68a; }
.purple { background: #faf5ff; border-color: #e9d5ff; }
.blue .card-icon { background: #dbeafe; }
.green .card-icon { background: #dcfce7; }
.yellow .card-icon { background: #fef3c7; }
.purple .card-icon { background: #ede9fe; }
/* REKAP + DONUT ROW */
.grid2 {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
/* TABLE */
.table-box {
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
}
table { width: 100%; border-collapse: collapse; }
table thead tr { background: #f8fafc; }
table th { padding: 10px 12px; font-size: 11px; font-weight: 700; color: #374151; text-align: left; }
table td { padding: 9px 12px; font-size: 11px; border-top: 1px solid #f1f5f9; }
table tbody tr:hover { background: #fafafa; }
.tfoot-row td { background: #f8fafc; font-weight: 700; border-top: 2px solid #e5e7eb; }
.pos { color: #16a34a; font-weight: 600; }
.net { color: #d97706; font-weight: 600; }
.neg { color: #dc2626; font-weight: 600; }
.badge-green {
background: #dcfce7;
color: #15803d;
border-radius: 20px;
padding: 2px 8px;
font-weight: 700;
font-size: 11px;
}
/* DONUT BOX */
.donut-box {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
}
.donut-box h4 {
font-size: 11px;
font-weight: 700;
color: #1e3a8a;
text-transform: uppercase;
letter-spacing: .4px;
margin-bottom: 10px;
}
.donut-wrap {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.donut-center {
position: absolute;
text-align: center;
pointer-events: none;
}
.donut-center .dc-num { font-size: 20px; font-weight: 800; color: #1e3a8a; }
.donut-center .dc-label { font-size: 9px; color: #6b7280; }
/* TREND BOX */
.trend-box {
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.trend-box h4 {
font-size: 11px;
font-weight: 700;
color: #1e3a8a;
text-transform: uppercase;
letter-spacing: .4px;
margin-bottom: 10px;
}
/* BOTTOM GRID */
.bottom-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
/* KESIMPULAN */
.kesimpulan-box {
border: 1px solid #dbeafe;
background: #eff6ff;
border-radius: 12px;
padding: 16px 18px;
font-size: 12px;
line-height: 1.7;
}
.kesimpulan-box h4 {
font-size: 12px;
font-weight: 700;
color: #1e3a8a;
margin-bottom: 8px;
text-transform: uppercase;
}
/* FOOTER */
.footer {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #9ca3af;
padding-top: 14px;
border-top: 1px solid #f1f5f9;
}
@media print {
body { background: white; padding: 0; }
.action-bar { display: none !important; }
.container { box-shadow: none; }
}
</style>
</head>
<body>
<div class="action-bar no-print" id="actionBar">
<div></div>
<div class="btn-group">
<button class="btn btn-white" onclick="window.print()">🖨 Cetak</button>
</div>
</div>
<div class="container" id="isiLaporan">
<div class="hero">
<div class="hero-left">
<div class="hero-icon">
<svg viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6M7 4H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2h-2M9 4a2 2 0 002 2h2a2 2 0 002-2M9 4a2 2 0 012-2h2a2 2 0 012 2" stroke="white" stroke-width="2" stroke-linecap="round" fill="none"/></svg>
</div>
<div>
<div style="display:inline-flex;align-items:center;gap:5px;background:#1e3a8a;border-radius:6px;padding:3px 10px;margin-bottom:7px;">
<span style="color:#fff;font-size:11px;font-weight:900;letter-spacing:1px;">-SENTARA-</span>
<span style="color:#93c5fd;font-size:9px;letter-spacing:2px;">JEMBER</span>
</div>
<div class="hero-title">LAPORAN ANALISIS SENTIMEN<br>PER TAHUN</div>
<div class="hero-sub">SISTEM ANALISIS SENTIMEN WISATA JEMBER</div>
</div>
</div>
<div class="hero-right">
<div style="color:#6b7280;font-size:11px;">Tahun</div>
<div class="year"><?php echo e($tahun); ?></div>
<div style="color:#6b7280;font-size:11px;margin-top:6px;">Tanggal Cetak</div>
<div style="font-weight:600;"><?php echo e(now()->format('d M Y H:i')); ?></div>
</div>
</div>
<div class="section-title">Ringkasan Tahunan</div>
<div class="cards">
<div class="card blue">
<div class="card-info">
<div class="card-label">Total Ulasan</div>
<div class="card-value"><?php echo e($totalUlasan); ?></div>
<div class="card-unit">Ulasan</div>
</div>
<div class="card-icon">💬</div>
</div>
<div class="card green">
<div class="card-info">
<div class="card-label">Rata-rata Akurasi</div>
<div class="card-value" style="font-size:20px;"><?php echo e($akurasi); ?>%</div>
<div class="card-unit">Akurasi</div>
</div>
<div class="card-icon"></div>
</div>
<div class="card yellow">
<div class="card-info">
<div class="card-label">Total Wisata Dibahas</div>
<div class="card-value"><?php echo e($totalWisata); ?></div>
<div class="card-unit">Wisata</div>
</div>
<div class="card-icon">📍</div>
</div>
<div class="card purple">
<div class="card-info">
<div class="card-label">Bulan Dianalisis</div>
<div class="card-value"><?php echo e(count($rekapBulanan)); ?></div>
<div class="card-unit">Bulan</div>
</div>
<div class="card-icon">📅</div>
</div>
</div>
<div class="section-title">Rekap Sentimen per Bulan</div>
<div class="grid2">
<div class="table-box">
<table>
<thead>
<tr>
<th>Bulan</th>
<th>Total Ulasan</th>
<th>Positif</th>
<th>Netral</th>
<th>Negatif</th>
<th>Persentase Positif</th>
</tr>
</thead>
<tbody>
<?php $totP=0; $totN=0; $totNt=0; $totAll=0; ?>
<?php $__currentLoopData = $rekapBulanan; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $r): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<?php $totP+=$r->positif; $totN+=$r->negatif; $totNt+=$r->netral; $totAll+=$r->total; ?>
<tr>
<td><?php echo e($r->bulan); ?></td>
<td><?php echo e($r->total); ?></td>
<td class="pos"><?php echo e($r->positif); ?></td>
<td class="net"><?php echo e($r->netral); ?></td>
<td class="neg"><?php echo e($r->negatif); ?></td>
<td><span class="badge-green"><?php echo e(number_format(($r->positif/max($r->total,1))*100,2)); ?>%</span></td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</tbody>
<tfoot>
<tr class="tfoot-row">
<td>TOTAL / RATA-RATA</td>
<td><?php echo e($totAll); ?></td>
<td class="pos"><?php echo e($totP); ?></td>
<td class="net"><?php echo e($totNt); ?></td>
<td class="neg"><?php echo e($totN); ?></td>
<td><span class="badge-green"><?php echo e($totAll > 0 ? number_format(($totP/$totAll)*100,2) : 0); ?>%</span></td>
</tr>
</tfoot>
</table>
</div>
<div class="donut-box">
<h4>Distribusi Sentimen Tahun <?php echo e($tahun); ?></h4>
<div class="donut-wrap" style="height:220px;">
<canvas id="donut"></canvas>
</div>
</div>
</div>
<div class="section-title">Trend Sentimen per Bulan (<?php echo e($tahun); ?>)</div>
<div class="trend-box">
<canvas id="trend" height="90"></canvas>
</div>
<div class="section-title">Wisata Serpopuler & Kesimpulan</div>
<div class="bottom-grid">
<div class="table-box">
<table>
<thead>
<tr>
<th>No</th>
<th>Wisata</th>
<th>Total Ulasan</th>
<th>Persentase Positif</th>
</tr>
</thead>
<tbody>
<?php $__currentLoopData = $wisataPopuler; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $i => $w): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<tr>
<td><?php echo e($i+1); ?></td>
<td><?php echo e($w->wisata); ?></td>
<td><?php echo e($w->jumlah); ?></td>
<td><span class="badge-green"><?php echo e($w->persen); ?>%</span></td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</tbody>
</table>
</div>
<div class="kesimpulan-box">
<h4>Kesimpulan Tahunan</h4>
<p>
Secara umum, sentimen positif mendominasi tahun <b><?php echo e($tahun); ?></b>
dengan rata-rata <b><?php echo e($persenPositif); ?>%</b>.
<?php if(isset($wisataPopuler[0])): ?>
<?php echo e($wisataPopuler[0]->wisata); ?> menjadi destinasi paling populer
dengan sentimen positif tertinggi.
<?php endif; ?>
Diperlukan peningkatan pada aspek fasilitas, kebersihan, dan pelayanan
untuk menekan sentimen negatif.
</p>
</div>
</div>
<div class="footer">
<div>SENTARA - Sistem Analisis Sentimen Wisata Jember</div>
<div>Dicetak oleh: <?php echo e(auth()->user()->name); ?> (<?php echo e(ucfirst(auth()->user()->role ?? 'Admin')); ?>)</div>
</div>
</div>
<script>
// ── DONUT ──
const donutCtx = document.getElementById('donut');
const totalUlasan = <?php echo e($totalUlasan > 0 ? $totalUlasan : 1); ?>;
const donutData = [<?php echo e($positif); ?>, <?php echo e($netral); ?>, <?php echo e($negatif); ?>];
// Plugin: angka total di tengah (tidak numpuk)
const centerTextPlugin = {
id: 'centerText',
beforeDraw(chart) {
const { ctx, chartArea: { left, top, right, bottom } } = chart;
const cx = (left + right) / 2;
const cy = (top + bottom) / 2;
ctx.save();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#1e3a8a';
ctx.font = 'bold 22px Arial';
ctx.fillText(totalUlasan, cx, cy - 8);
ctx.fillStyle = '#9ca3af';
ctx.font = '11px Arial';
ctx.fillText('Total Ulasan', cx, cy + 12);
ctx.restore();
}
};
new Chart(donutCtx, {
type: 'doughnut',
data: {
labels: ['Positif', 'Netral', 'Negatif'],
datasets: [{
data: donutData,
backgroundColor: ['#22c55e', '#f59e0b', '#ef4444'],
borderWidth: 3,
borderColor: '#fff',
hoverOffset: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
cutout: '62%',
plugins: {
legend: {
position: 'bottom',
labels: {
font: { size: 11 },
padding: 10,
generateLabels(chart) {
const d = chart.data;
return d.labels.map((lbl, i) => {
const val = d.datasets[0].data[i];
const pct = Math.round(val / totalUlasan * 1000) / 10;
return {
text: `${lbl} ${val} (${pct}%)`,
fillStyle: d.datasets[0].backgroundColor[i],
strokeStyle: '#fff',
lineWidth: 2,
index: i,
};
});
}
}
},
tooltip: {
callbacks: {
label(ctx) {
const pct = Math.round(ctx.parsed / totalUlasan * 1000) / 10;
return ` ${ctx.label}: ${ctx.parsed} (${pct}%)`;
}
}
}
}
},
plugins: [centerTextPlugin]
});
// ── TREND LINE ──
new Chart(document.getElementById('trend'), {
type: 'line',
data: {
labels: [
<?php $__currentLoopData = $rekapBulanan; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $r): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
'<?php echo e($r->bulan); ?>',
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
],
datasets: [
{
label: 'Positif',
data: [<?php $__currentLoopData = $rekapBulanan; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $r): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?><?php echo e($r->positif); ?>,<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>],
borderColor: '#22c55e',
backgroundColor: 'rgba(34,197,94,0.08)',
tension: 0.4, fill: true, pointRadius: 4, pointBackgroundColor: '#22c55e'
},
{
label: 'Netral',
data: [<?php $__currentLoopData = $rekapBulanan; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $r): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?><?php echo e($r->netral); ?>,<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>],
borderColor: '#f59e0b',
backgroundColor: 'rgba(245,158,11,0.05)',
tension: 0.4, fill: false, pointRadius: 4, pointBackgroundColor: '#f59e0b',
borderDash: [4,3]
},
{
label: 'Negatif',
data: [<?php $__currentLoopData = $rekapBulanan; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $r): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?><?php echo e($r->negatif); ?>,<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>],
borderColor: '#ef4444',
backgroundColor: 'rgba(239,68,68,0.05)',
tension: 0.4, fill: false, pointRadius: 4, pointBackgroundColor: '#ef4444'
}
]
},
options: {
responsive: true,
animation: false,
plugins: {
legend: { position: 'bottom', labels: { font: { size: 11 }, padding: 12 } }
},
scales: {
y: { beginAtZero: true, ticks: { font: { size: 10 } }, grid: { color: '#f1f5f9' } },
x: { ticks: { font: { size: 10 } }, grid: { display: false } }
}
}
});
// ── SIMPAN PDF ──
function simpanPDF() {
const el = document.getElementById('isiLaporan');
const bar = document.getElementById('actionBar');
bar.style.display = 'none';
html2pdf().set({
margin: 8,
filename: 'Laporan-Tahunan-<?php echo e($tahun); ?>.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, windowWidth: 960 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }
}).from(el).save().then(() => {
bar.style.display = 'flex';
});
}
</script>
</body>
</html><?php /**PATH C:\laragon\www\SistemAnalisisSentimen\resources\views/laporan/tahunan.blade.php ENDPATH**/ ?>

View File

@ -0,0 +1,442 @@
<?php $__env->startSection('content'); ?>
<div class="space-y-6">
<?php if(session('success')): ?>
<div class="bg-green-50 border border-green-200 text-green-700 px-5 py-4 rounded-2xl">
<?php echo e(session('success')); ?>
</div>
<?php endif; ?>
<?php if(session('error')): ?>
<div class="bg-red-50 border border-red-200 text-red-700 px-5 py-4 rounded-2xl">
<?php echo e(session('error')); ?>
</div>
<?php endif; ?>
<!-- HEADER -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-slate-800">Kelola Pengguna</h1>
<p class="text-slate-500 mt-1">Kelola akun pengguna sistem SENTARA.</p>
</div>
<!-- BUTTON TAMBAH -->
<button
onclick="openTambahModal()"
class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-3 rounded-2xl text-sm font-semibold shadow transition">
+ Tambah Pengguna
</button>
</div>
<!-- CARD -->
<div class="bg-white rounded-3xl shadow-sm border border-slate-100 p-5">
<!-- TABLE -->
<div class="overflow-x-auto mt-6">
<table class="w-full text-sm min-w-[900px]">
<thead>
<tr class="text-slate-500 border-b">
<th class="py-4 text-left">No</th>
<th class="py-4 text-left">Nama</th>
<th class="py-4 text-left">Email</th>
<th class="py-4 text-left">Role</th>
<th class="py-4 text-left">Status</th>
<th class="py-4 text-left">Terakhir Aktif</th>
<th class="py-4 text-center">Aksi</th>
</tr>
</thead>
<tbody class="text-slate-700">
<?php $__empty_1 = true; $__currentLoopData = $users; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $user): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<tr class="border-b hover:bg-slate-50 transition">
<td class="py-4"><?php echo e($loop->iteration); ?></td>
<td class="py-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center text-xs font-bold text-slate-600">
<?php echo e(strtoupper(substr($user->name, 0, 2))); ?>
</div>
<span class="font-medium"><?php echo e($user->name); ?></span>
</div>
</td>
<td class="py-4"><?php echo e($user->email); ?></td>
<td class="py-4">
<?php if($user->role == 'admin'): ?>
<span class="bg-purple-100 text-purple-700 px-3 py-1 rounded-full text-xs font-semibold">Admin</span>
<?php else: ?>
<span class="bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-xs font-semibold">Staff</span>
<?php endif; ?>
</td>
<td class="py-4">
<span class="bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-semibold">Aktif</span>
</td>
<td class="py-4"><?php echo e($user->updated_at->format('d M Y, H:i')); ?></td>
<td class="py-4">
<div class="flex justify-center gap-2">
<button
onclick="openEditModal('<?php echo e($user->id); ?>', '<?php echo e(addslashes($user->name)); ?>', '<?php echo e($user->email); ?>', '<?php echo e($user->role); ?>')"
class="w-11 h-11 rounded-2xl border border-blue-200 text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
title="Edit Pengguna">✏️</button>
<button
onclick="openPasswordModal('<?php echo e($user->id); ?>')"
class="w-11 h-11 rounded-2xl border border-amber-200 text-amber-500 hover:bg-amber-50 flex items-center justify-center transition"
title="Reset Password">🔒</button>
<button
onclick="openDeleteModal('<?php echo e($user->id); ?>', '<?php echo e($user->role); ?>')"
class="w-11 h-11 rounded-2xl border border-red-200 text-red-500 hover:bg-red-50 flex items-center justify-center transition"
title="Hapus Pengguna">🗑️</button>
</div>
</td>
</tr>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<tr>
<td colspan="7" class="py-10 text-center text-slate-400">
Data pengguna belum tersedia.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- PAGINATION -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mt-6">
<p class="text-sm text-slate-500">
Menampilkan <?php echo e($users->firstItem() ?? 0); ?> - <?php echo e($users->lastItem() ?? 0); ?>
dari <?php echo e($users->total()); ?> pengguna
</p>
<?php echo e($users->links()); ?>
</div>
</div>
</div>
<!-- ================= MODAL TAMBAH PENGGUNA ================= -->
<div id="tambahModal"
class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center px-4">
<div class="bg-white rounded-3xl p-6 shadow-2xl w-full" style="max-width: 480px;">
<div class="flex items-start justify-between mb-5">
<div>
<h2 class="text-2xl font-bold text-slate-800">Tambah Pengguna</h2>
<p class="text-slate-500 text-sm mt-1">Buat akun pengguna baru untuk sistem SENTARA.</p>
</div>
<button onclick="closeTambahModal()"
class="text-slate-400 hover:text-slate-700 text-2xl leading-none ml-4 flex-shrink-0">×</button>
</div>
<form method="POST" action="/kelola-pengguna" id="tambahForm">
<?php echo csrf_field(); ?>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Nama Pengguna</label>
<input type="text" name="name" placeholder="Masukkan nama lengkap"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" required>
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Email</label>
<input type="email" name="email" placeholder="contoh@email.com"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" required>
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Role Pengguna</label>
<select name="role"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Password</label>
<input type="password" name="password" placeholder="Minimal 8 karakter"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" required>
</div>
<div class="mb-6">
<label class="block text-sm font-semibold text-slate-700 mb-2">Konfirmasi Password</label>
<input type="password" name="password_confirmation" placeholder="Ulangi password"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none" required>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeTambahModal()"
class="flex-1 py-3 rounded-2xl border border-slate-200 text-slate-600 text-sm font-medium hover:bg-slate-100 transition">
Batal
</button>
<button type="submit"
class="flex-1 py-3 rounded-2xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition">
Tambah
</button>
</div>
</form>
</div>
</div>
<!-- ================= MODAL EDIT ================= -->
<div id="editModal"
class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center px-4">
<div class="bg-white rounded-3xl p-6 shadow-2xl w-full" style="max-width: 480px;">
<div class="flex items-start justify-between mb-5">
<div>
<h2 class="text-2xl font-bold text-slate-800">Edit Pengguna</h2>
<p class="text-slate-500 text-sm mt-1">Perbarui informasi akun pengguna SENTARA.</p>
</div>
<button onclick="closeEditModal()"
class="text-slate-400 hover:text-slate-700 text-2xl leading-none ml-4 flex-shrink-0">×</button>
</div>
<form method="POST" id="editForm">
<?php echo csrf_field(); ?>
<?php if($errors->any()): ?>
<div class="mb-4 rounded-2xl bg-red-50 border border-red-200 text-red-600 px-4 py-3 text-sm">
<?php echo e($errors->first()); ?>
</div>
<?php endif; ?>
<?php echo method_field('PUT'); ?>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Nama Pengguna</label>
<input type="text" name="name" id="editName"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Email</label>
<input type="email" name="email" id="editEmail"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none">
</div>
<div class="mb-6">
<label class="block text-sm font-semibold text-slate-700 mb-2">Role Pengguna</label>
<select name="role" id="editRole"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none">
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
</div>
<div class="flex gap-3">
<button type="button" onclick="closeEditModal()"
class="flex-1 py-3 rounded-2xl border border-slate-200 text-slate-600 text-sm font-medium hover:bg-slate-100 transition">
Batal
</button>
<button type="submit"
class="flex-1 py-3 rounded-2xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition">
Simpan
</button>
</div>
</form>
</div>
</div>
<!-- ================= MODAL RESET PASSWORD ================= -->
<div id="passwordModal"
class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center px-4">
<div class="bg-white rounded-3xl p-6 shadow-2xl w-full" style="max-width: 480px;">
<div class="flex items-start justify-between mb-5">
<div>
<h2 class="text-2xl font-bold text-slate-800">Reset Password</h2>
<p class="text-slate-500 text-sm mt-1">Buat password baru pengguna.</p>
</div>
<button onclick="closePasswordModal()"
class="text-slate-400 hover:text-slate-700 text-2xl leading-none ml-4 flex-shrink-0">×</button>
</div>
<div class="mb-5 bg-amber-50 border border-amber-200 rounded-2xl p-4">
<p class="text-sm text-amber-700 leading-relaxed">
Password minimal 8 karakter dan harus kombinasi huruf besar, huruf kecil, dan angka.
</p>
</div>
<form method="POST" id="passwordForm">
<?php echo csrf_field(); ?>
<?php echo method_field('PUT'); ?>
<div class="mb-4">
<label class="block text-sm font-semibold text-slate-700 mb-2">Password Baru</label>
<input type="password" name="password"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-amber-400 focus:outline-none">
</div>
<div class="mb-6">
<label class="block text-sm font-semibold text-slate-700 mb-2">Konfirmasi Password</label>
<input type="password" name="password_confirmation"
class="w-full rounded-2xl border border-slate-200 px-4 py-3 text-sm focus:ring-2 focus:ring-amber-400 focus:outline-none">
</div>
<div class="flex gap-3">
<button type="button" onclick="closePasswordModal()"
class="flex-1 py-3 rounded-2xl border border-slate-200 text-slate-600 text-sm font-medium hover:bg-slate-100 transition">
Batal
</button>
<button type="submit"
class="flex-1 py-3 rounded-2xl bg-amber-500 hover:bg-amber-600 text-white text-sm font-semibold transition">
Reset
</button>
</div>
</form>
</div>
</div>
<!-- ================= MODAL DELETE ================= -->
<div id="deleteModal"
class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center px-4">
<div class="bg-white rounded-3xl p-6 shadow-2xl w-full text-center" style="max-width: 400px;">
<div class="w-16 h-16 mx-auto rounded-full bg-red-100 flex items-center justify-center text-3xl mb-4">🗑️</div>
<h2 class="text-2xl font-bold text-slate-800">Hapus Pengguna?</h2>
<p class="text-slate-500 text-sm mt-2 leading-relaxed">
Data pengguna akan dihapus permanen dan tidak dapat dikembalikan.
</p>
<form method="POST" id="deleteForm" class="mt-6">
<?php echo csrf_field(); ?>
<?php echo method_field('DELETE'); ?>
<div class="flex gap-3">
<button type="button" onclick="closeDeleteModal()"
class="flex-1 py-3 rounded-2xl border border-slate-200 text-slate-600 text-sm font-medium hover:bg-slate-100 transition">
Batal
</button>
<button type="submit"
class="flex-1 py-3 rounded-2xl bg-red-500 hover:bg-red-600 text-white text-sm font-semibold transition">
Hapus
</button>
</div>
</form>
</div>
</div>
<!-- ================= MODAL ADMIN TIDAK BISA DIHAPUS ================= -->
<div id="adminAlertModal"
class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center px-4">
<div class="bg-white rounded-3xl p-6 shadow-2xl w-full text-center" style="max-width: 400px;">
<div class="w-16 h-16 mx-auto rounded-full bg-amber-100 flex items-center justify-center text-3xl mb-4">⚠️</div>
<h2 class="text-2xl font-bold text-slate-800">Akses Ditolak</h2>
<p class="text-slate-500 text-sm mt-2 leading-relaxed">
Akun administrator tidak dapat dihapus demi menjaga keamanan sistem.
</p>
<button onclick="closeAdminAlertModal()"
class="w-full mt-6 py-3 rounded-2xl bg-blue-600 hover:bg-blue-700 text-white text-sm font-semibold transition">
Mengerti
</button>
</div>
</div>
<!-- ================= SCRIPT ================= -->
<script>
/* ---- TAMBAH ---- */
function openTambahModal() {
document.getElementById('tambahModal').classList.remove('hidden');
document.getElementById('tambahForm').reset();
}
function closeTambahModal() {
document.getElementById('tambahModal').classList.add('hidden');
}
/* ---- EDIT ---- */
function openEditModal(id, name, email, role) {
document.getElementById('editModal').classList.remove('hidden');
document.getElementById('editName').value = name;
document.getElementById('editEmail').value = email;
document.getElementById('editRole').value = role;
document.getElementById('editForm').action = '/kelola-pengguna/' + id;
}
function closeEditModal() {
document.getElementById('editModal').classList.add('hidden');
}
/* ---- RESET PASSWORD ---- */
function openPasswordModal(id) {
document.getElementById('passwordModal').classList.remove('hidden');
document.getElementById('passwordForm').action =
'/kelola-pengguna/' + id + '/reset-password';
}
function closePasswordModal() {
document.getElementById('passwordModal').classList.add('hidden');
}
/* ---- DELETE ---- */
function openDeleteModal(id, role) {
if (role === 'admin') {
document.getElementById('adminAlertModal').classList.remove('hidden');
return;
}
document.getElementById('deleteModal').classList.remove('hidden');
document.getElementById('deleteForm').action = '/kelola-pengguna/' + id;
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
}
function closeAdminAlertModal() {
document.getElementById('adminAlertModal').classList.add('hidden');
}
</script>
<?php $__env->stopSection(); ?>
<?php echo $__env->make('layouts.sentara', array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1]))->render(); ?><?php /**PATH C:\laragon\www\SistemAnalisisSentimen\resources\views/kelolauser/index.blade.php ENDPATH**/ ?>

View File

@ -3,6 +3,8 @@
$totalDestinasiAnalisis ??= 0;
$totalDestinasiBerkeluhan ??= 0;
$totalNegatif ??= 0;
$totalPositif ??= 0;
$totalNetral ??= 0;
$totalUlasan ??= 0;
$persenNegatif ??= 0;
$tingkatKepuasan ??= 0;
@ -15,6 +17,37 @@
$prioritas ??= [];
?>
<?php
// $persenPositif, $persenNetral, $isuNegatif, $isuNetral, $isuPositif sudah dikirim dari controller
// Icon map untuk prioritas cards
$iconMap = [
'harga' => '🎫',
'tiket' => '🎫',
'kebersihan'=> '🧹',
'parkir' => '🚗',
'fasilitas' => '🏗️',
'keramaian' => '👥',
'akses' => '🛣️',
'jalan' => '🛣️',
'default' => '⚙️',
];
?>
<style>
.gauge-ring {
transform: rotate(-90deg);
transform-origin: center;
}
.gauge-wrap { position: relative; display: inline-block; }
.gauge-inner {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -30%);
text-align: center;
line-height: 1.1;
}
</style>
<div class="space-y-6">
@ -22,7 +55,7 @@
<div>
<h2 class="text-2xl font-bold text-gray-800">Rekomendasi Layanan</h2>
<p class="text-sm text-gray-500">
Rekomendasi dibuat dari ulasan negatif agar saran perbaikan fokus pada keluhan pengunjung.
Rekomendasi dibuat dari hasil analisis sentimen untuk perbaikan layanan destinasi wisata.
</p>
<p class="text-xs text-gray-400 mt-1">
<?php echo e($totalDestinasiAnalisis); ?> destinasi dianalisis, <?php echo e($totalDestinasiBerkeluhan); ?> destinasi memiliki ulasan negatif.
@ -32,7 +65,10 @@
<form method="GET" action="<?php echo e(route('dashboard')); ?>" class="flex gap-3">
<input type="hidden" name="tab" value="rekomendasi">
<input type="hidden" name="periode_id" value="<?php echo e($periodeAktif->id ?? request('periode_id')); ?>">
<input type="hidden" name="periode_id" value="<?php echo e($periodeId ?? $periodeAktif->id ?? request('periode_id')); ?>">
<?php if(request('periode_bulan')): ?>
<input type="hidden" name="periode_bulan" value="<?php echo e(request('periode_bulan')); ?>">
<?php endif; ?>
<select name="destinasi" onchange="this.form.submit()"
class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none focus:ring-2 focus:ring-blue-400">
<option value="">Semua Destinasi</option>
@ -47,176 +83,241 @@ class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<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 class="bg-white rounded-xl shadow p-5 flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Destinasi Dianalisis</p>
<h3 class="text-xl font-bold"><?php echo e($totalDestinasiAnalisis); ?></h3>
<p class="text-xs text-gray-500"><?php echo e($totalDestinasiBerkeluhan); ?> punya keluhan</p>
<p class="text-sm font-semibold text-green-600">Tingkat Positif</p>
<h3 class="text-4xl font-bold text-green-600 mt-1"><?php echo e($persenPositif); ?>%</h3>
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 font-medium">Positif</span>
<p class="text-xs text-gray-400 mt-2"><?php echo e($totalPositif); ?> ulasan</p>
</div>
<div class="gauge-wrap">
<svg width="90" height="70" viewBox="0 0 90 70">
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#22c55e" stroke-width="8" stroke-linecap="round"
stroke-dasharray="<?php echo e(round($persenPositif * 1.099)); ?> 110"
style="transition: stroke-dasharray 0.8s ease"/>
</svg>
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😊</div>
</div>
</div>
<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-white rounded-xl shadow p-5 flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Total Ulasan Negatif</p>
<h3 class="text-xl font-bold"><?php echo e($totalNegatif); ?></h3>
<p class="text-xs text-red-500"><?php echo e($persenNegatif); ?>% dari total</p>
<p class="text-sm font-semibold text-red-500">Tingkat Negatif</p>
<h3 class="text-4xl font-bold text-red-500 mt-1"><?php echo e($persenNegatif); ?>%</h3>
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-red-100 text-red-600 font-medium">Negatif</span>
<p class="text-xs text-gray-400 mt-2"><?php echo e($totalNegatif); ?> ulasan</p>
</div>
<div class="gauge-wrap">
<svg width="90" height="70" viewBox="0 0 90 70">
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#fee2e2" stroke-width="8" stroke-linecap="round"/>
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#ef4444" stroke-width="8" stroke-linecap="round"
stroke-dasharray="<?php echo e(round($persenNegatif * 1.099)); ?> 110"
style="transition: stroke-dasharray 0.8s ease"/>
</svg>
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😟</div>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
<div class="bg-green-100 text-green-600 p-3 rounded-full text-xl">😊</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center justify-between">
<div>
<p class="text-sm text-gray-500">Tingkat Kepuasan</p>
<h3 class="text-xl font-bold"><?php echo e($tingkatKepuasan); ?>%</h3>
<span class="text-xs px-2 py-1 rounded
<?php echo e($labelKepuasan === 'Baik' ? 'bg-green-100 text-green-700' :
($labelKepuasan === 'Sedang' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700')); ?>">
<?php echo e($labelKepuasan); ?>
</span>
<p class="text-sm font-semibold text-gray-500">Tingkat Netral</p>
<h3 class="text-4xl font-bold text-gray-600 mt-1"><?php echo e($persenNetral); ?>%</h3>
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-600 font-medium">Netral</span>
<p class="text-xs text-gray-400 mt-2"><?php echo e($totalNetral); ?> ulasan</p>
</div>
</div>
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
<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"><?php echo e($isuDominan); ?></h3>
<p class="text-xs text-gray-500"><?php echo e($isuDominanPersen); ?>% dari total isu</p>
<div class="gauge-wrap">
<svg width="90" height="70" viewBox="0 0 90 70">
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#9ca3af" stroke-width="8" stroke-linecap="round"
stroke-dasharray="<?php echo e(round($persenNetral * 1.099)); ?> 110"
style="transition: stroke-dasharray 0.8s ease"/>
</svg>
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😐</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<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-red-600">Isu Negatif Utama (Top 5)</h3>
<?php $__empty_1 = true; $__currentLoopData = $isuUtama; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $i => $isu): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<?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',
};
?>
<?php $__empty_1 = true; $__currentLoopData = $isuNegatif; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $i => $isu): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<?php if($i >= 5): ?> <?php break; ?> <?php endif; ?>
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span><?php echo e($i + 1); ?>. <?php echo e($isu['nama']); ?></span>
<span class="font-semibold"><?php echo e($isu['persen']); ?>%</span>
<span class="text-gray-700"><?php echo e($i + 1); ?>. <?php echo e($isu['nama']); ?></span>
<span class="font-semibold text-gray-800">
<?php echo e($isu['persen']); ?>%
<?php if(isset($isu['jumlah'])): ?> (<?php echo e($isu['jumlah']); ?>) <?php endif; ?>
</span>
</div>
<div class="w-full bg-red-100 h-2 rounded">
<div class="h-2 rounded bg-red-500" style="width: <?php echo e($isu['persen']); ?>%"></div>
</div>
</div>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu negatif.</p>
<?php endif; ?>
<p class="text-xs text-red-400 mt-2">
Persentase berdasarkan dari total ulasan negatif (<?php echo e($totalNegatif); ?> ulasan)
</p>
</div>
<div class="bg-white rounded-xl shadow p-5">
<h3 class="font-semibold mb-4 text-gray-600">Isu Netral Utama (Top 5)</h3>
<?php $__empty_1 = true; $__currentLoopData = $isuNetral; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $i => $isu): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<?php if($i >= 5): ?> <?php break; ?> <?php endif; ?>
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-700"><?php echo e($i + 1); ?>. <?php echo e($isu['nama']); ?></span>
<span class="font-semibold text-gray-800">
<?php echo e($isu['persen']); ?>%
<?php if(isset($isu['jumlah'])): ?> (<?php echo e($isu['jumlah']); ?>) <?php endif; ?>
</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded">
<div class="h-2 rounded <?php echo e($warnaClass); ?>" style="width: <?php echo e($isu['persen']); ?>%"></div>
<div class="h-2 rounded bg-gray-500" style="width: <?php echo e($isu['persen']); ?>%"></div>
</div>
</div>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu.</p>
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu netral.</p>
<?php endif; ?>
<p class="text-xs text-gray-400 mt-2">
Persentase berdasarkan dari total ulasan netral (<?php echo e($totalNetral); ?> ulasan)
</p>
</div>
<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-green-600">Aspek Positif Utama (Top 5)</h3>
<?php $maxFreq = !empty($kataDominan) ? max($kataDominan) : 1; ?>
<?php $__empty_1 = true; $__currentLoopData = array_slice($kataDominan, 0, 5, true); $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $kata => $jumlah): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<div class="mb-3">
<div class="flex justify-between text-sm">
<span><?php echo e($kata); ?></span>
<span class="font-semibold text-blue-600"><?php echo e($jumlah); ?>x</span>
<?php $__empty_1 = true; $__currentLoopData = $isuPositif; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $i => $isu): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<?php if($i >= 5): ?> <?php break; ?> <?php endif; ?>
<div class="mb-4">
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-700"><?php echo e($i + 1); ?>. <?php echo e($isu['nama']); ?></span>
<span class="font-semibold text-gray-800">
<?php echo e($isu['persen']); ?>%
<?php if(isset($isu['jumlah'])): ?> (<?php echo e($isu['jumlah']); ?>) <?php endif; ?>
</span>
</div>
<div class="w-full bg-gray-200 h-2 rounded mt-1">
<div class="bg-blue-500 h-2 rounded" style="width: <?php echo e(round(($jumlah / $maxFreq) * 100)); ?>%"></div>
<div class="w-full bg-green-100 h-2 rounded">
<div class="h-2 rounded bg-green-500" style="width: <?php echo e($isu['persen']); ?>%"></div>
</div>
</div>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<p class="text-sm text-gray-400 text-center py-4">Belum ada kata kunci.</p>
<p class="text-sm text-gray-400 text-center py-4">Belum ada data isu positif.</p>
<?php endif; ?>
</div>
<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">
<?php $__empty_1 = true; $__currentLoopData = $saranPerbaikan; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $saran): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<div class="flex gap-3">
<div class="text-xl"><?php echo e($saran['icon']); ?></div>
<div>
<p class="font-semibold"><?php echo e($saran['nama']); ?></p>
<p class="text-gray-600"><?php echo e($saran['tip']); ?></p>
</div>
</div>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<p class="text-sm text-gray-400 text-center py-4">Belum ada saran.</p>
<?php endif; ?>
</div>
<p class="text-xs text-green-400 mt-2">
Persentase berdasarkan dari total ulasan positif (<?php echo e($totalPositif); ?> ulasan)
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<h3 class="text-lg font-bold text-gray-800 mb-1">Prioritas Rekomendasi Perbaikan Layanan</h3>
<p class="text-sm text-gray-500 mb-4">Prioritas disusun berdasarkan frekuensi isu negatif dan dampaknya terhadap kepuasan pengunjung.</p>
<?php $__empty_1 = true; $__currentLoopData = $prioritas; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $p): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<?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',
};
?>
<div class="border <?php echo e($border); ?> rounded-xl p-5 bg-white">
<h4 class="font-semibold <?php echo e($title); ?> mb-2">Prioritas <?php echo e($p['rank']); ?></h4>
<h3 class="text-lg font-bold mb-3"><?php echo e($p['nama']); ?></h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<ul class="text-sm space-y-2 mb-4">
<?php $__currentLoopData = $p['actions']; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $action): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<li class="flex items-center gap-2">
<span class="text-blue-500"></span> <?php echo e($action); ?>
<?php $__empty_1 = true; $__currentLoopData = $prioritas; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $p): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
<?php
$border = match($p['color']) {
'red' => 'border-red-200',
'orange' => 'border-orange-200',
'yellow' => 'border-yellow-200',
'green' => 'border-green-200',
default => 'border-blue-200',
};
$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-50 text-red-500 border border-red-200',
'orange' => 'bg-orange-50 text-orange-500 border border-orange-200',
'yellow' => 'bg-yellow-50 text-yellow-600 border border-yellow-200',
'green' => 'bg-green-50 text-green-700 border border-green-200',
default => 'bg-blue-50 text-blue-600 border border-blue-200',
};
$badgeBg = match($p['color']) {
'red' => 'bg-red-500',
'orange' => 'bg-orange-400',
'yellow' => 'bg-yellow-400',
'green' => 'bg-green-500',
default => 'bg-blue-500',
};
$namaLower = strtolower($p['nama']);
$icon = '⚙️';
foreach ($iconMap as $key => $val) {
if (str_contains($namaLower, $key)) { $icon = $val; break; }
}
?>
<div class="border <?php echo e($border); ?> rounded-xl p-5 bg-white">
<div class="flex items-start gap-4 mb-3">
<div class="<?php echo e($badgeBg); ?> text-white rounded-full w-8 h-8 flex items-center justify-center font-bold text-sm flex-shrink-0">
<?php echo e($p['rank']); ?>
</li>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</ul>
</div>
<div>
<p class="text-xs font-semibold <?php echo e($title); ?>">
Prioritas <?php echo e($p['rank'] === 1 ? 'Utama' : ($p['rank'] === 2 ? 'Kedua' : 'Ketiga')); ?>
<div class="text-xs <?php echo e($dampak); ?> p-2 rounded">
Dampak: <?php echo e($p['dampak']); ?>
</p>
<h3 class="text-base font-bold text-gray-800"><?php echo e($p['nama']); ?></h3>
</div>
<div class="ml-auto text-3xl"><?php echo e($icon); ?></div>
</div>
<ul class="text-sm space-y-2 mb-4">
<?php $__currentLoopData = $p['actions']; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $action): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
<li class="flex items-start gap-2 text-gray-700">
<span class="text-blue-500 mt-0.5"></span> <?php echo e($action); ?>
</li>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
</ul>
<div class="text-xs <?php echo e($dampak); ?> p-2 rounded-lg">
Alasan: <?php echo e($p['dampak']); ?>
</div>
</div>
</div>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<div class="col-span-3 text-center text-gray-400 text-sm py-6">
Belum ada data rekomendasi. Pastikan analisis sentimen sudah dijalankan.
</div>
<?php endif; ?>
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
<div class="col-span-3 text-center text-gray-400 text-sm py-6">
Belum ada data rekomendasi. Pastikan analisis sentimen sudah dijalankan.
</div>
<?php endif; ?>
</div>
</div>
<div class="bg-blue-50 border border-blue-100 rounded-xl px-5 py-3 flex items-start gap-3 text-sm text-gray-600">
<span class="text-blue-500 mt-0.5"></span>
<span>Rekomendasi dihasilkan secara otomatis berdasarkan hasil analisis sentimen dan kata kunci dominan dari ulasan wisatawan.</span>
</div>
</div><?php /**PATH C:\laragon\www\SistemAnalisisSentimen\resources\views/rekomendasi/index.blade.php ENDPATH**/ ?>

View File

@ -101,12 +101,7 @@ class="bg-blue-600 text-white px-4 rounded h-[46px]">
</form>
<a href="<?php echo e(route('riwayat.index')); ?>"
class="inline-flex items-center justify-center bg-blue-600 hover:bg-blue-700 text-white px-5 rounded-lg shadow transition h-[46px]">
<i class="fas fa-history mr-2"></i>
Lihat Riwayat
</a>
</div>

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.