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

@ -66,49 +66,63 @@ public function index(Request $request)
$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'));
[$tahun, $bulan] = explode('-', $periodeBulan);
$currentMonth = now()->month;
$currentYear = now()->year;
// ================================================================
// AMBIL SEMUA PERIODE & PERIODE AKTIF
// 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');
$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');
$q->select(DB::raw(1))->from('hasil_analisis as h')->whereColumn('h.periode_id', 'p.id');
})
->orderBy('p.id', 'desc')
->orderBy('p.tahun', 'desc')
->orderBy('p.bulan', 'desc')
->select('p.*')
->get();
// Cocokkan pakai kolom bulan & tahun
$periodeAktif = $periodeList->first(function ($p) use ($tahun, $bulan) {
return $p->tahun == (int)$tahun && $p->bulan == (int)$bulan;
});
// ================================================================
// 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
}
}
// 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}.";
$noDataPesan = 'Belum ada data.';
}
$periodeId = $periodeAktif->id ?? null;
// ================================================================
// EVALUASI MODEL
// ================================================================
$evaluasi = DB::table('evaluasi_model')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest()->first();
->latest()
->first();
// ================================================================
// TOTAL ULASAN
@ -158,16 +172,6 @@ public function index(Request $request)
// ================================================================
// 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))
@ -212,7 +216,8 @@ public function index(Request $request)
$lastUpdate = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->latest('created_at')->value('created_at');
->latest('created_at')
->value('created_at');
$destinasiList = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
@ -221,26 +226,23 @@ public function index(Request $request)
$totalDestinasiAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
->distinct('wisata')
->count('wisata');
->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');
->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))
->orderByDesc('ulasan_id')
->orderBy('created_at', 'desc')
->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');
->distinct('sentimen')->count('sentimen');
$totalHasilAnalisis = DB::table('hasil_analisis')
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
@ -347,6 +349,76 @@ public function index(Request $request)
$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
// ================================================================
@ -368,17 +440,27 @@ public function index(Request $request)
'evaluasi',
'totalNegatif',
'persenNegatif',
'persenPositif',
'persenNetral',
'tingkatKepuasan',
'labelKepuasan',
'isuUtama',
'isuNegatif',
'isuNetral',
'isuPositif',
'isuDominan',
'isuDominanPersen',
'kataDominan',
'saranPerbaikan',
'prioritas',
'periodeId',
'totalPositif',
'totalNetral',
'periodeList',
'periodeAktif',
'noDataPesan', // ← tambahan baru
'noDataPesan',
'currentMonth',
'currentYear',
))->with([
'scraperDestinations' => $this->scraperDestinations(),
]);

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,9 +159,9 @@ 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 =

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

@ -13,79 +13,65 @@ class UlasanController extends Controller
{
public function index(Request $request)
{
$currentMonth = now()->month;
$currentYear = now()->year;
// 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');
$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');
$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();
$availablePeriodeIds = $periodeList->pluck('id')->map(fn($id) => (string) $id);
// 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();
$requestedPeriodeId = $request->filled('periode_id')
? (string) $request->periode_id
: null;
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;
}
}
// cari periode bulan & tahun sekarang
$currentMonth = now()->month;
$currentYear = now()->year;
// Query ulasan berdasarkan periode aktif
$query = Ulasan::query()->where('periode_id', $periodeId);
$currentPeriode = $periodeList->first(function ($periode) use ($currentMonth, $currentYear) {
return $periode->bulan == $currentMonth
&& $periode->tahun == $currentYear;
});
// kalau bulan sekarang tidak ada → ambil periode terakhir
$defaultPeriodeId = $currentPeriode->id
?? $periodeList->sortByDesc('id')->first()->id
?? null;
// prioritas:
// 1. request manual dari dropdown
// 2. periode bulan sekarang
// 3. periode terakhir
$periodeId = $request->filled('periode_id')
&& $availablePeriodeIds->contains($requestedPeriodeId)
? $requestedPeriodeId
: $defaultPeriodeId;
$periodeAktif = $periodeId
? $periodeList->firstWhere('id', (int) $periodeId)
: null;
$query = Ulasan::query();
// 🔍 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%");
});
}
// 🔍 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');
@ -100,10 +86,9 @@ public function index(Request $request)
'periodeList',
'periodeId',
'periodeAktif',
'defaultPeriodeId'
))->with([
'scraperDestinations' => $this->scraperDestinations(),
]);
'currentMonth',
'currentYear'
))->with(['scraperDestinations' => $this->scraperDestinations()]);
}
public function riwayat()
@ -282,6 +267,11 @@ public function ambilData(Request $request)
->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 = [
@ -292,17 +282,15 @@ public function ambilData(Request $request)
$redirectParameters['wisata'] = $validated['wisata'];
}
if (($validated['redirect_to'] ?? 'dashboard') === 'ulasan') {
if (($validated['redirect_to'] ?? 'ulasan') === 'ulasan') {
return redirect()->route('ulasan.index', array_filter($redirectParameters))
->with('success', 'Data berhasil diambil' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '.');
->with('success', 'Data berhasil diambil untuk ' . $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.');
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)
@ -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,136 +81,171 @@ 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>
<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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@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',
'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',
@ -184,27 +255,49 @@ class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none
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',
'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">
<h4 class="font-semibold {{ $title }} mb-2">Prioritas {{ $p['rank'] }}</h4>
<h3 class="text-lg font-bold mb-3">{{ $p['nama'] }}</h3>
<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>
<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 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">
Dampak: {{ $p['dampak'] }}
<div class="text-xs {{ $dampak }} p-2 rounded-lg">
Alasan: {{ $p['dampak'] }}
</div>
</div>
@empty
@ -214,5 +307,12 @@ class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none
@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,153 +1,157 @@
# -*- 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.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
from sklearn.naive_bayes import ComplementNB
from sklearn.pipeline import Pipeline
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),
}
# ═══════════════════════════════════════════════════════════════
# KAMUS SENTIMEN (pseudo-label fallback)
# ═══════════════════════════════════════════════════════════════
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","cantik","keren","mantap","asri","bersih","nyaman","rapi",
@ -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)
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
# 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
return sentiment_result, probability
# 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
# 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}",
)
log("INFO", f"Split — Train: {len(X_train)} | Test: {len(X_test)}")
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()
log("INFO", "=" * 55)
log("INFO", "SENTARA — Sistem Analisis Sentimen Wisata Jember")
log("INFO", "=" * 55)
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:
# ── 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_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}}
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:
# 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)
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
if best_pipeline is None:
# Fallback: langsung pakai pseudo-label
vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_train_vec = vectorizer.fit_transform(X_train)
X_test_vec = vectorizer.transform(X_test)
#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"] = 0.60
accuracy = macro_f1 = 0.0
report_dict = {"weighted avg": {"precision": 0, "recall": 0, "f1-score": 0}}
else:
report_dict = {"weighted avg": {"precision": accuracy, "recall": accuracy, "f1-score": macro_f1}}
df["probabilitas"] = df["rating"].apply(lambda rating: rating_confidence(rating) or 0.7)
# ── 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)
log("INFO", "Evaluasi memakai pseudo-label dari rating/rule otomatis, bukan label manual.")
# 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
# 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,))
dist = df["sentimen"].value_counts().to_dict()
log("INFO", f"Distribusi sentimen hasil: {dist}")
# ── 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(weighted.get("f1-score", 0)),
float(accuracy),
0, 0, 0, 0, periode_id,
))
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,136 +83,170 @@ 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; ?>
<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="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>
</div>
</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>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<?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',
'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',
@ -186,28 +256,52 @@ class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none
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',
'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">
<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="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']); ?>
</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')); ?>
</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-center gap-2">
<span class="text-blue-500"></span> <?php echo e($action); ?>
<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">
Dampak: <?php echo e($p['dampak']); ?>
<div class="text-xs <?php echo e($dampak); ?> p-2 rounded-lg">
Alasan: <?php echo e($p['dampak']); ?>
</div>
</div>
@ -218,5 +312,12 @@ class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none
<?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.