perubahan
This commit is contained in:
parent
5be35f3851
commit
e622cda9f3
|
|
@ -10,6 +10,7 @@ class AnalisisController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
|
;
|
||||||
$periodeList = DB::table('periode_analisis as p')
|
$periodeList = DB::table('periode_analisis as p')
|
||||||
->whereExists(function ($q) {
|
->whereExists(function ($q) {
|
||||||
$q->select(DB::raw(1))
|
$q->select(DB::raw(1))
|
||||||
|
|
@ -55,7 +56,8 @@ public function index(Request $request)
|
||||||
$query->where('wisata', $request->wisata);
|
$query->where('wisata', $request->wisata);
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasil = $query->orderBy('id', 'desc')->paginate(10);
|
$hasil = $query->orderBy('created_at', 'desc')->paginate(10);
|
||||||
|
|
||||||
$standalone = true;
|
$standalone = true;
|
||||||
|
|
||||||
return view('analisis.index', compact(
|
return view('analisis.index', compact(
|
||||||
|
|
@ -64,7 +66,9 @@ public function index(Request $request)
|
||||||
'evaluasi',
|
'evaluasi',
|
||||||
'standalone',
|
'standalone',
|
||||||
'jumlahKelasAnalisis',
|
'jumlahKelasAnalisis',
|
||||||
'totalHasilAnalisis'
|
'totalHasilAnalisis',
|
||||||
|
'periodeAktif', // ← tambahkan
|
||||||
|
'periodeList', // ← tambahkan
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
class ForgotPasswordController extends Controller
|
class ForgotPasswordController extends Controller
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -66,49 +66,63 @@ public function index(Request $request)
|
||||||
$wisata = $tab === 'analisis' ? $request->wisata : null;
|
$wisata = $tab === 'analisis' ? $request->wisata : null;
|
||||||
$destinasi = $tab === 'rekomendasi' ? $request->destinasi : null;
|
$destinasi = $tab === 'rekomendasi' ? $request->destinasi : null;
|
||||||
|
|
||||||
// ================================================================
|
$currentMonth = now()->month;
|
||||||
// PERIODE BULAN YANG DIPILIH
|
$currentYear = now()->year;
|
||||||
// ================================================================
|
|
||||||
$periodeBulan = $request->input('periode_bulan', now()->format('Y-m'));
|
|
||||||
|
|
||||||
[$tahun, $bulan] = explode('-', $periodeBulan);
|
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// AMBIL SEMUA PERIODE & PERIODE AKTIF
|
// AMBIL SEMUA PERIODE YANG PUNYA DATA
|
||||||
// ================================================================
|
// ================================================================
|
||||||
$periodeList = DB::table('periode_analisis as p')
|
$periodeList = DB::table('periode_analisis as p')
|
||||||
->whereExists(function ($q) {
|
->whereExists(function ($q) {
|
||||||
$q->select(DB::raw(1))
|
$q->select(DB::raw(1))->from('ulasan as u')->whereColumn('u.periode_id', 'p.id');
|
||||||
->from('ulasan as u')
|
|
||||||
->whereColumn('u.periode_id', 'p.id');
|
|
||||||
})
|
})
|
||||||
->orWhereExists(function ($q) {
|
->orWhereExists(function ($q) {
|
||||||
$q->select(DB::raw(1))
|
$q->select(DB::raw(1))->from('hasil_analisis as h')->whereColumn('h.periode_id', 'p.id');
|
||||||
->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.*')
|
->select('p.*')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Cocokkan pakai kolom bulan & tahun
|
// ================================================================
|
||||||
$periodeAktif = $periodeList->first(function ($p) use ($tahun, $bulan) {
|
// TENTUKAN PERIODE AKTIF
|
||||||
return $p->tahun == (int)$tahun && $p->bulan == (int)$bulan;
|
// 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;
|
$noDataPesan = null;
|
||||||
if (!$periodeAktif) {
|
if (!$periodeAktif) {
|
||||||
$namaBulanDipilih = \Carbon\Carbon::createFromDate($tahun, $bulan, 1)
|
$noDataPesan = 'Belum ada data.';
|
||||||
->locale('id')->isoFormat('MMMM YYYY');
|
|
||||||
$noDataPesan = "Tidak ada data untuk bulan {$namaBulanDipilih}.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$periodeId = $periodeAktif->id ?? null;
|
$periodeId = $periodeAktif->id ?? null;
|
||||||
|
|
||||||
|
// ================================================================
|
||||||
|
// EVALUASI MODEL
|
||||||
|
// ================================================================
|
||||||
$evaluasi = DB::table('evaluasi_model')
|
$evaluasi = DB::table('evaluasi_model')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
->latest()->first();
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// TOTAL ULASAN
|
// TOTAL ULASAN
|
||||||
|
|
@ -158,16 +172,6 @@ public function index(Request $request)
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// CHART DATA
|
// 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')
|
$rawSentimen = DB::table('hasil_analisis')
|
||||||
->select('sentimen', DB::raw('count(*) as total'))
|
->select('sentimen', DB::raw('count(*) as total'))
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
|
|
@ -212,7 +216,8 @@ public function index(Request $request)
|
||||||
|
|
||||||
$lastUpdate = DB::table('hasil_analisis')
|
$lastUpdate = DB::table('hasil_analisis')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->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')
|
$destinasiList = DB::table('hasil_analisis')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
|
|
@ -221,26 +226,23 @@ public function index(Request $request)
|
||||||
|
|
||||||
$totalDestinasiAnalisis = DB::table('hasil_analisis')
|
$totalDestinasiAnalisis = DB::table('hasil_analisis')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
->distinct('wisata')
|
->distinct('wisata')->count('wisata');
|
||||||
->count('wisata');
|
|
||||||
|
|
||||||
$totalDestinasiBerkeluhan = DB::table('hasil_analisis')
|
$totalDestinasiBerkeluhan = DB::table('hasil_analisis')
|
||||||
->where('sentimen', 'negatif')
|
->where('sentimen', 'negatif')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
->distinct('wisata')
|
->distinct('wisata')->count('wisata');
|
||||||
->count('wisata');
|
|
||||||
|
|
||||||
$hasil = DB::table('hasil_analisis')
|
$hasil = DB::table('hasil_analisis')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
|
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
|
||||||
->orderByDesc('ulasan_id')
|
->orderBy('created_at', 'desc')
|
||||||
->paginate(10);
|
->paginate(10);
|
||||||
|
|
||||||
$jumlahKelasAnalisis = DB::table('hasil_analisis')
|
$jumlahKelasAnalisis = DB::table('hasil_analisis')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
|
->when($wisata && $wisata != 'Semua Destinasi', fn($q) => $q->where('wisata', $wisata))
|
||||||
->distinct('sentimen')
|
->distinct('sentimen')->count('sentimen');
|
||||||
->count('sentimen');
|
|
||||||
|
|
||||||
$totalHasilAnalisis = DB::table('hasil_analisis')
|
$totalHasilAnalisis = DB::table('hasil_analisis')
|
||||||
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
|
|
@ -347,6 +349,76 @@ public function index(Request $request)
|
||||||
$isuDominan = $isuUtama[0]['nama'] ?? '-';
|
$isuDominan = $isuUtama[0]['nama'] ?? '-';
|
||||||
$isuDominanPersen = $isuUtama[0]['persen'] ?? 0;
|
$isuDominanPersen = $isuUtama[0]['persen'] ?? 0;
|
||||||
|
|
||||||
|
// --- Variabel tambahan untuk tab Rekomendasi ---
|
||||||
|
$netralRek = DB::table('hasil_analisis')
|
||||||
|
->where('sentimen', 'netral')
|
||||||
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
|
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$totalUlasanRek = $positifRek + $negatifRek + $netralRek;
|
||||||
|
$totalPositif = $positifRek;
|
||||||
|
$totalNetral = $netralRek;
|
||||||
|
$persenPositif = $totalUlasanRek > 0 ? round(($positifRek / $totalUlasanRek) * 100) : 0;
|
||||||
|
$persenNetral = $totalUlasanRek > 0 ? round(($netralRek / $totalUlasanRek) * 100) : 0;
|
||||||
|
|
||||||
|
// Isu negatif (sudah ada dari $isuUtama)
|
||||||
|
$isuNegatif = $isuUtama;
|
||||||
|
|
||||||
|
// Isu netral (rule-based dari ulasan netral)
|
||||||
|
$ulasanNetralRek = DB::table('hasil_analisis')
|
||||||
|
->where('sentimen', 'netral')
|
||||||
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
|
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
|
||||||
|
->pluck('ulasan_bersih')
|
||||||
|
->toArray();
|
||||||
|
$textNetral = strtolower(implode(' ', $ulasanNetralRek));
|
||||||
|
$skorNetral = [];
|
||||||
|
foreach ($this->issueRules as $kategori => $rule) {
|
||||||
|
$skor = 0;
|
||||||
|
foreach ($rule['keywords'] as $kw) { $skor += substr_count($textNetral, $kw); }
|
||||||
|
$skorNetral[$kategori] = $skor;
|
||||||
|
}
|
||||||
|
arsort($skorNetral);
|
||||||
|
$totalSkorNetral = array_sum($skorNetral) ?: 1;
|
||||||
|
$isuNetral = [];
|
||||||
|
foreach (array_slice($skorNetral, 0, 5, true) as $nama => $skor) {
|
||||||
|
$isuNetral[] = [
|
||||||
|
'nama' => $nama,
|
||||||
|
'skor' => $skor,
|
||||||
|
'persen' => round(($skor / $totalSkorNetral) * 100),
|
||||||
|
'color' => $this->issueRules[$nama]['color'],
|
||||||
|
'icon' => $this->issueRules[$nama]['icon'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Isu positif (rule-based dari ulasan positif)
|
||||||
|
$ulasanPositifRek = DB::table('hasil_analisis')
|
||||||
|
->where('sentimen', 'positif')
|
||||||
|
->when($periodeId, fn($q) => $q->where('periode_id', $periodeId))
|
||||||
|
->when($destinasi && $destinasi != 'Semua Destinasi', fn($q) => $q->where('wisata', $destinasi))
|
||||||
|
->pluck('ulasan_bersih')
|
||||||
|
->toArray();
|
||||||
|
$textPositif = strtolower(implode(' ', $ulasanPositifRek));
|
||||||
|
$skorPositif = [];
|
||||||
|
foreach ($this->issueRules as $kategori => $rule) {
|
||||||
|
$skor = 0;
|
||||||
|
foreach ($rule['keywords'] as $kw) { $skor += substr_count($textPositif, $kw); }
|
||||||
|
$skorPositif[$kategori] = $skor;
|
||||||
|
}
|
||||||
|
arsort($skorPositif);
|
||||||
|
$totalSkorPositif = array_sum($skorPositif) ?: 1;
|
||||||
|
$isuPositif = [];
|
||||||
|
foreach (array_slice($skorPositif, 0, 5, true) as $nama => $skor) {
|
||||||
|
$isuPositif[] = [
|
||||||
|
'nama' => $nama,
|
||||||
|
'skor' => $skor,
|
||||||
|
'persen' => round(($skor / $totalSkorPositif) * 100),
|
||||||
|
'color' => $this->issueRules[$nama]['color'],
|
||||||
|
'icon' => $this->issueRules[$nama]['icon'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
// ================================================================
|
// ================================================================
|
||||||
// RETURN VIEW
|
// RETURN VIEW
|
||||||
// ================================================================
|
// ================================================================
|
||||||
|
|
@ -368,17 +440,27 @@ public function index(Request $request)
|
||||||
'evaluasi',
|
'evaluasi',
|
||||||
'totalNegatif',
|
'totalNegatif',
|
||||||
'persenNegatif',
|
'persenNegatif',
|
||||||
|
'persenPositif',
|
||||||
|
'persenNetral',
|
||||||
'tingkatKepuasan',
|
'tingkatKepuasan',
|
||||||
'labelKepuasan',
|
'labelKepuasan',
|
||||||
'isuUtama',
|
'isuUtama',
|
||||||
|
'isuNegatif',
|
||||||
|
'isuNetral',
|
||||||
|
'isuPositif',
|
||||||
'isuDominan',
|
'isuDominan',
|
||||||
'isuDominanPersen',
|
'isuDominanPersen',
|
||||||
'kataDominan',
|
'kataDominan',
|
||||||
'saranPerbaikan',
|
'saranPerbaikan',
|
||||||
'prioritas',
|
'prioritas',
|
||||||
|
'periodeId',
|
||||||
|
'totalPositif',
|
||||||
|
'totalNetral',
|
||||||
'periodeList',
|
'periodeList',
|
||||||
'periodeAktif',
|
'periodeAktif',
|
||||||
'noDataPesan', // ← tambahan baru
|
'noDataPesan',
|
||||||
|
'currentMonth',
|
||||||
|
'currentYear',
|
||||||
))->with([
|
))->with([
|
||||||
'scraperDestinations' => $this->scraperDestinations(),
|
'scraperDestinations' => $this->scraperDestinations(),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ public function downloadBulanan($periode)
|
||||||
|
|
||||||
$data['evaluasi'] =
|
$data['evaluasi'] =
|
||||||
DB::table('evaluasi_model')
|
DB::table('evaluasi_model')
|
||||||
|
->where('periode_id', $periode)
|
||||||
->orderByDesc('id')
|
->orderByDesc('id')
|
||||||
->first()
|
->first()
|
||||||
?? (object)['accuracy' => 0];
|
?? (object)['accuracy' => 0];
|
||||||
|
|
@ -158,9 +159,9 @@ public function downloadTahunan(Request $request)
|
||||||
// AKURASI
|
// AKURASI
|
||||||
$akurasi =
|
$akurasi =
|
||||||
DB::table('evaluasi_model')
|
DB::table('evaluasi_model')
|
||||||
->latest('id')
|
->whereIn('periode_id', $periodeIds)
|
||||||
->value('accuracy')
|
->avg('accuracy') ?? 0;
|
||||||
?? 0;
|
|
||||||
|
|
||||||
// TOTAL WISATA
|
// TOTAL WISATA
|
||||||
$totalWisata =
|
$totalWisata =
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,37 @@ public function index(Request $request)
|
||||||
->distinct()
|
->distinct()
|
||||||
->pluck('wisata');
|
->pluck('wisata');
|
||||||
|
|
||||||
// --- 2. Query dengan filter destinasi ---
|
// --- 2. Query dengan filter destinasi & periode ---
|
||||||
$queryAll = HasilAnalisis::query();
|
$queryAll = HasilAnalisis::query();
|
||||||
$queryFilter = 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')) {
|
if ($request->filled('destinasi')) {
|
||||||
$queryFilter->where('wisata', $request->destinasi);
|
$queryFilter->where('wisata', $request->destinasi);
|
||||||
}
|
}
|
||||||
|
|
@ -89,7 +116,19 @@ public function index(Request $request)
|
||||||
$totalNetral = (clone $queryFilter)->where('sentimen', 'netral')->count();
|
$totalNetral = (clone $queryFilter)->where('sentimen', 'netral')->count();
|
||||||
|
|
||||||
$persenNegatif = $totalUlasan > 0 ? round(($totalNegatif / $totalUlasan) * 100, 2) : 0;
|
$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
|
// Label kepuasan
|
||||||
$labelKepuasan = match(true) {
|
$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) ---
|
// --- 7. Isu dominan (ranking 1) ---
|
||||||
// FIX: guard jika tidak ada ulasan negatif sama sekali
|
// FIX: guard jika tidak ada ulasan negatif sama sekali
|
||||||
if (empty($isuUtama) || $isuUtama[0]['skor'] === 0) {
|
if (empty($isuUtama) || $isuUtama[0]['skor'] === 0) {
|
||||||
|
|
@ -210,14 +323,23 @@ public function index(Request $request)
|
||||||
return view('rekomendasi.index', compact(
|
return view('rekomendasi.index', compact(
|
||||||
'destinasiList',
|
'destinasiList',
|
||||||
'destinasiAktif',
|
'destinasiAktif',
|
||||||
|
'periodeId',
|
||||||
'totalUlasan',
|
'totalUlasan',
|
||||||
'totalNegatif',
|
'totalNegatif',
|
||||||
'totalPositif',
|
'totalPositif',
|
||||||
'totalNetral',
|
'totalNetral',
|
||||||
'persenNegatif',
|
'persenNegatif',
|
||||||
|
'persenPositif',
|
||||||
|
'persenNetral',
|
||||||
'tingkatKepuasan',
|
'tingkatKepuasan',
|
||||||
'labelKepuasan',
|
'labelKepuasan',
|
||||||
|
'totalDestinasiAnalisis',
|
||||||
|
'totalDestinasiBerkeluhan',
|
||||||
|
'periodeAktif',
|
||||||
'isuUtama',
|
'isuUtama',
|
||||||
|
'isuNegatif',
|
||||||
|
'isuNetral',
|
||||||
|
'isuPositif',
|
||||||
'isuDominan',
|
'isuDominan',
|
||||||
'isuDominanPersen',
|
'isuDominanPersen',
|
||||||
'kataDominan',
|
'kataDominan',
|
||||||
|
|
|
||||||
|
|
@ -13,79 +13,65 @@ class UlasanController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
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')
|
$periodeList = DB::table('periode_analisis as p')
|
||||||
->whereExists(function ($q) {
|
->whereExists(function ($q) {
|
||||||
$q->select(DB::raw(1))
|
$q->select(DB::raw(1))->from('ulasan as u')->whereColumn('u.periode_id', 'p.id');
|
||||||
->from('ulasan as u')
|
|
||||||
->whereColumn('u.periode_id', 'p.id');
|
|
||||||
})
|
})
|
||||||
->orWhereExists(function ($q) {
|
->orWhereExists(function ($q) {
|
||||||
$q->select(DB::raw(1))
|
$q->select(DB::raw(1))->from('hasil_analisis as h')->whereColumn('h.periode_id', 'p.id');
|
||||||
->from('hasil_analisis as h')
|
|
||||||
->whereColumn('h.periode_id', 'p.id');
|
|
||||||
})
|
})
|
||||||
->orderBy('p.tahun', 'desc')
|
->orderBy('p.tahun', 'desc')
|
||||||
->orderBy('p.bulan', 'desc')
|
->orderBy('p.bulan', 'desc')
|
||||||
->select('p.*')
|
->select('p.*')
|
||||||
->get();
|
->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')
|
if (!$periodeBulanIni) {
|
||||||
? (string) $request->periode_id
|
$periodeId = DB::table('periode_analisis')->insertGetId([
|
||||||
: null;
|
'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
|
// Query ulasan berdasarkan periode aktif
|
||||||
$currentMonth = now()->month;
|
$query = Ulasan::query()->where('periode_id', $periodeId);
|
||||||
$currentYear = now()->year;
|
|
||||||
|
|
||||||
$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')) {
|
if ($request->filled('search')) {
|
||||||
$search = $request->search;
|
$search = $request->search;
|
||||||
|
|
||||||
$query->where(function ($q) use ($search) {
|
$query->where(function ($q) use ($search) {
|
||||||
$q->where('ulasan', 'LIKE', "%$search%")
|
$q->where('ulasan', 'LIKE', "%$search%")
|
||||||
->orWhere('wisata', 'LIKE', "%$search%");
|
->orWhere('wisata', 'LIKE', "%$search%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔍 FILTER WISATA
|
|
||||||
if ($request->filled('wisata')) {
|
if ($request->filled('wisata')) {
|
||||||
$query->where('wisata', 'LIKE', '%' . $request->wisata . '%');
|
$query->where('wisata', 'LIKE', '%' . $request->wisata . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ← default/reset menampilkan periode terbaru
|
|
||||||
if ($periodeId) {
|
|
||||||
$query->where('periode_id', $periodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$filteredQuery = clone $query;
|
$filteredQuery = clone $query;
|
||||||
|
|
||||||
$totalUlasan = (clone $filteredQuery)->count();
|
$totalUlasan = (clone $filteredQuery)->count();
|
||||||
$totalWisata = (clone $filteredQuery)->distinct('wisata')->count('wisata');
|
$totalWisata = (clone $filteredQuery)->distinct('wisata')->count('wisata');
|
||||||
$lastUpdate = (clone $filteredQuery)->latest()->value('created_at');
|
$lastUpdate = (clone $filteredQuery)->latest()->value('created_at');
|
||||||
|
|
@ -100,10 +86,9 @@ public function index(Request $request)
|
||||||
'periodeList',
|
'periodeList',
|
||||||
'periodeId',
|
'periodeId',
|
||||||
'periodeAktif',
|
'periodeAktif',
|
||||||
'defaultPeriodeId'
|
'currentMonth',
|
||||||
))->with([
|
'currentYear'
|
||||||
'scraperDestinations' => $this->scraperDestinations(),
|
))->with(['scraperDestinations' => $this->scraperDestinations()]);
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function riwayat()
|
public function riwayat()
|
||||||
|
|
@ -282,6 +267,11 @@ public function ambilData(Request $request)
|
||||||
->with('error', 'Scraping gagal: ' . $scraping['output']);
|
->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'];
|
$lokasiLabel = empty($validated['wisata']) ? 'semua lokasi' : $validated['wisata'];
|
||||||
$redirectParameters = [
|
$redirectParameters = [
|
||||||
|
|
@ -292,17 +282,15 @@ public function ambilData(Request $request)
|
||||||
$redirectParameters['wisata'] = $validated['wisata'];
|
$redirectParameters['wisata'] = $validated['wisata'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($validated['redirect_to'] ?? 'dashboard') === 'ulasan') {
|
if (($validated['redirect_to'] ?? 'ulasan') === 'ulasan') {
|
||||||
return redirect()->route('ulasan.index', array_filter($redirectParameters))
|
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';
|
$redirectParameters['tab'] = 'analisis';
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('ulasan.index', array_filter($redirectParameters))
|
return redirect()->route('dashboard', array_filter($redirectParameters))
|
||||||
->with('success', 'Scraping berhasil dilakukan untuk ' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '. Silakan jalankan proses analisis.');
|
->with('success', 'Scraping berhasil untuk ' . $lokasiLabel . ' periode ' . $periodeBulan->translatedFormat('F Y') . '. Silakan jalankan proses analisis.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function kosongkanPeriode(Request $request)
|
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',
|
'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
|
// 3. Masukkan $envVars ke parameter ketiga Process
|
||||||
$process = new Process(array_merge([$pythonPath, $scriptPath], $arguments), base_path(), $envVars);
|
$process = new Process(array_merge([$pythonPath, $scriptPath], $arguments), base_path(), $envVars);
|
||||||
$process->setTimeout($timeout);
|
$process->setTimeout($timeout);
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -96,12 +96,7 @@ class="bg-blue-600 text-white px-4 rounded h-[46px]">
|
||||||
|
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
$totalDestinasiAnalisis ??= 0;
|
$totalDestinasiAnalisis ??= 0;
|
||||||
$totalDestinasiBerkeluhan ??= 0;
|
$totalDestinasiBerkeluhan ??= 0;
|
||||||
$totalNegatif ??= 0;
|
$totalNegatif ??= 0;
|
||||||
|
$totalPositif ??= 0;
|
||||||
|
$totalNetral ??= 0;
|
||||||
$totalUlasan ??= 0;
|
$totalUlasan ??= 0;
|
||||||
$persenNegatif ??= 0;
|
$persenNegatif ??= 0;
|
||||||
$tingkatKepuasan ??= 0;
|
$tingkatKepuasan ??= 0;
|
||||||
|
|
@ -15,6 +17,37 @@
|
||||||
$prioritas ??= [];
|
$prioritas ??= [];
|
||||||
@endphp
|
@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">
|
<div class="space-y-6">
|
||||||
|
|
||||||
{{-- HEADER --}}
|
{{-- HEADER --}}
|
||||||
|
|
@ -22,7 +55,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-gray-800">Rekomendasi Layanan</h2>
|
<h2 class="text-2xl font-bold text-gray-800">Rekomendasi Layanan</h2>
|
||||||
<p class="text-sm text-gray-500">
|
<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>
|
||||||
<p class="text-xs text-gray-400 mt-1">
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
{{ $totalDestinasiAnalisis }} destinasi dianalisis, {{ $totalDestinasiBerkeluhan }} destinasi memiliki ulasan negatif.
|
{{ $totalDestinasiAnalisis }} destinasi dianalisis, {{ $totalDestinasiBerkeluhan }} destinasi memiliki ulasan negatif.
|
||||||
|
|
@ -32,7 +65,10 @@
|
||||||
{{-- FILTER DESTINASI — submit ke /dashboard agar tab tidak reset --}}
|
{{-- FILTER DESTINASI — submit ke /dashboard agar tab tidak reset --}}
|
||||||
<form method="GET" action="{{ route('dashboard') }}" class="flex gap-3">
|
<form method="GET" action="{{ route('dashboard') }}" class="flex gap-3">
|
||||||
<input type="hidden" name="tab" value="rekomendasi">
|
<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()"
|
<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">
|
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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- SUMMARY CARDS --}}
|
{{-- SUMMARY CARDS — 3 gauge cards seperti di screenshot --}}
|
||||||
<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">
|
{{-- Tingkat Positif --}}
|
||||||
<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>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Destinasi Dianalisis</p>
|
<p class="text-sm font-semibold text-green-600">Tingkat Positif</p>
|
||||||
<h3 class="text-xl font-bold">{{ $totalDestinasiAnalisis }}</h3>
|
<h3 class="text-4xl font-bold text-green-600 mt-1">{{ $persenPositif }}%</h3>
|
||||||
<p class="text-xs text-gray-500">{{ $totalDestinasiBerkeluhan }} punya keluhan</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
|
{{-- Tingkat Negatif --}}
|
||||||
<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>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Total Ulasan Negatif</p>
|
<p class="text-sm font-semibold text-red-500">Tingkat Negatif</p>
|
||||||
<h3 class="text-xl font-bold">{{ $totalNegatif }}</h3>
|
<h3 class="text-4xl font-bold text-red-500 mt-1">{{ $persenNegatif }}%</h3>
|
||||||
<p class="text-xs text-red-500">{{ $persenNegatif }}% dari total</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
|
{{-- Tingkat Netral --}}
|
||||||
<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>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Tingkat Kepuasan</p>
|
<p class="text-sm font-semibold text-gray-500">Tingkat Netral</p>
|
||||||
<h3 class="text-xl font-bold">{{ $tingkatKepuasan }}%</h3>
|
<h3 class="text-4xl font-bold text-gray-600 mt-1">{{ $persenNetral }}%</h3>
|
||||||
<span class="text-xs px-2 py-1 rounded
|
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-600 font-medium">Netral</span>
|
||||||
{{ $labelKepuasan === 'Baik' ? 'bg-green-100 text-green-700' :
|
<p class="text-xs text-gray-400 mt-2">{{ $totalNetral }} ulasan</p>
|
||||||
($labelKepuasan === 'Sedang' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-red-100 text-red-700') }}">
|
|
||||||
{{ $labelKepuasan }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="gauge-wrap">
|
||||||
|
<svg width="90" height="70" viewBox="0 0 90 70">
|
||||||
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
|
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
|
||||||
<div class="bg-purple-100 text-purple-600 p-3 rounded-full text-xl">❗</div>
|
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#9ca3af" stroke-width="8" stroke-linecap="round"
|
||||||
<div>
|
stroke-dasharray="{{ round($persenNetral * 1.099) }} 110"
|
||||||
<p class="text-sm text-gray-500">Isu Dominan</p>
|
style="transition: stroke-dasharray 0.8s ease"/>
|
||||||
<h3 class="text-xl font-bold">{{ $isuDominan }}</h3>
|
</svg>
|
||||||
<p class="text-xs text-gray-500">{{ $isuDominanPersen }}% dari total isu</p>
|
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😐</div>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<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)
|
@forelse ($isuNegatif as $i => $isu)
|
||||||
@php
|
@if($i >= 5) @break @endif
|
||||||
$warnaClass = match($isu['color']) {
|
|
||||||
'red' => 'bg-red-500',
|
|
||||||
'orange' => 'bg-orange-500',
|
|
||||||
'yellow' => 'bg-yellow-400',
|
|
||||||
'green' => 'bg-green-500',
|
|
||||||
default => 'bg-blue-500',
|
|
||||||
};
|
|
||||||
@endphp
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex justify-between text-sm mb-1">
|
<div class="flex justify-between text-sm mb-1">
|
||||||
<span>{{ $i + 1 }}. {{ $isu['nama'] }}</span>
|
<span class="text-gray-700">{{ $i + 1 }}. {{ $isu['nama'] }}</span>
|
||||||
<span class="font-semibold">{{ $isu['persen'] }}%</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>
|
||||||
<div class="w-full bg-gray-200 h-2 rounded">
|
<div class="w-full bg-gray-200 h-2 rounded">
|
||||||
<div class="h-2 rounded {{ $warnaClass }}" style="width: {{ $isu['persen'] }}%"></div>
|
<div class="h-2 rounded bg-gray-500" style="width: {{ $isu['persen'] }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@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
|
@endforelse
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-400 mt-2">
|
||||||
|
Persentase berdasarkan dari total ulasan netral ({{ $totalNetral }} ulasan)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- KATA KUNCI DOMINAN --}}
|
{{-- ASPEK POSITIF UTAMA --}}
|
||||||
<div class="bg-white rounded-xl shadow p-5">
|
<div class="bg-white rounded-xl shadow p-5">
|
||||||
<h3 class="font-semibold mb-4 text-gray-700">Kata Kunci Dominan</h3>
|
<h3 class="font-semibold mb-4 text-green-600">Aspek Positif Utama (Top 5)</h3>
|
||||||
|
|
||||||
@php $maxFreq = !empty($kataDominan) ? max($kataDominan) : 1; @endphp
|
@forelse ($isuPositif as $i => $isu)
|
||||||
|
@if($i >= 5) @break @endif
|
||||||
@forelse (array_slice($kataDominan, 0, 5, true) as $kata => $jumlah)
|
<div class="mb-4">
|
||||||
<div class="mb-3">
|
<div class="flex justify-between text-sm mb-1">
|
||||||
<div class="flex justify-between text-sm">
|
<span class="text-gray-700">{{ $i + 1 }}. {{ $isu['nama'] }}</span>
|
||||||
<span>{{ $kata }}</span>
|
<span class="font-semibold text-gray-800">
|
||||||
<span class="font-semibold text-blue-600">{{ $jumlah }}x</span>
|
{{ $isu['persen'] }}%
|
||||||
|
@if(isset($isu['jumlah'])) ({{ $isu['jumlah'] }}) @endif
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 h-2 rounded mt-1">
|
<div class="w-full bg-green-100 h-2 rounded">
|
||||||
<div class="bg-blue-500 h-2 rounded" style="width: {{ round(($jumlah / $maxFreq) * 100) }}%"></div>
|
<div class="h-2 rounded bg-green-500" style="width: {{ $isu['persen'] }}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@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
|
@endforelse
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- SARAN PERBAIKAN --}}
|
<p class="text-xs text-green-400 mt-2">
|
||||||
<div class="bg-white rounded-xl shadow p-5">
|
Persentase berdasarkan dari total ulasan positif ({{ $totalPositif }} ulasan)
|
||||||
<h3 class="font-semibold mb-4 text-gray-700">Saran Perbaikan</h3>
|
</p>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- PRIORITAS REKOMENDASI --}}
|
{{-- 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">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
|
||||||
@forelse ($prioritas as $p)
|
@forelse ($prioritas as $p)
|
||||||
@php
|
@php
|
||||||
$border = match($p['color']) {
|
$border = match($p['color']) {
|
||||||
'red' => 'border-red-300',
|
'red' => 'border-red-200',
|
||||||
'orange' => 'border-orange-300',
|
'orange' => 'border-orange-200',
|
||||||
'yellow' => 'border-yellow-300',
|
'yellow' => 'border-yellow-200',
|
||||||
'green' => 'border-green-300',
|
'green' => 'border-green-200',
|
||||||
default => 'border-blue-300',
|
default => 'border-blue-200',
|
||||||
};
|
};
|
||||||
$title = match($p['color']) {
|
$title = match($p['color']) {
|
||||||
'red' => 'text-red-600',
|
'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',
|
default => 'text-blue-600',
|
||||||
};
|
};
|
||||||
$dampak = match($p['color']) {
|
$dampak = match($p['color']) {
|
||||||
'red' => 'bg-red-100 text-red-600',
|
'red' => 'bg-red-50 text-red-500 border border-red-200',
|
||||||
'orange' => 'bg-orange-100 text-orange-600',
|
'orange' => 'bg-orange-50 text-orange-500 border border-orange-200',
|
||||||
'yellow' => 'bg-yellow-100 text-yellow-600',
|
'yellow' => 'bg-yellow-50 text-yellow-600 border border-yellow-200',
|
||||||
'green' => 'bg-green-100 text-green-700',
|
'green' => 'bg-green-50 text-green-700 border border-green-200',
|
||||||
default => 'bg-blue-100 text-blue-600',
|
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
|
@endphp
|
||||||
<div class="border {{ $border }} rounded-xl p-5 bg-white">
|
<div class="border {{ $border }} rounded-xl p-5 bg-white">
|
||||||
<h4 class="font-semibold {{ $title }} mb-2">Prioritas {{ $p['rank'] }}</h4>
|
<div class="flex items-start gap-4 mb-3">
|
||||||
<h3 class="text-lg font-bold mb-3">{{ $p['nama'] }}</h3>
|
<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">
|
<ul class="text-sm space-y-2 mb-4">
|
||||||
@foreach ($p['actions'] as $action)
|
@foreach ($p['actions'] as $action)
|
||||||
<li class="flex items-center gap-2">
|
<li class="flex items-start gap-2 text-gray-700">
|
||||||
<span class="text-blue-500">✔</span> {{ $action }}
|
<span class="text-blue-500 mt-0.5">✔</span> {{ $action }}
|
||||||
</li>
|
</li>
|
||||||
@endforeach
|
@endforeach
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="text-xs {{ $dampak }} p-2 rounded">
|
<div class="text-xs {{ $dampak }} p-2 rounded-lg">
|
||||||
Dampak: {{ $p['dampak'] }}
|
Alasan: {{ $p['dampak'] }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@empty
|
||||||
|
|
@ -214,5 +307,12 @@ class="border rounded-lg pl-3 pr-9 py-2 text-sm min-w-[220px] focus:outline-none
|
||||||
@endforelse
|
@endforelse
|
||||||
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
@ -1,153 +1,157 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
import argparse
|
||||||
analisis.py — SENTARA
|
|
||||||
=====================
|
|
||||||
Sistem Analisis Sentimen Ulasan Wisatawan
|
|
||||||
Metode : Naive Bayes (ComplementNB) + TF-IDF
|
|
||||||
Label : Dari teks ulasan (bukan rating)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sqlite3
|
|
||||||
import sys
|
import sys
|
||||||
import warnings
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import joblib
|
import joblib
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pymysql
|
import pymysql
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
from sklearn.feature_extraction.text import TfidfVectorizer
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
from sklearn.metrics import (
|
from sklearn.metrics import accuracy_score, classification_report
|
||||||
accuracy_score,
|
from sklearn.model_selection import train_test_split
|
||||||
classification_report,
|
|
||||||
f1_score,
|
|
||||||
)
|
|
||||||
from sklearn.model_selection import (
|
|
||||||
GridSearchCV,
|
|
||||||
StratifiedKFold,
|
|
||||||
cross_val_score,
|
|
||||||
train_test_split,
|
|
||||||
)
|
|
||||||
from sklearn.naive_bayes import ComplementNB
|
from sklearn.naive_bayes import ComplementNB
|
||||||
from sklearn.pipeline import Pipeline
|
|
||||||
|
|
||||||
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
|
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
|
||||||
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
|
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
|
||||||
|
|
||||||
warnings.filterwarnings("ignore")
|
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
sys.stderr.reconfigure(encoding="utf-8")
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# KONSTANTA & PATH
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
SEED = 42
|
|
||||||
BASE_DIR = Path(__file__).resolve().parents[1]
|
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):
|
def log(level, message):
|
||||||
print(f"[{level}] {message}", flush=True)
|
print(f"[{level}] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
def fail(message, code=1):
|
def fail(message, code=1):
|
||||||
log("ERROR", message)
|
log("ERROR", message)
|
||||||
sys.exit(code)
|
sys.exit(code)
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# KAMUS NORMALISASI LENGKAP (dari dataset.ipynb)
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
NORMALISASI = {
|
def parse_args():
|
||||||
# Negasi — JANGAN hapus
|
parser = argparse.ArgumentParser(description="Analisis sentimen ulasan untuk satu periode.")
|
||||||
r"\b(ga|gak|gk|nggak|ngga|ngak|engga|enggak)\b": "tidak",
|
parser.add_argument("--periode-id", type=int, help="ID periode yang dianalisis. Default: periode terbaru.")
|
||||||
r"\b(blm|blom|blum)\b": "belum",
|
return parser.parse_args()
|
||||||
r"\b(bkn|bukn)\b": "bukan",
|
|
||||||
r"\b(jgn|jangan|jgan)\b": "jangan",
|
|
||||||
# Kata ganti
|
def read_laravel_env():
|
||||||
r"\b(ak|aq|gw|gue|gua)\b": "aku",
|
env = {}
|
||||||
r"\b(km|loe|lu|elo|lo)\b": "kamu",
|
env_file = BASE_DIR / ".env"
|
||||||
r"\b(sy|sya)\b": "saya",
|
if not env_file.exists():
|
||||||
# Kata kerja
|
return env
|
||||||
r"\b(bs|bsa)\b": "bisa",
|
|
||||||
r"\b(liat|lht)\b": "lihat",
|
for line in env_file.read_text(encoding="utf-8").splitlines():
|
||||||
r"\b(mkn|mkan|maem)\b": "makan",
|
line = line.strip()
|
||||||
r"\b(pake|pk|pke)\b": "pakai",
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
r"\b(tau|taw|tw)\b": "tahu",
|
continue
|
||||||
r"\b(dtg)\b": "datang",
|
key, value = line.split("=", 1)
|
||||||
r"\b(lgsg|lgsung)\b": "langsung",
|
value = value.strip().strip('"').strip("'")
|
||||||
# Kata sifat
|
env.setdefault(key.strip(), value)
|
||||||
r"\b(bgs|bgus|nice|good|top)\b": "bagus",
|
|
||||||
r"\b(josss|joss|sipp|sip)\b": "mantap",
|
return env
|
||||||
r"\b(byk|bnyk)\b": "banyak",
|
|
||||||
r"\b(lbh|lbih)\b": "lebih",
|
|
||||||
r"\b(bener|bnr)\b": "benar",
|
def env_value(env, key, default=""):
|
||||||
# Kata sambung & keterangan
|
value = os.getenv(key, env.get(key, default))
|
||||||
r"\b(aja|sja|ae)\b": "saja",
|
if value in {None, "", "null", "None"}:
|
||||||
r"\b(bgt|bangettt|bangett)\b": "banget",
|
return default
|
||||||
r"\b(br|bru)\b": "baru",
|
return value
|
||||||
r"\b(cmn|cuma|cuman)\b": "cuma",
|
|
||||||
r"\b(dgn|dngn|dg)\b": "dengan",
|
|
||||||
r"\b(dl|dlu)\b": "dulu",
|
def db_config():
|
||||||
r"\b(dlm|dalem)\b": "dalam",
|
env = read_laravel_env()
|
||||||
r"\b(dr|dri)\b": "dari",
|
connection = env_value(env, "DB_CONNECTION", "mysql")
|
||||||
r"\b(emg|emang)\b": "memang",
|
|
||||||
r"\b(gt|gitu|bgitu)\b": "begitu",
|
if connection == "sqlite":
|
||||||
r"\b(hbs|hbis)\b": "habis",
|
database = env_value(env, "DB_DATABASE", str(BASE_DIR / "database" / "database.sqlite"))
|
||||||
r"\b(hrs|hrus)\b": "harus",
|
database_path = Path(database)
|
||||||
r"\b(jd|jdi)\b": "jadi",
|
if not database_path.is_absolute():
|
||||||
r"\b(jg|jga)\b": "juga",
|
database_path = BASE_DIR / database
|
||||||
r"\b(kdg|kdang)\b": "kadang",
|
|
||||||
r"\b(klo|kalo|kl)\b": "kalau",
|
return {
|
||||||
r"\b(krn|karna)\b": "karena",
|
"connection": connection,
|
||||||
r"\b(kyk|kek|kya)\b": "seperti",
|
"database": str(database_path),
|
||||||
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": "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
if connection not in {"mysql", "mariadb"}:
|
||||||
# KAMUS SENTIMEN (pseudo-label fallback)
|
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 = {
|
POSITIF_WORDS = {
|
||||||
"bagus","indah","cantik","keren","mantap","asri","bersih","nyaman","rapi",
|
"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",
|
"licin","curam","sampah","tidak puas","kapok","ogah","zonk","tipu","pungli",
|
||||||
}
|
}
|
||||||
|
|
||||||
NEGASI = {"tidak","bukan","jangan","belum","tanpa","kurang","ga","gak","nggak"}
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
stemmer = StemmerFactory().create_stemmer()
|
||||||
# INISIALISASI NLP
|
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}")
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
def normalize_rating(value):
|
||||||
# PREPROCESSING
|
if pd.isna(value):
|
||||||
# ═══════════════════════════════════════════════════════════════
|
return None
|
||||||
|
match = re.search(r"([1-5])", str(value))
|
||||||
def normalisasi_teks(text):
|
return int(match.group(1)) if match else None
|
||||||
for pattern, replacement in NORMALISASI.items():
|
|
||||||
text = re.sub(pattern, replacement, text)
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
#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):
|
def preprocess_text(text):
|
||||||
"""Pipeline preprocessing 6 tahap."""
|
|
||||||
text = str(text).lower()
|
text = str(text).lower()
|
||||||
text = re.sub(r"https?://\S+|www\.\S+", " ", text) # hapus URL
|
text = re.sub(r"https?://\S+|www\.\S+", " ", text)
|
||||||
text = re.sub(r"@\w+|#\w+", " ", text) # hapus mention/hashtag
|
text = re.sub(r"[^a-z\s]", " ", text)
|
||||||
text = re.sub(r"[^a-z\s]", " ", text) # hapus non-alfabet
|
|
||||||
text = re.sub(r"\s+", " ", text).strip()
|
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()
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
words = [SLANG_MAP.get(word, word) for word in text.split()]
|
||||||
# PSEUDO-LABELING (fallback jika tidak ada model tersimpan)
|
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):
|
def label_by_keyword(clean_text):
|
||||||
tokens = clean_text.split()
|
words = clean_text.split()
|
||||||
pos = neg = 0
|
|
||||||
i = 0
|
positive_score = sum(1 for word in words if word in POSITIF_WORDS)
|
||||||
while i < len(tokens):
|
negative_score = sum(1 for word in words if word in NEGATIF_WORDS)
|
||||||
sebelum_negasi = (i > 0 and tokens[i-1] in NEGASI)
|
|
||||||
w = tokens[i]
|
if positive_score > negative_score:
|
||||||
if w in POSITIF_WORDS:
|
return "positif"
|
||||||
neg += 1 if sebelum_negasi else 0
|
|
||||||
pos += 0 if sebelum_negasi else 1
|
elif negative_score > positive_score:
|
||||||
elif w in NEGATIF_WORDS:
|
return "negatif"
|
||||||
pos += 1 if sebelum_negasi else 0
|
|
||||||
neg += 0 if sebelum_negasi else 1
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
if pos > neg: return "positif"
|
|
||||||
if neg > pos: return "negatif"
|
|
||||||
return "netral"
|
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):
|
def make_pseudo_label(row):
|
||||||
return label_by_keyword(row["ulasan_bersih"])
|
return label_by_keyword(row["ulasan_bersih"])
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
# LOAD / TRAIN MODEL
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
def load_saved_model():
|
def rating_confidence(value):
|
||||||
"""Muat model tersimpan jika ada."""
|
rating = normalize_rating(value)
|
||||||
if MODEL_PATH.exists():
|
if rating is None:
|
||||||
log("INFO", f"Model tersimpan ditemukan → dimuat dari {MODEL_PATH}")
|
|
||||||
return joblib.load(MODEL_PATH)
|
|
||||||
return 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
|
def apply_rating_priority(row, model_classes=None):
|
||||||
y = df["label"].values
|
sentiment_result = row["sentimen"]
|
||||||
|
probability = float(row["probabilitas"])
|
||||||
|
|
||||||
label_counts = pd.Series(y).value_counts()
|
# Jika probabilitas model sangat rendah (di bawah 0.5), baru gunakan rating
|
||||||
log("INFO", "Distribusi label: " + str(label_counts.to_dict()))
|
if probability < 0.5:
|
||||||
|
rating_label = make_pseudo_label(row)
|
||||||
|
return rating_label, 0.5
|
||||||
|
|
||||||
# Minimal 2 kelas dan tiap kelas >= 5
|
return sentiment_result, probability
|
||||||
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
|
|
||||||
|
|
||||||
# Split 80/20
|
# rating_label = make_pseudo_label(row)
|
||||||
can_stratify = label_counts.min() >= 2
|
# rating = normalize_rating(row.get("rating"))
|
||||||
X_train, X_test, y_train, y_test = train_test_split(
|
# if rating is None:
|
||||||
X, y, test_size=0.2, random_state=SEED,
|
# return row["sentimen"], float(row["probabilitas"])
|
||||||
stratify=y if can_stratify else None
|
|
||||||
|
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 ──────────────────────────────
|
return rating_label, probability
|
||||||
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}")
|
|
||||||
|
|
||||||
# ── 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():
|
def main():
|
||||||
import argparse
|
args = parse_args()
|
||||||
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)
|
|
||||||
|
|
||||||
config = db_config()
|
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)
|
engine, raw_conn = make_connections(config)
|
||||||
cursor = raw_conn.cursor()
|
cursor = raw_conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# ── Cari periode ──────────────────────────────────────
|
|
||||||
if args.periode_id:
|
if args.periode_id:
|
||||||
execute(cursor, raw_conn,
|
execute(cursor, raw_conn, "SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1", (args.periode_id,))
|
||||||
"SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1",
|
|
||||||
(args.periode_id,))
|
|
||||||
else:
|
else:
|
||||||
execute(cursor, raw_conn, """
|
execute(cursor, raw_conn, """
|
||||||
SELECT p.id, p.nama FROM periode_analisis p
|
SELECT p.id, p.nama
|
||||||
WHERE EXISTS (SELECT 1 FROM ulasan u WHERE u.periode_id = p.id)
|
FROM periode_analisis p
|
||||||
ORDER BY p.id DESC LIMIT 1
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM ulasan u WHERE u.periode_id = p.id
|
||||||
|
)
|
||||||
|
ORDER BY p.id DESC
|
||||||
|
LIMIT 1
|
||||||
""")
|
""")
|
||||||
periode = cursor.fetchone()
|
periode = cursor.fetchone()
|
||||||
if not periode:
|
if not periode:
|
||||||
fail("Belum ada periode dengan ulasan.")
|
fail("Belum ada periode yang memiliki ulasan. Jalankan Ambil Data terlebih dahulu.")
|
||||||
|
|
||||||
periode_id = periode["id"]
|
periode_id = periode["id"]
|
||||||
periode_nama = periode["nama"]
|
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(
|
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 "
|
"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:
|
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 = df.dropna(subset=["ulasan"]).copy()
|
||||||
df["ulasan"] = df["ulasan"].astype(str)
|
df["ulasan"] = df["ulasan"].astype(str)
|
||||||
df = df[df["ulasan"].str.strip().ne("")]
|
df = df[df["ulasan"].str.strip().ne("")]
|
||||||
|
|
@ -470,82 +342,105 @@ def main():
|
||||||
df = df[df["ulasan"].str.len() > 5]
|
df = df[df["ulasan"].str.len() > 5]
|
||||||
|
|
||||||
if df.empty:
|
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["ulasan_bersih"] = df["ulasan"].apply(preprocess_text)
|
||||||
df = df[df["ulasan_bersih"].str.strip().ne("")].copy()
|
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)
|
df["label"] = df.apply(make_pseudo_label, axis=1)
|
||||||
label_counts = df["label"].value_counts()
|
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 ─────────────────────────────
|
use_model = True
|
||||||
saved_pipeline = load_saved_model()
|
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:
|
can_stratify = label_counts.min() >= 2
|
||||||
# Pakai model tersimpan (dari training manual sebelumnya)
|
if not can_stratify:
|
||||||
log("INFO", "Menggunakan model tersimpan untuk prediksi.")
|
log("WARNING", "Ada kelas dengan jumlah data kurang dari 2. Split evaluasi dibuat tanpa stratify.")
|
||||||
best_pipeline = saved_pipeline
|
|
||||||
# Baca metadata akurasi
|
report = {"weighted avg": {"precision": 0, "recall": 0, "f1-score": 0}}
|
||||||
accuracy = macro_f1 = 0.0
|
accuracy = 0
|
||||||
if METADATA_PATH.exists():
|
|
||||||
with open(METADATA_PATH, encoding="utf-8") as f:
|
if use_model:
|
||||||
meta = json.load(f)
|
X = df["ulasan_bersih"]
|
||||||
accuracy = meta.get("evaluasi_test", {}).get("accuracy", 0)
|
y = df["label"]
|
||||||
macro_f1 = meta.get("evaluasi_test", {}).get("macro_f1", 0)
|
|
||||||
log("INFO", f"Akurasi model tersimpan : {accuracy:.4f}")
|
if len(df) >= 5:
|
||||||
log("INFO", f"Macro F1 model tersimpan: {macro_f1:.4f}")
|
X_train, X_test, y_train, y_test = train_test_split(
|
||||||
report_dict = {"weighted avg": {"precision": accuracy, "recall": accuracy, "f1-score": macro_f1}}
|
X,
|
||||||
|
y,
|
||||||
|
test_size=0.2,
|
||||||
|
random_state=42,
|
||||||
|
stratify=y if can_stratify else None,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Tidak ada model → train dari pseudo-label
|
log("WARNING", "Data kurang dari 5 baris. Evaluasi memakai data latih yang sama.")
|
||||||
log("WARNING",
|
X_train, X_test, y_train, y_test = X, X, y, y
|
||||||
"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)
|
|
||||||
|
|
||||||
if best_pipeline is None:
|
vectorizer = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
|
||||||
# Fallback: langsung pakai pseudo-label
|
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["sentimen"] = df["label"]
|
||||||
df["probabilitas"] = 0.60
|
df["probabilitas"] = df["rating"].apply(lambda rating: rating_confidence(rating) or 0.7)
|
||||||
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}}
|
|
||||||
|
|
||||||
# ── Prediksi ──────────────────────────────────────────
|
log("INFO", "Evaluasi memakai pseudo-label dari rating/rule otomatis, bukan label manual.")
|
||||||
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)
|
|
||||||
|
|
||||||
# Post-processing: probabilitas rendah → fallback kamus
|
# execute(cursor, raw_conn, "DELETE FROM hasil_analisis WHERE periode_id = %s", (periode_id,))
|
||||||
mask_low = df["probabilitas"] < 0.45
|
# execute(cursor, raw_conn, "DELETE FROM evaluasi_model WHERE periode_id = %s", (periode_id,))
|
||||||
if mask_low.sum() > 0:
|
|
||||||
log("INFO", f"{mask_low.sum()} ulasan probabilitas rendah → fallback ke kamus")
|
|
||||||
df.loc[mask_low, "sentimen"] = df.loc[mask_low, "ulasan_bersih"].apply(label_by_keyword)
|
|
||||||
df.loc[mask_low, "probabilitas"] = 0.50
|
|
||||||
|
|
||||||
dist = df["sentimen"].value_counts().to_dict()
|
|
||||||
log("INFO", f"Distribusi sentimen hasil: {dist}")
|
|
||||||
|
|
||||||
# ── Simpan ke hasil_analisis ──────────────────────────
|
|
||||||
hasil_columns = table_columns(cursor, raw_conn, "hasil_analisis")
|
hasil_columns = table_columns(cursor, raw_conn, "hasil_analisis")
|
||||||
insert_columns = [
|
insert_columns = [
|
||||||
"ulasan_id","wisata","ulasan_asli","ulasan_bersih",
|
"ulasan_id",
|
||||||
"hasil_preprocessing","sentimen","probabilitas",
|
"wisata",
|
||||||
"periode_id","created_at","updated_at",
|
"ulasan_asli",
|
||||||
|
"ulasan_bersih",
|
||||||
|
"hasil_preprocessing",
|
||||||
|
"sentimen",
|
||||||
|
"probabilitas",
|
||||||
|
"periode_id",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
]
|
]
|
||||||
if "ulasan_terolah" in hasil_columns:
|
if "ulasan_terolah" in hasil_columns:
|
||||||
insert_columns.insert(3, "ulasan_terolah")
|
insert_columns.insert(3, "ulasan_terolah")
|
||||||
|
|
||||||
placeholders = ", ".join(["%s"] * (len(insert_columns) - 2) + ["NOW()", "NOW()"])
|
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():
|
for _, row in df.fillna("").iterrows():
|
||||||
values = [
|
values = [
|
||||||
|
|
@ -560,24 +455,33 @@ def main():
|
||||||
]
|
]
|
||||||
if "ulasan_terolah" in hasil_columns:
|
if "ulasan_terolah" in hasil_columns:
|
||||||
values.insert(3, str(row["ulasan_bersih"]))
|
values.insert(3, str(row["ulasan_bersih"]))
|
||||||
execute(cursor, raw_conn, insert_sql, tuple(values))
|
|
||||||
|
|
||||||
# ── Simpan ke evaluasi_model ──────────────────────────
|
execute(cursor, raw_conn, insert_hasil, tuple(values))
|
||||||
weighted = report_dict.get("weighted avg", {})
|
|
||||||
execute(cursor, raw_conn, """
|
weighted = report.get("weighted avg", {})
|
||||||
|
execute(
|
||||||
|
cursor,
|
||||||
|
raw_conn,
|
||||||
|
"""
|
||||||
INSERT INTO evaluasi_model
|
INSERT INTO evaluasi_model
|
||||||
(`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id, created_at, updated_at)
|
(`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())
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
|
||||||
""", (
|
""",
|
||||||
|
(
|
||||||
float(weighted.get("precision", 0)),
|
float(weighted.get("precision", 0)),
|
||||||
float(weighted.get("recall", 0)),
|
float(weighted.get("recall", 0)),
|
||||||
float(weighted.get("f1-score", macro_f1)),
|
float(weighted.get("f1-score", 0)),
|
||||||
float(accuracy),
|
float(accuracy),
|
||||||
0, 0, 0, 0, periode_id,
|
0,
|
||||||
))
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
periode_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
raw_conn.commit()
|
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.")
|
log("OK", "Analisis selesai.")
|
||||||
|
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
@ -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!")
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import hashlib
|
||||||
|
|
||||||
from networkx import config
|
from networkx import config
|
||||||
import undetected_chromedriver as uc
|
import undetected_chromedriver as uc
|
||||||
|
|
|
||||||
|
|
@ -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**/ ?>
|
|
||||||
|
|
@ -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**/ ?>
|
||||||
|
|
@ -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 | <?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**/ ?>
|
||||||
|
|
@ -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**/ ?>
|
|
||||||
|
|
@ -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**/ ?>
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
$totalDestinasiAnalisis ??= 0;
|
$totalDestinasiAnalisis ??= 0;
|
||||||
$totalDestinasiBerkeluhan ??= 0;
|
$totalDestinasiBerkeluhan ??= 0;
|
||||||
$totalNegatif ??= 0;
|
$totalNegatif ??= 0;
|
||||||
|
$totalPositif ??= 0;
|
||||||
|
$totalNetral ??= 0;
|
||||||
$totalUlasan ??= 0;
|
$totalUlasan ??= 0;
|
||||||
$persenNegatif ??= 0;
|
$persenNegatif ??= 0;
|
||||||
$tingkatKepuasan ??= 0;
|
$tingkatKepuasan ??= 0;
|
||||||
|
|
@ -15,6 +17,37 @@
|
||||||
$prioritas ??= [];
|
$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">
|
<div class="space-y-6">
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,7 +55,7 @@
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-gray-800">Rekomendasi Layanan</h2>
|
<h2 class="text-2xl font-bold text-gray-800">Rekomendasi Layanan</h2>
|
||||||
<p class="text-sm text-gray-500">
|
<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>
|
||||||
<p class="text-xs text-gray-400 mt-1">
|
<p class="text-xs text-gray-400 mt-1">
|
||||||
<?php echo e($totalDestinasiAnalisis); ?> destinasi dianalisis, <?php echo e($totalDestinasiBerkeluhan); ?> destinasi memiliki ulasan negatif.
|
<?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">
|
<form method="GET" action="<?php echo e(route('dashboard')); ?>" class="flex gap-3">
|
||||||
<input type="hidden" name="tab" value="rekomendasi">
|
<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()"
|
<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">
|
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>
|
<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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Destinasi Dianalisis</p>
|
<p class="text-sm font-semibold text-green-600">Tingkat Positif</p>
|
||||||
<h3 class="text-xl font-bold"><?php echo e($totalDestinasiAnalisis); ?></h3>
|
<h3 class="text-4xl font-bold text-green-600 mt-1"><?php echo e($persenPositif); ?>%</h3>
|
||||||
<p class="text-xs text-gray-500"><?php echo e($totalDestinasiBerkeluhan); ?> punya keluhan</p>
|
<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>
|
</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>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Total Ulasan Negatif</p>
|
<p class="text-sm font-semibold text-red-500">Tingkat Negatif</p>
|
||||||
<h3 class="text-xl font-bold"><?php echo e($totalNegatif); ?></h3>
|
<h3 class="text-4xl font-bold text-red-500 mt-1"><?php echo e($persenNegatif); ?>%</h3>
|
||||||
<p class="text-xs text-red-500"><?php echo e($persenNegatif); ?>% dari total</p>
|
<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>
|
</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>
|
<div>
|
||||||
<p class="text-sm text-gray-500">Tingkat Kepuasan</p>
|
<p class="text-sm font-semibold text-gray-500">Tingkat Netral</p>
|
||||||
<h3 class="text-xl font-bold"><?php echo e($tingkatKepuasan); ?>%</h3>
|
<h3 class="text-4xl font-bold text-gray-600 mt-1"><?php echo e($persenNetral); ?>%</h3>
|
||||||
<span class="text-xs px-2 py-1 rounded
|
<span class="inline-block mt-2 text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-600 font-medium">Netral</span>
|
||||||
<?php echo e($labelKepuasan === 'Baik' ? 'bg-green-100 text-green-700' :
|
<p class="text-xs text-gray-400 mt-2"><?php echo e($totalNetral); ?> ulasan</p>
|
||||||
($labelKepuasan === 'Sedang' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
'bg-red-100 text-red-700')); ?>">
|
|
||||||
<?php echo e($labelKepuasan); ?>
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="gauge-wrap">
|
||||||
|
<svg width="90" height="70" viewBox="0 0 90 70">
|
||||||
<div class="bg-white rounded-xl shadow p-5 flex items-center gap-4">
|
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#e5e7eb" stroke-width="8" stroke-linecap="round"/>
|
||||||
<div class="bg-purple-100 text-purple-600 p-3 rounded-full text-xl">❗</div>
|
<path d="M 10 65 A 35 35 0 0 1 80 65" fill="none" stroke="#9ca3af" stroke-width="8" stroke-linecap="round"
|
||||||
<div>
|
stroke-dasharray="<?php echo e(round($persenNetral * 1.099)); ?> 110"
|
||||||
<p class="text-sm text-gray-500">Isu Dominan</p>
|
style="transition: stroke-dasharray 0.8s ease"/>
|
||||||
<h3 class="text-xl font-bold"><?php echo e($isuDominan); ?></h3>
|
</svg>
|
||||||
<p class="text-xs text-gray-500"><?php echo e($isuDominanPersen); ?>% dari total isu</p>
|
<div style="position:absolute;bottom:6px;left:50%;transform:translateX(-50%);font-size:1.5rem;">😐</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow p-5">
|
<div class="bg-white rounded-xl shadow p-5">
|
||||||
<h3 class="font-semibold mb-4 text-gray-700">Isu Utama (Top 5)</h3>
|
<h3 class="font-semibold mb-4 text-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 $__empty_1 = true; $__currentLoopData = $isuNegatif; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $i => $isu): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
|
||||||
<?php
|
<?php if($i >= 5): ?> <?php break; ?> <?php endif; ?>
|
||||||
$warnaClass = match($isu['color']) {
|
|
||||||
'red' => 'bg-red-500',
|
|
||||||
'orange' => 'bg-orange-500',
|
|
||||||
'yellow' => 'bg-yellow-400',
|
|
||||||
'green' => 'bg-green-500',
|
|
||||||
default => 'bg-blue-500',
|
|
||||||
};
|
|
||||||
?>
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="flex justify-between text-sm mb-1">
|
<div class="flex justify-between text-sm mb-1">
|
||||||
<span><?php echo e($i + 1); ?>. <?php echo e($isu['nama']); ?></span>
|
<span class="text-gray-700"><?php echo e($i + 1); ?>. <?php echo e($isu['nama']); ?></span>
|
||||||
<span class="font-semibold"><?php echo e($isu['persen']); ?>%</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>
|
||||||
<div class="w-full bg-gray-200 h-2 rounded">
|
<div class="w-full bg-gray-200 h-2 rounded">
|
||||||
<div class="h-2 rounded <?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>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
|
<?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; ?>
|
<?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>
|
||||||
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow p-5">
|
<div class="bg-white rounded-xl shadow p-5">
|
||||||
<h3 class="font-semibold mb-4 text-gray-700">Kata Kunci Dominan</h3>
|
<h3 class="font-semibold mb-4 text-green-600">Aspek Positif Utama (Top 5)</h3>
|
||||||
|
|
||||||
<?php $maxFreq = !empty($kataDominan) ? max($kataDominan) : 1; ?>
|
<?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; ?>
|
||||||
<?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-4">
|
||||||
<div class="mb-3">
|
<div class="flex justify-between text-sm mb-1">
|
||||||
<div class="flex justify-between text-sm">
|
<span class="text-gray-700"><?php echo e($i + 1); ?>. <?php echo e($isu['nama']); ?></span>
|
||||||
<span><?php echo e($kata); ?></span>
|
<span class="font-semibold text-gray-800">
|
||||||
<span class="font-semibold text-blue-600"><?php echo e($jumlah); ?>x</span>
|
<?php echo e($isu['persen']); ?>%
|
||||||
|
<?php if(isset($isu['jumlah'])): ?> (<?php echo e($isu['jumlah']); ?>) <?php endif; ?>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 h-2 rounded mt-1">
|
<div class="w-full bg-green-100 h-2 rounded">
|
||||||
<div class="bg-blue-500 h-2 rounded" style="width: <?php echo e(round(($jumlah / $maxFreq) * 100)); ?>%"></div>
|
<div class="h-2 rounded bg-green-500" style="width: <?php echo e($isu['persen']); ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); if ($__empty_1): ?>
|
<?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; ?>
|
<?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>
|
||||||
|
|
||||||
|
|
||||||
<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>
|
<div>
|
||||||
<p class="font-semibold"><?php echo e($saran['nama']); ?></p>
|
<h3 class="text-lg font-bold text-gray-800 mb-1">Prioritas Rekomendasi Perbaikan Layanan</h3>
|
||||||
<p class="text-gray-600"><?php echo e($saran['tip']); ?></p>
|
<p class="text-sm text-gray-500 mb-4">Prioritas disusun berdasarkan frekuensi isu negatif dan dampaknya terhadap kepuasan pengunjung.</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>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<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 $__empty_1 = true; $__currentLoopData = $prioritas; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $p): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); $__empty_1 = false; ?>
|
||||||
<?php
|
<?php
|
||||||
$border = match($p['color']) {
|
$border = match($p['color']) {
|
||||||
'red' => 'border-red-300',
|
'red' => 'border-red-200',
|
||||||
'orange' => 'border-orange-300',
|
'orange' => 'border-orange-200',
|
||||||
'yellow' => 'border-yellow-300',
|
'yellow' => 'border-yellow-200',
|
||||||
'green' => 'border-green-300',
|
'green' => 'border-green-200',
|
||||||
default => 'border-blue-300',
|
default => 'border-blue-200',
|
||||||
};
|
};
|
||||||
$title = match($p['color']) {
|
$title = match($p['color']) {
|
||||||
'red' => 'text-red-600',
|
'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',
|
default => 'text-blue-600',
|
||||||
};
|
};
|
||||||
$dampak = match($p['color']) {
|
$dampak = match($p['color']) {
|
||||||
'red' => 'bg-red-100 text-red-600',
|
'red' => 'bg-red-50 text-red-500 border border-red-200',
|
||||||
'orange' => 'bg-orange-100 text-orange-600',
|
'orange' => 'bg-orange-50 text-orange-500 border border-orange-200',
|
||||||
'yellow' => 'bg-yellow-100 text-yellow-600',
|
'yellow' => 'bg-yellow-50 text-yellow-600 border border-yellow-200',
|
||||||
'green' => 'bg-green-100 text-green-700',
|
'green' => 'bg-green-50 text-green-700 border border-green-200',
|
||||||
default => 'bg-blue-100 text-blue-600',
|
default => 'bg-blue-50 text-blue-600 border border-blue-200',
|
||||||
};
|
};
|
||||||
|
$badgeBg = match($p['color']) {
|
||||||
|
'red' => 'bg-red-500',
|
||||||
|
'orange' => 'bg-orange-400',
|
||||||
|
'yellow' => 'bg-yellow-400',
|
||||||
|
'green' => 'bg-green-500',
|
||||||
|
default => 'bg-blue-500',
|
||||||
|
};
|
||||||
|
$namaLower = strtolower($p['nama']);
|
||||||
|
$icon = '⚙️';
|
||||||
|
foreach ($iconMap as $key => $val) {
|
||||||
|
if (str_contains($namaLower, $key)) { $icon = $val; break; }
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<div class="border <?php echo e($border); ?> rounded-xl p-5 bg-white">
|
<div class="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>
|
<div class="flex items-start gap-4 mb-3">
|
||||||
<h3 class="text-lg font-bold mb-3"><?php echo e($p['nama']); ?></h3>
|
<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">
|
<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(); ?>
|
<?php $__currentLoopData = $p['actions']; $__env->addLoop($__currentLoopData); foreach($__currentLoopData as $action): $__env->incrementLoopIndices(); $loop = $__env->getLastLoop(); ?>
|
||||||
<li class="flex items-center gap-2">
|
<li class="flex items-start gap-2 text-gray-700">
|
||||||
<span class="text-blue-500">✔</span> <?php echo e($action); ?>
|
<span class="text-blue-500 mt-0.5">✔</span> <?php echo e($action); ?>
|
||||||
|
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
|
<?php endforeach; $__env->popLoop(); $loop = $__env->getLastLoop(); ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="text-xs <?php echo e($dampak); ?> p-2 rounded">
|
<div class="text-xs <?php echo e($dampak); ?> p-2 rounded-lg">
|
||||||
Dampak: <?php echo e($p['dampak']); ?>
|
Alasan: <?php echo e($p['dampak']); ?>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
</div>
|
</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**/ ?>
|
</div><?php /**PATH C:\laragon\www\SistemAnalisisSentimen\resources\views/rekomendasi/index.blade.php ENDPATH**/ ?>
|
||||||
|
|
@ -101,12 +101,7 @@ class="bg-blue-600 text-white px-4 rounded h-[46px]">
|
||||||
|
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue