update revisi

This commit is contained in:
WahyuTegarP 2026-06-18 10:10:52 +07:00
parent 920e6caf3d
commit 9e1a910c2f
10 changed files with 1444 additions and 109 deletions

View File

@ -76,10 +76,12 @@ public function prosesDiagnosis(Request $request)
]); ]);
} }
return redirect()->route('hasil-diagnosis') session([
->with('diagnosis', $diagnosis) 'diagnosis' => $diagnosis,
->with('gejala', $inputNama); 'gejala' => $inputNama,
$biodataId = session('biodata_id'); ]);
return redirect()->route('hasil-diagnosis');
} }
// 🔥 halaman hasil // 🔥 halaman hasil
@ -137,6 +139,7 @@ public function simpanBiodata(Request $request)
'umur_kucing' => 'required|numeric', 'umur_kucing' => 'required|numeric',
'jenis_kelamin' => 'required', 'jenis_kelamin' => 'required',
'berat_badan' => 'required|numeric', 'berat_badan' => 'required|numeric',
'alamat' => 'required|in:Ajung,Ambulu,Arjasa,Balung,Bangsalsari,Gumukmas,Jelbuk,Jenggawah,Jombang,Kalisat,Kaliwates,Kencong,Ledokombo,Mayang,Mumbulsari,Pakusari,Panti,Patrang,Puger,Rambipuji,Semboro,Silo,Sukorambi,Sukowono,Sumberbaru,Sumberjambe,Sumbersari,Tanggul,Tempurejo,Umbulsari,Wuluhan',
]); ]);
$data = \App\Models\Biodata::create([ $data = \App\Models\Biodata::create([

View File

@ -0,0 +1,239 @@
<?php
namespace App\Http\Controllers;
use App\Models\Biodata;
use App\Models\Ulasan;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use ZipArchive;
class LandingController extends Controller
{
public function index(Request $request)
{
$ulasan = Schema::hasTable('ulasans')
? Ulasan::where('is_hidden', false)->latest()->take(3)->get()
: collect();
$range = $request->query('range') === 'week' ? 'week' : 'month';
return view('landing', [
'ulasan' => $ulasan,
'diseaseNews' => $this->buildDiseaseNews($range),
]);
}
private function buildDiseaseNews(string $range): array
{
$startDate = $range === 'week'
? Carbon::now()->subDays(6)->startOfDay()
: Carbon::now()->subDays(29)->startOfDay();
$periodLabel = $range === 'week' ? '7 hari terakhir' : '30 hari terakhir';
if (!Schema::hasTable('biodata')) {
return $this->emptyDiseaseNews($range, $periodLabel, $startDate);
}
$diseaseStats = Biodata::query()
->select('hasil_diagnosis', DB::raw('COUNT(*) as total'))
->whereNotNull('hasil_diagnosis')
->where('hasil_diagnosis', '!=', '')
->where('created_at', '>=', $startDate)
->groupBy('hasil_diagnosis')
->orderByDesc('total')
->limit(3)
->get();
$topDisease = trim((string) ($diseaseStats->first()->hasil_diagnosis ?? ''));
$areaStats = collect();
$areaStatsByDisease = [];
if ($topDisease !== '') {
$areaStats = Biodata::query()
->where('hasil_diagnosis', $topDisease)
->where('created_at', '>=', $startDate)
->get(['alamat'])
->map(fn ($item) => $this->extractArea((string) ($item->alamat ?? '')))
->filter()
->countBy()
->sortDesc()
->take(6);
}
foreach ($diseaseStats as $row) {
$disease = trim((string) $row->hasil_diagnosis);
if ($disease === '') {
continue;
}
$areas = Biodata::query()
->where('hasil_diagnosis', $disease)
->where('created_at', '>=', $startDate)
->get(['alamat'])
->map(fn ($item) => $this->extractArea((string) ($item->alamat ?? '')))
->filter()
->countBy()
->sortDesc()
->take(6);
$areaStatsByDisease[$disease] = [
'labels' => $areas->keys()->values(),
'data' => $areas->values()->map(fn ($n) => (int) $n)->values(),
];
}
$knowledge = $this->getDiseaseKnowledge($topDisease);
return [
'range' => $range,
'period_label' => $periodLabel,
'start_label' => $this->formatDateLabel($startDate),
'end_label' => $this->formatDateLabel(Carbon::now()),
'top_disease' => $topDisease,
'total_cases' => (int) ($diseaseStats->first()->total ?? 0),
'disease_labels' => $diseaseStats->pluck('hasil_diagnosis')->map(fn ($name) => trim((string) $name))->values(),
'disease_data' => $diseaseStats->pluck('total')->map(fn ($n) => (int) $n)->values(),
'area_labels' => $areaStats->keys()->values(),
'area_data' => $areaStats->values()->map(fn ($n) => (int) $n)->values(),
'area_by_disease' => $areaStatsByDisease,
'handling' => $knowledge['pertolongan'] ?? [],
'prevention' => $knowledge['pencegahan'] ?? [],
];
}
private function emptyDiseaseNews(string $range, string $periodLabel, Carbon $startDate): array
{
return [
'range' => $range,
'period_label' => $periodLabel,
'start_label' => $this->formatDateLabel($startDate),
'end_label' => $this->formatDateLabel(Carbon::now()),
'top_disease' => '',
'total_cases' => 0,
'disease_labels' => collect(),
'disease_data' => collect(),
'area_labels' => collect(),
'area_data' => collect(),
'area_by_disease' => [],
'handling' => [],
'prevention' => [],
];
}
private function extractArea(string $address): string
{
$address = trim($address);
if ($address === '') {
return 'Tidak diketahui';
}
$parts = array_values(array_filter(array_map('trim', preg_split('/[,;-]+/', $address))));
$selected = $parts[0] ?? $address;
foreach ($parts as $part) {
if (preg_match('/\b(kota|kabupaten|kec\.?|kecamatan|kel\.?|kelurahan|desa)\b/i', $part)) {
$selected = $part;
break;
}
}
$selected = preg_replace('/\s+/', ' ', $selected);
return mb_convert_case($selected, MB_CASE_TITLE, 'UTF-8');
}
private function formatDateLabel(Carbon $date): string
{
$months = [
1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'Mei', 6 => 'Jun',
7 => 'Jul', 8 => 'Agu', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Des',
];
return $date->format('d') . ' ' . $months[(int) $date->format('n')] . ' ' . $date->format('Y');
}
private function getDiseaseKnowledge(string $diseaseName): array
{
if ($diseaseName === '') {
return ['pertolongan' => [], 'pencegahan' => []];
}
$rows = $this->readXlsxRows(public_path('data/Bissmilah lagi.xlsx'));
foreach ($rows as $row) {
$name = trim((string) ($row['Penyakit'] ?? ''));
if (mb_strtolower($name) !== mb_strtolower($diseaseName)) {
continue;
}
return [
'pertolongan' => $this->splitRecommendation((string) ($row['Pertolongan'] ?? '')),
'pencegahan' => $this->splitRecommendation((string) ($row['Pencegahan'] ?? '')),
];
}
return ['pertolongan' => [], 'pencegahan' => []];
}
private function splitRecommendation(string $value): array
{
return array_values(array_filter(array_map('trim', explode(';', $value))));
}
private function readXlsxRows(string $path): array
{
if (!is_file($path) || !class_exists(ZipArchive::class)) {
return [];
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
return [];
}
$sharedStrings = [];
$sharedXml = $zip->getFromName('xl/sharedStrings.xml');
if ($sharedXml !== false) {
$shared = simplexml_load_string($sharedXml);
foreach ($shared->si ?? [] as $item) {
$sharedStrings[] = trim((string) ($item->t ?? ''));
}
}
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
$zip->close();
if ($sheetXml === false) {
return [];
}
$sheet = simplexml_load_string($sheetXml);
$rows = [];
foreach ($sheet->sheetData->row ?? [] as $xmlRow) {
$cells = [];
foreach ($xmlRow->c as $cell) {
$ref = (string) $cell['r'];
$column = preg_replace('/\d+/', '', $ref);
$value = (string) ($cell->v ?? '');
if ((string) $cell['t'] === 's') {
$value = $sharedStrings[(int) $value] ?? '';
}
$cells[$column] = trim($value);
}
$rows[] = $cells;
}
$headers = array_shift($rows) ?? [];
return array_values(array_filter(array_map(function ($row) use ($headers) {
$mapped = [];
foreach ($headers as $column => $header) {
if ($header !== '') {
$mapped[$header] = $row[$column] ?? '';
}
}
return $mapped;
}, $rows)));
}
}

View File

@ -0,0 +1,139 @@
<?php
namespace Database\Seeders;
use App\Models\Biodata;
use Carbon\Carbon;
use Illuminate\Database\Seeder;
class BiodataTrendSeeder extends Seeder
{
/**
* Seed dummy diagnosis results so disease trend charts have data.
*/
public function run(): void
{
Biodata::query()
->where('no_telepon', 'like', '08DUMMY%')
->delete();
$diseases = [
[
'name' => 'Scabies',
'category' => 'Parasit',
'symptoms' => ['Gatal hebat', 'Kerak pada telinga', 'Bulu rontok', 'Kulit kemerahan'],
'weight' => 5,
],
[
'name' => 'Feline calicivirus',
'category' => 'Virus',
'symptoms' => ['Bersin', 'Sariawan', 'Air liur berlebih', 'Nafsu makan menurun'],
'weight' => 4,
],
[
'name' => 'Jamur/Ringworm',
'category' => 'Parasit',
'symptoms' => ['Bulu rontok melingkar', 'Kulit bersisik', 'Gatal', 'Kerak pada kulit'],
'weight' => 4,
],
[
'name' => 'Cacingan',
'category' => 'Parasit',
'symptoms' => ['Perut membesar', 'Berat badan turun', 'Muntah', 'Diare'],
'weight' => 3,
],
[
'name' => 'FLUTD (Feline Lower Urinary Tract Diseases)',
'category' => 'Virus / Lingkungan',
'symptoms' => ['Sulit buang air kecil', 'Sering ke litter box', 'Urin berdarah', 'Nyeri saat pipis'],
'weight' => 3,
],
[
'name' => 'Diare Non Spesifik',
'category' => 'Virus / Parasit',
'symptoms' => ['Diare', 'Lemas', 'Nafsu makan menurun', 'Dehidrasi ringan'],
'weight' => 2,
],
[
'name' => 'Earmite',
'category' => 'Parasit',
'symptoms' => ['Telinga kotor', 'Sering menggaruk telinga', 'Bau telinga', 'Kepala sering digelengkan'],
'weight' => 2,
],
];
$areas = [
'Sumbersari',
'Kaliwates',
'Patrang',
'Ajung',
'Rambipuji',
'Ambulu',
'Puger',
'Wuluhan',
'Arjasa',
'Jenggawah',
];
$cats = [
['owner' => 'Alya Pratama', 'cat' => 'Milo', 'gender' => 'Jantan', 'breed' => 'Domestik'],
['owner' => 'Bima Santoso', 'cat' => 'Luna', 'gender' => 'Betina', 'breed' => 'Persia'],
['owner' => 'Citra Dewi', 'cat' => 'Oyen', 'gender' => 'Jantan', 'breed' => 'Domestik'],
['owner' => 'Dani Kurniawan', 'cat' => 'Mochi', 'gender' => 'Betina', 'breed' => 'Anggora'],
['owner' => 'Eka Lestari', 'cat' => 'Nala', 'gender' => 'Betina', 'breed' => 'Mixdom'],
['owner' => 'Farhan Hakim', 'cat' => 'Simba', 'gender' => 'Jantan', 'breed' => 'Persia Medium'],
['owner' => 'Gita Maharani', 'cat' => 'Coco', 'gender' => 'Betina', 'breed' => 'Domestik'],
['owner' => 'Hendra Wijaya', 'cat' => 'Leo', 'gender' => 'Jantan', 'breed' => 'Maine Coon Mix'],
['owner' => 'Intan Permata', 'cat' => 'Mimi', 'gender' => 'Betina', 'breed' => 'Domestik'],
['owner' => 'Joko Saputra', 'cat' => 'Tom', 'gender' => 'Jantan', 'breed' => 'British Shorthair Mix'],
];
$weightedDiseases = [];
foreach ($diseases as $disease) {
for ($i = 0; $i < $disease['weight']; $i++) {
$weightedDiseases[] = $disease;
}
}
$rows = [];
$today = Carbon::today();
$rowNumber = 1;
for ($dayOffset = 0; $dayOffset < 30; $dayOffset++) {
$casesForDay = 1 + ($dayOffset % 4);
if (in_array($dayOffset, [0, 1, 2, 6, 13, 20], true)) {
$casesForDay++;
}
for ($case = 0; $case < $casesForDay; $case++) {
$cat = $cats[($rowNumber + $case) % count($cats)];
$disease = $weightedDiseases[($dayOffset + $case + $rowNumber) % count($weightedDiseases)];
$createdAt = $today
->copy()
->subDays($dayOffset)
->setTime(8 + (($case * 3) % 10), (17 + $rowNumber) % 60, 0);
$rows[] = [
'nama_pemilik' => $cat['owner'] . ' ' . str_pad((string) $rowNumber, 2, '0', STR_PAD_LEFT),
'nama_kucing' => $cat['cat'],
'umur_kucing' => 6 + (($rowNumber * 3) % 72),
'jenis_kelamin' => $cat['gender'],
'berat_badan' => 2.4 + (($rowNumber % 18) / 10),
'ras_kucing' => $cat['breed'],
'alamat' => $areas[($dayOffset + $case) % count($areas)],
'no_telepon' => '08DUMMY' . str_pad((string) $rowNumber, 5, '0', STR_PAD_LEFT),
'hasil_diagnosis' => $disease['name'],
'jenis' => $disease['category'],
'gejala_dipilih' => json_encode($disease['symptoms'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'created_at' => $createdAt,
'updated_at' => $createdAt,
];
$rowNumber++;
}
}
Biodata::query()->insert($rows);
}
}

View File

@ -5,6 +5,7 @@
use App\Models\User; use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@ -16,12 +17,16 @@ class DatabaseSeeder extends Seeder
public function run(): void public function run(): void
{ {
// Create Admin User // Create Admin User
User::create([ User::query()->updateOrCreate(
'name' => 'Admin PawMedic', ['email' => 'admin@pawmedic.app'],
'email' => 'admin@pawmedic.app', [
'password' => \Illuminate\Support\Facades\Hash::make('admin123'), 'name' => 'Admin PawMedic',
'email_verified_at' => now(), 'password' => Hash::make('admin123'),
]); 'email_verified_at' => now(),
]
);
$this->call(BiodataTrendSeeder::class);
// Optional: Create test user // Optional: Create test user
// User::factory()->create([ // User::factory()->create([

Binary file not shown.

View File

@ -11,7 +11,7 @@ app = Flask(__name__)
model = joblib.load("../python_artifacts/model.joblib") model = joblib.load("../python_artifacts/model.joblib")
# ========================= # =========================
# LOAD FEATURE # LOAD FEATURE/GEJALA
# ========================= # =========================
with open("../python_artifacts/feature_cols.json") as f: with open("../python_artifacts/feature_cols.json") as f:
feature_cols = json.load(f) feature_cols = json.load(f)
@ -66,7 +66,7 @@ def predict():
print("INPUT VECTOR:", input_data) print("INPUT VECTOR:", input_data)
input_df = pd.DataFrame([input_data], columns=feature_cols) input_df = pd.DataFrame([input_data], columns=feature_cols)
#melakukan prediksi
hasil = model.predict(input_df)[0] hasil = model.predict(input_df)[0]
penyakit = str(hasil).lower().strip() penyakit = str(hasil).lower().strip()
@ -82,7 +82,7 @@ def predict():
"pertolongan": [], "pertolongan": [],
"pencegahan": [] "pencegahan": []
}) })
#mengembalikan hasil prediksi ke laravel
return jsonify({ return jsonify({
"penyakit": hasil, "penyakit": hasil,
"jenis": info["jenis"], "jenis": info["jenis"],

View File

@ -159,6 +159,71 @@
font-size:13px; font-size:13px;
} }
.combo-wrap{
position:relative;
}
.combo-input-wrap{
position:relative;
}
.combo-input-wrap input{
padding-right:46px;
}
.combo-toggle{
position:absolute;
right:14px;
top:50%;
transform:translateY(-50%);
border:none;
background:transparent;
color:#334155;
width:28px;
height:28px;
display:flex;
align-items:center;
justify-content:center;
cursor:pointer;
font-size:14px;
}
.combo-menu{
display:none;
position:absolute;
left:0;
right:0;
top:calc(100% + 8px);
z-index:20;
max-height:240px;
overflow:auto;
padding:8px;
border:1px solid #dbe6ef;
border-radius:14px;
background:#fff;
box-shadow:0 18px 44px rgba(15,23,42,0.16);
}
.combo-wrap.open .combo-menu{
display:block;
}
.combo-option{
width:100%;
border:none;
background:transparent;
color:var(--text-dark);
padding:11px 12px;
border-radius:10px;
text-align:left;
font:600 14px var(--ff-body);
cursor:pointer;
}
.combo-option:hover,
.combo-option.active{
background:var(--primary-light);
color:var(--primary-dark);
}
.combo-empty{
padding:12px;
color:var(--text-muted);
font-size:14px;
}
.form-row{ .form-row{
display:grid; display:grid;
grid-template-columns:1fr 1fr; grid-template-columns:1fr 1fr;
@ -343,7 +408,7 @@
<!-- FORM CARD --> <!-- FORM CARD -->
<div class="form-card"> <div class="form-card">
<form action="{{ route('biodata.simpan') }}" method="POST"> <form id="biodataForm" action="{{ route('biodata.simpan') }}" method="POST">
@csrf @csrf
<!-- Nama Pemilik --> <!-- Nama Pemilik -->
@ -441,15 +506,26 @@
<!-- Alamat --> <!-- Alamat -->
<div class="form-group"> <div class="form-group">
<label for="alamat"> <label for="alamat">
Alamat Alamat Kecamatan <span class="required">*</span>
</label> </label>
<textarea <div class="combo-wrap" id="alamatCombo">
id="alamat" <div class="combo-input-wrap">
name="alamat" <input
placeholder="Masukkan alamat (opsional)" type="text"
rows="3" id="alamat"
></textarea> name="alamat"
<small>Opsional - Untuk keperluan dokumentasi</small> placeholder="Pilih atau cari kecamatan di Jember"
required
autocomplete="off"
role="combobox"
aria-expanded="false"
aria-controls="alamatOptions"
>
<button type="button" class="combo-toggle" id="alamatToggle" aria-label="Tampilkan pilihan kecamatan"></button>
</div>
<div class="combo-menu" id="alamatOptions" role="listbox"></div>
</div>
<small>Wajib memilih kecamatan dalam lingkup Kabupaten Jember.</small>
</div> </div>
<!-- Nomor Telepon --> <!-- Nomor Telepon -->
@ -482,45 +558,103 @@
</div> </div>
<script> <script>
const kecamatanJember = [
'Ajung', 'Ambulu', 'Arjasa', 'Balung', 'Bangsalsari', 'Gumukmas', 'Jelbuk',
'Jenggawah', 'Jombang', 'Kalisat', 'Kaliwates', 'Kencong', 'Ledokombo',
'Mayang', 'Mumbulsari', 'Pakusari', 'Panti', 'Patrang', 'Puger',
'Rambipuji', 'Semboro', 'Silo', 'Sukorambi', 'Sukowono', 'Sumberbaru',
'Sumberjambe', 'Sumbersari', 'Tanggul', 'Tempurejo', 'Umbulsari', 'Wuluhan'
];
// Validasi sederhana const biodataForm = document.getElementById('biodataForm');
const namaPemilik = document.getElementById('nama_pemilik').value.trim(); const alamatInput = document.getElementById('alamat');
const namaKucing = document.getElementById('nama_kucing').value.trim(); const alamatCombo = document.getElementById('alamatCombo');
const umurKucing = document.getElementById('umur_kucing').value; const alamatOptions = document.getElementById('alamatOptions');
const jenisKelamin = document.getElementById('jenis_kelamin').value; const alamatToggle = document.getElementById('alamatToggle');
const beratBadan = document.getElementById('berat_badan').value;
if (!namaPemilik || !namaKucing || !umurKucing || !jenisKelamin || !beratBadan) { function normalizeText(value) {
alert('Mohon lengkapi semua field yang wajib diisi!'); return String(value || '').trim().toLowerCase();
}
function openAlamatOptions() {
alamatCombo.classList.add('open');
alamatInput.setAttribute('aria-expanded', 'true');
}
function closeAlamatOptions() {
alamatCombo.classList.remove('open');
alamatInput.setAttribute('aria-expanded', 'false');
}
function renderAlamatOptions(query = '') {
const q = normalizeText(query);
const filtered = kecamatanJember.filter((item) => normalizeText(item).includes(q));
if (filtered.length === 0) {
alamatOptions.innerHTML = '<div class="combo-empty">Kecamatan tidak ditemukan</div>';
return;
}
alamatOptions.innerHTML = filtered.map((item) => (
`<button type="button" class="combo-option" role="option" data-value="${item}">${item}</button>`
)).join('');
}
renderAlamatOptions();
alamatInput.addEventListener('input', () => {
const selected = kecamatanJember.some((item) => normalizeText(item) === normalizeText(alamatInput.value));
alamatInput.setCustomValidity(selected || alamatInput.value.trim() === '' ? '' : 'Pilih kecamatan yang tersedia di Kabupaten Jember.');
renderAlamatOptions(alamatInput.value);
openAlamatOptions();
});
alamatInput.addEventListener('focus', () => {
renderAlamatOptions(alamatInput.value);
openAlamatOptions();
});
alamatToggle.addEventListener('click', () => {
renderAlamatOptions(alamatInput.value);
alamatCombo.classList.contains('open') ? closeAlamatOptions() : openAlamatOptions();
alamatInput.focus();
});
alamatOptions.addEventListener('click', (event) => {
const option = event.target.closest('.combo-option');
if (!option) return;
alamatInput.value = option.dataset.value;
alamatInput.setCustomValidity('');
closeAlamatOptions();
});
document.addEventListener('click', (event) => {
if (!alamatCombo.contains(event.target)) {
closeAlamatOptions();
}
});
biodataForm.addEventListener('submit', function(e) {
const selected = kecamatanJember.some((item) => normalizeText(item) === normalizeText(alamatInput.value));
if (!selected) {
e.preventDefault();
alamatInput.setCustomValidity('Pilih kecamatan yang tersedia di Kabupaten Jember.');
alamatInput.reportValidity();
return; return;
} }
// Simpan data ke sessionStorage untuk sementara
const formData = { const formData = {
nama_pemilik: namaPemilik, nama_pemilik: document.getElementById('nama_pemilik').value.trim(),
nama_kucing: namaKucing, nama_kucing: document.getElementById('nama_kucing').value.trim(),
umur_kucing: umurKucing, umur_kucing: document.getElementById('umur_kucing').value,
jenis_kelamin: jenisKelamin, jenis_kelamin: document.getElementById('jenis_kelamin').value,
ras_kucing: document.getElementById('ras_kucing').value.trim(), ras_kucing: document.getElementById('ras_kucing').value.trim(),
berat_badan: beratBadan, berat_badan: document.getElementById('berat_badan').value,
alamat: document.getElementById('alamat').value.trim(), alamat: alamatInput.value.trim(),
no_telepon: document.getElementById('no_telepon').value.trim() no_telepon: document.getElementById('no_telepon').value.trim()
}; };
sessionStorage.setItem('biodata_kucing', JSON.stringify(formData)); sessionStorage.setItem('biodata_kucing', JSON.stringify(formData));
// Simpan data ke sessionStorage
sessionStorage.setItem('biodata_kucing', JSON.stringify(formData));
// Show success message
if (window.showToast) {
showToast('Biodata berhasil disimpan!', 'success', 'Berhasil');
}
// Redirect ke halaman pilih gejala
setTimeout(() => {
window.location.href = '{{ route("gejala") }}';
}, 500);
}); });
</script> </script>

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pilih Gejala - PawMedic</title> <title>Pilih Gejala - PawMedic</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}"> <link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
@ -205,7 +206,7 @@
animation:fadeUp 0.8s cubic-bezier(0.16, 1, 0.3, 1); animation:fadeUp 0.8s cubic-bezier(0.16, 1, 0.3, 1);
border:1px solid rgba(111,207,151,0.2); border:1px solid rgba(111,207,151,0.2);
position:relative; position:relative;
overflow:hidden; overflow:visible;
transform-style:preserve-3d; transform-style:preserve-3d;
transition:transform 0.3s ease, box-shadow 0.3s ease; transition:transform 0.3s ease, box-shadow 0.3s ease;
} }
@ -237,6 +238,7 @@
} }
.form-card::after{ .form-card::after{
display:none;
content:''; content:'';
position:absolute; position:absolute;
top:-50%; top:-50%;
@ -378,6 +380,9 @@
position:relative; position:relative;
animation:fadeInUp 0.5s ease backwards; animation:fadeInUp 0.5s ease backwards;
} }
.gejala-item:hover{
z-index:5;
}
.gejala-item:nth-child(1){animation-delay:0.05s;} .gejala-item:nth-child(1){animation-delay:0.05s;}
.gejala-item:nth-child(2){animation-delay:0.1s;} .gejala-item:nth-child(2){animation-delay:0.1s;}
@ -409,6 +414,7 @@
.gejala-label{ .gejala-label{
display:flex; display:flex;
align-items:center; align-items:center;
flex-wrap:wrap;
gap:14px; gap:14px;
padding:20px 24px; padding:20px 24px;
background:linear-gradient(135deg, #ffffff 0%, #fafafa 100%); background:linear-gradient(135deg, #ffffff 0%, #fafafa 100%);
@ -421,40 +427,20 @@
color:var(--text-dark); color:var(--text-dark);
user-select:none; user-select:none;
position:relative; position:relative;
overflow:hidden; overflow:visible;
box-shadow: box-shadow:
0 4px 12px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.06),
0 0 0 0 rgba(111,207,151,0); 0 0 0 0 rgba(111,207,151,0);
transform:perspective(1000px) rotateX(0deg); transform:perspective(1000px) rotateX(0deg);
} }
.gejala-label::after{
content:'';
position:absolute;
top:50%;
left:50%;
width:0;
height:0;
border-radius:50%;
background:rgba(111,207,151,0.1);
transform:translate(-50%, -50%);
transition:width 0.6s ease, height 0.6s ease;
}
.gejala-label:hover::after{
width:300px;
height:300px;
}
.gejala-label:hover{ .gejala-label:hover{
background:linear-gradient(135deg, var(--primary-light) 0%, #ffffff 100%); background:linear-gradient(135deg, var(--primary-light) 0%, #ffffff 100%);
border-color:var(--primary); border-color:var(--primary);
transform:translateY(-6px) scale(1.03) perspective(1000px) rotateX(-2deg); transform:translateY(-3px);
box-shadow: box-shadow:
0 12px 32px rgba(111,207,151,0.25), 0 10px 24px rgba(111,207,151,0.18),
0 0 0 4px rgba(111,207,151,0.1), 0 0 0 4px rgba(111,207,151,0.08);
inset 0 1px 0 rgba(255,255,255,0.9);
border-width:3px;
} }
.gejala-checkbox:checked + .gejala-label{ .gejala-checkbox:checked + .gejala-label{
@ -466,23 +452,7 @@
0 12px 36px rgba(111,207,151,0.3), 0 12px 36px rgba(111,207,151,0.3),
0 0 0 5px rgba(111,207,151,0.15), 0 0 0 5px rgba(111,207,151,0.15),
inset 0 2px 4px rgba(111,207,151,0.1); inset 0 2px 4px rgba(111,207,151,0.1);
transform:translateY(-4px) scale(1.02) perspective(1000px) rotateX(-1deg); transform:translateY(-2px);
animation:selectedPulse 2s ease infinite;
}
@keyframes selectedPulse{
0%, 100%{
box-shadow:
0 12px 36px rgba(111,207,151,0.3),
0 0 0 5px rgba(111,207,151,0.15),
inset 0 2px 4px rgba(111,207,151,0.1);
}
50%{
box-shadow:
0 12px 36px rgba(111,207,151,0.35),
0 0 0 6px rgba(111,207,151,0.2),
inset 0 2px 4px rgba(111,207,151,0.15);
}
} }
.gejala-checkbox:checked + .gejala-label::before{ .gejala-checkbox:checked + .gejala-label::before{
@ -527,6 +497,156 @@
z-index:1; z-index:1;
} }
.gejala-name{
position:relative;
z-index:2;
flex:1;
min-width:0;
}
.gejala-help{
position:relative;
z-index:3;
width:28px;
height:28px;
border-radius:999px;
display:inline-flex;
align-items:center;
justify-content:center;
color:var(--primary-dark);
background:#ecfdf5;
border:1px solid #bbf7d0;
flex-shrink:0;
}
.gejala-help i{
transition:transform 0.2s ease;
}
.gejala-item.show-info .gejala-help i{
transform:rotate(180deg);
}
.gejala-description{
display:none;
width:100%;
margin-top:2px;
margin-left:42px;
padding:12px 14px;
border-radius:12px;
background:#f0fdf4;
border:1px solid #bbf7d0;
color:#14532d;
font-size:13px;
line-height:1.55;
font-weight:500;
}
.gejala-item.show-info .gejala-description{
display:block;
}
.diagnosis-alert{
display:none;
position:fixed;
left:50%;
top:50%;
z-index:9999;
width:min(420px, calc(100vw - 28px));
margin:0;
padding:16px 44px 16px 16px;
border:1px solid #fde68a;
border-radius:14px;
background:#ffffff;
color:#1f2937;
box-shadow:0 22px 55px rgba(15,23,42,0.26);
transform:translate(-50%, -46%) scale(.96);
opacity:0;
transition:opacity .2s ease, transform .2s ease;
}
.diagnosis-alert.show{
display:flex;
align-items:flex-start;
gap:12px;
opacity:1;
transform:translate(-50%, -50%) scale(1);
}
.diagnosis-alert-icon{
flex:0 0 auto;
width:36px;
height:36px;
border-radius:50%;
display:inline-flex;
align-items:center;
justify-content:center;
color:#b45309;
background:#fef3c7;
font-size:18px;
}
.diagnosis-alert-content{
min-width:0;
}
.diagnosis-alert-heading{
display:block;
margin:0 0 2px;
color:#114d3a;
font-size:14px;
font-weight:800;
line-height:1.35;
}
.diagnosis-alert-message{
margin:0;
color:#475569;
font-size:14px;
font-weight:600;
line-height:1.45;
}
.diagnosis-alert-close{
position:absolute;
top:12px;
right:10px;
width:28px;
height:28px;
border:0;
border-radius:50%;
display:inline-flex;
align-items:center;
justify-content:center;
color:#64748b;
background:transparent;
cursor:pointer;
transition:background .2s ease, color .2s ease;
}
.diagnosis-alert-close:hover{
color:#0f172a;
background:#f1f5f9;
}
@media (max-width:480px){
.diagnosis-alert{
width:calc(100vw - 24px);
padding:14px 40px 14px 14px;
border-radius:12px;
}
.diagnosis-alert-icon{
width:32px;
height:32px;
font-size:16px;
}
.diagnosis-alert-heading,
.diagnosis-alert-message{
font-size:13px;
}
}
.selected-count{ .selected-count{
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color:white; color:white;
@ -826,6 +946,65 @@ class="search-input"
<button type="button" id="clearSearch" class="clear-search" style="display:none;"></button> <button type="button" id="clearSearch" class="clear-search" style="display:none;"></button>
</div> </div>
@php
$explainGejala = function ($name) {
$text = trim((string) $name);
$lower = \Illuminate\Support\Str::lower($text);
$rules = [
'demam tinggi' => 'Telinga, telapak kaki, atau tubuh kucing terasa lebih panas dari biasanya dan kucing tampak kurang aktif.',
'sulit kencing' => 'Cek litterbox/kotak pasir kucing, lihat apakah kencingnya sedikit atau normal ke banyak.',
'kencing' => 'Amati frekuensi, jumlah, warna, dan apakah kucing tampak mengejan saat menggunakan kotak pasir.',
'diare berdarah' => 'Periksa apakah feses encer bercampur darah. Jika terlihat darah, kondisi ini perlu lebih diwaspadai.',
'diare' => 'Periksa apakah feses lebih encer dari biasanya, lebih sering keluar, atau baunya lebih menyengat.',
'muntah' => 'Catat seberapa sering kucing muntah, isi muntahan, dan apakah terjadi setelah makan atau minum.',
'nafsu makan' => 'Bandingkan porsi makan hari ini dengan kebiasaan normalnya dan perhatikan apakah kucing menolak makanan favorit.',
'kelemahan' => 'Perhatikan apakah kucing tampak lemas, lebih banyak diam, sulit berdiri, atau tidak mau bermain.',
'lemas' => 'Perhatikan apakah kucing lebih banyak tidur, kurang responsif, atau enggan bermain dan bergerak.',
'bersin' => 'Amati apakah bersin disertai lendir hidung, mata berair, atau napas berbunyi.',
'flu' => 'Perhatikan apakah hidung berair, bersin, atau kucing terlihat sulit mencium makanan.',
'pilek' => 'Cek apakah ada cairan dari hidung, hidung tersumbat, atau suara napas menjadi berbeda.',
'sesak napas' => 'Lihat apakah napas kucing cepat, berat, mulut terbuka, atau dada terlihat naik turun kuat.',
'batuk' => 'Dengarkan apakah batuk kering atau berdahak, serta apakah muncul setelah aktivitas atau saat istirahat.',
'radang telinga' => 'Cek apakah telinga kemerahan, kotor, berbau, atau kucing sering menggaruk telinga.',
'otitis' => 'Perhatikan apakah kucing sering menggelengkan kepala, telinga berbau, atau ada kotoran berlebih.',
'gatal' => 'Cek area kulit yang sering digaruk, dijilat, atau digigit, terutama telinga, leher, punggung, dan ekor.',
'kutu' => 'Sisir atau buka bulu kucing dan lihat apakah ada kutu kecil bergerak atau bintik hitam seperti kotoran.',
'pinjal' => 'Periksa pangkal ekor, leher, dan perut untuk melihat kutu kecil atau bekas gigitan.',
'kebotakan' => 'Lihat apakah ada area bulu yang menipis atau botak, terutama jika sering digaruk atau dijilat.',
'rontok' => 'Perhatikan apakah bulu rontok lebih banyak dari biasanya atau menyisakan area kulit terlihat.',
'bulu' => 'Lihat apakah bulu rontok berlebihan, kusam, menggumpal, atau ada area botak.',
'gangguan mata' => 'Periksa apakah mata merah, berair, belekan, bengkak, atau kucing sering menyipitkan mata.',
'mata' => 'Perhatikan apakah mata terlihat keruh, merah, berair, atau ada kotoran yang tidak biasa.',
'telinga' => 'Cek apakah telinga kotor, berbau, sering digaruk, atau kepala sering digelengkan.',
'demam' => 'Rasakan telinga/telapak kaki yang lebih hangat dari biasa dan perhatikan apakah kucing tampak lesu.',
'luka pada mulut' => 'Lihat area gusi, lidah, atau bibir. Perhatikan apakah ada sariawan, luka, bau mulut, atau sulit makan.',
'luka garukan' => 'Cek bekas garukan, kemerahan, atau kerak di kulit akibat kucing sering menggaruk.',
'luka' => 'Periksa lokasi luka, kemerahan, bengkak, nanah, atau apakah kucing kesakitan saat disentuh.',
'pincang' => 'Amati cara berjalan kucing, apakah salah satu kaki diangkat, diseret, atau tidak kuat menapak.',
'selaput lendir kuning' => 'Cek gusi, bagian putih mata, atau telinga bagian dalam. Warna kuning bisa menandakan masalah serius.',
'jaundice' => 'Perhatikan warna kuning pada gusi, mata, atau kulit tipis seperti telinga.',
'perut membesar' => 'Lihat apakah perut tampak membesar tidak biasa, terasa tegang, atau kucing tidak nyaman saat disentuh.',
'buncit' => 'Bandingkan bentuk perut dengan biasanya, terutama jika disertai lemas atau nafsu makan turun.',
'anemia' => 'Cek warna gusi. Gusi yang tampak pucat bisa menjadi tanda darah atau stamina kucing sedang bermasalah.',
'infeksi kulit' => 'Periksa kulit yang merah, basah, berkerak, bernanah, atau berbau tidak biasa.',
'overgrooming' => 'Perhatikan apakah kucing menjilat satu area terus-menerus sampai bulu menipis atau kulit iritasi.',
'perut bawah keras' => 'Raba pelan area perut bawah. Jika terasa keras dan kucing kesakitan, catat sebagai gejala penting.',
'sakit perut' => 'Perhatikan apakah kucing menghindar saat perut disentuh, meringkuk, atau tampak tidak nyaman.',
'nyeri abdomen' => 'Amati tanda nyeri di perut seperti mengeong saat disentuh, gelisah, atau posisi tubuh membungkuk.',
'penurunan berat badan cepat' => 'Bandingkan berat atau bentuk tubuh dalam beberapa hari/minggu terakhir, terutama jika makan tetap normal.',
'berat' => 'Bandingkan berat badan dengan kondisi sebelumnya dan amati apakah tubuh tampak lebih kurus atau membesar.',
];
foreach ($rules as $keyword => $description) {
if (\Illuminate\Support\Str::contains($lower, $keyword)) {
return $description;
}
}
return 'Perhatikan gejala ' . $text . ' dengan melihat kapan mulai muncul, seberapa sering terjadi, dan apakah membuat kucing berubah perilaku.';
};
@endphp
<div class="gejala-grid" id="gejalaGrid"> <div class="gejala-grid" id="gejalaGrid">
@foreach($gejala as $item) @foreach($gejala as $item)
<div class="gejala-item"> <div class="gejala-item">
@ -837,7 +1016,11 @@ class="gejala-checkbox"
id="gejala_{{ $loop->index }}" id="gejala_{{ $loop->index }}"
> >
<label for="gejala_{{ $loop->index }}" class="gejala-label"> <label for="gejala_{{ $loop->index }}" class="gejala-label">
{{ $item }} <span class="gejala-name">{{ $item }}</span>
<span class="gejala-help" role="button" tabindex="0" aria-label="Tampilkan penjelasan gejala {{ $item }}" aria-expanded="false">
<i class="bi bi-chevron-down"></i>
</span>
<span class="gejala-description">{{ $explainGejala($item) }}</span>
</label> </label>
</div> </div>
@endforeach @endforeach
@ -858,6 +1041,19 @@ class="gejala-checkbox"
</div> </div>
</div> </div>
<div class="alert alert-warning diagnosis-alert" id="diagnosisAlert" role="alert" aria-live="assertive">
<span class="diagnosis-alert-icon" aria-hidden="true">
<i class="bi bi-exclamation-triangle-fill"></i>
</span>
<div class="diagnosis-alert-content">
<strong class="diagnosis-alert-heading">Perhatian</strong>
<p class="diagnosis-alert-message" id="diagnosisAlertText">Pilih minimal 4 gejala terlebih dahulu.</p>
</div>
<button type="button" class="diagnosis-alert-close" aria-label="Tutup alert" id="closeDiagnosisAlert">
<i class="bi bi-x-lg"></i>
</button>
</div>
@include('components.toast') @include('components.toast')
@include('components.scroll-top') @include('components.scroll-top')
@ -866,6 +1062,24 @@ class="gejala-checkbox"
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
const selectedCount = document.getElementById('selectedCount'); const selectedCount = document.getElementById('selectedCount');
const form = document.getElementById('gejalaForm'); const form = document.getElementById('gejalaForm');
const diagnosisAlert = document.getElementById('diagnosisAlert');
const diagnosisAlertText = document.getElementById('diagnosisAlertText');
const closeDiagnosisAlert = document.getElementById('closeDiagnosisAlert');
let diagnosisAlertTimer = null;
function showDiagnosisAlert(message) {
diagnosisAlertText.textContent = message;
diagnosisAlert.classList.add('show');
clearTimeout(diagnosisAlertTimer);
diagnosisAlertTimer = setTimeout(hideDiagnosisAlert, 3000);
}
function hideDiagnosisAlert() {
clearTimeout(diagnosisAlertTimer);
diagnosisAlert.classList.remove('show');
}
closeDiagnosisAlert.addEventListener('click', hideDiagnosisAlert);
function updateSelectedCount() { function updateSelectedCount() {
const checked = document.querySelectorAll('.gejala-checkbox:checked').length; const checked = document.querySelectorAll('.gejala-checkbox:checked').length;
@ -875,13 +1089,13 @@ function updateSelectedCount() {
selectedCount.classList.add('animate'); selectedCount.classList.add('animate');
setTimeout(() => selectedCount.classList.remove('animate'), 500); setTimeout(() => selectedCount.classList.remove('animate'), 500);
// Enable/disable submit button // Tombol tetap bisa diklik agar validasi menampilkan alert yang jelas.
if (checked >= 4 && checked <= 7) { if (checked >= 4 && checked <= 7) {
submitBtn.disabled = false;
submitBtn.style.opacity = '1'; submitBtn.style.opacity = '1';
submitBtn.setAttribute('aria-disabled', 'false');
} else { } else {
submitBtn.disabled = true;
submitBtn.style.opacity = '0.6'; submitBtn.style.opacity = '0.6';
submitBtn.setAttribute('aria-disabled', 'true');
} }
} }
@ -890,6 +1104,23 @@ function updateSelectedCount() {
checkbox.addEventListener('change', updateSelectedCount); checkbox.addEventListener('change', updateSelectedCount);
}); });
document.querySelectorAll('.gejala-help').forEach((help) => {
const item = help.closest('.gejala-item');
const toggleInfo = (event) => {
event.preventDefault();
event.stopPropagation();
const isOpen = item.classList.toggle('show-info');
help.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
};
help.addEventListener('click', toggleInfo);
help.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
toggleInfo(event);
}
});
});
// Form submission // Form submission
form.addEventListener('submit', function(e) { form.addEventListener('submit', function(e) {
@ -898,15 +1129,17 @@ function updateSelectedCount() {
const checked = document.querySelectorAll('.gejala-checkbox:checked'); const checked = document.querySelectorAll('.gejala-checkbox:checked');
if (checked.length < 4) { if (checked.length < 4) {
alert("Minimal pilih 4 gejala!"); showDiagnosisAlert("Minimal pilih 4 gejala sebelum melanjutkan diagnosis.");
return; return;
} }
if (checked.length > 7) { if (checked.length > 7) {
alert("Maksimal hanya 7 gejala!"); showDiagnosisAlert("Maksimal hanya 7 gejala yang dapat dipilih.");
return; return;
} }
hideDiagnosisAlert();
// ambil gejala // ambil gejala
let gejala = []; let gejala = [];
checked.forEach(c => gejala.push(c.value)); checked.forEach(c => gejala.push(c.value));

View File

@ -17,6 +17,9 @@
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; --ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
--space:28px; --space:28px;
--muted: #6b7280; --muted: #6b7280;
--primary: #6fcf97;
--primary-dark: #4bb66f;
--primary-light: #e8f7ef;
} }
body{ body{
margin:0; margin:0;
@ -292,6 +295,299 @@
transform:translateY(-6px); transform:translateY(-6px);
} }
/* ===== HEALTH TRENDS ===== */
.health-trends{
background:linear-gradient(135deg,#ffffff 0%,#f8fffb 58%,#f6fbff 100%);
border:1px solid rgba(111,207,151,0.18);
border-radius:22px;
padding:26px;
box-shadow:0 16px 46px rgba(17,77,58,0.08);
}
.trend-heading{
display:flex;
align-items:center;
justify-content:space-between;
gap:18px;
flex-wrap:wrap;
margin-bottom:20px;
}
.trend-title-wrap{
display:flex;
align-items:flex-start;
gap:18px;
}
.trend-title-icon{
width:48px;
height:48px;
border-radius:14px;
background:linear-gradient(135deg,#dcfce7,#f0fdf4);
display:flex;
align-items:center;
justify-content:center;
color:#0f5132;
font-size:22px;
box-shadow:0 10px 26px rgba(34,197,94,0.13);
}
.trend-heading h2{
text-align:left;
margin-bottom:6px;
font-size:clamp(1.7rem, 3vw, 2.35rem);
color:#064e3b;
}
.trend-heading p{
text-align:left;
margin:0;
max-width:680px;
color:#64748b;
}
.trend-filter{
display:inline-flex;
gap:6px;
background:#f8fafc;
border:1px solid #e2e8f0;
border-radius:14px;
padding:6px;
box-shadow:inset 0 1px 3px rgba(15,23,42,0.05);
}
.trend-filter a{
text-decoration:none;
color:#475569;
font-weight:700;
font-size:14px;
padding:9px 18px;
border-radius:10px;
white-space:nowrap;
}
.trend-filter a.active{
background:linear-gradient(135deg,#22c55e,#16a34a);
color:#fff;
box-shadow:0 8px 18px rgba(34,197,94,0.24);
}
.trend-grid{
display:grid;
grid-template-columns:minmax(0,1fr) minmax(0,1fr);
gap:18px;
}
.trend-panel{
background:rgba(255,255,255,0.75);
border:1px solid #cfead9;
border-radius:18px;
padding:18px;
min-height:310px;
box-shadow:inset 0 1px 0 rgba(255,255,255,0.8);
}
.trend-panel.area-panel{
border-color:#c7daf9;
background:
radial-gradient(circle at 20% 20%, rgba(59,130,246,0.09), transparent 34%),
linear-gradient(145deg, rgba(255,255,255,0.9), rgba(248,251,255,0.78));
}
.trend-panel h3{
font-size:18px;
margin-bottom:10px;
display:flex;
align-items:center;
gap:12px;
}
.trend-panel h3 i{
width:38px;
height:38px;
border-radius:12px;
display:inline-flex;
align-items:center;
justify-content:center;
background:#dcfce7;
color:#16a34a;
font-size:22px;
}
.trend-panel.area-panel h3 i{
background:#dbeafe;
color:#2563eb;
}
.trend-panel p{
color:#64748b;
margin:0 0 14px;
font-size:14px;
}
.trend-chart-wrap{
height:205px;
}
.disease-legend{
display:none;
grid-template-columns:1fr 1fr;
gap:8px 24px;
border:1px solid #d7eadf;
border-radius:16px;
padding:14px 18px;
margin-top:18px;
background:rgba(255,255,255,0.72);
}
.legend-row{
display:grid;
grid-template-columns:auto 1fr auto;
align-items:center;
gap:10px;
color:#475569;
font-size:13px;
}
.legend-dot{
width:10px;
height:10px;
border-radius:999px;
background:#22c55e;
}
.legend-total{
color:#0f172a;
font-weight:800;
}
.area-donut-layout{
display:grid;
grid-template-columns:minmax(180px, 0.95fr) minmax(190px, 1fr);
align-items:center;
gap:22px;
margin-top:10px;
}
.area-donut-wrap{
height:220px;
position:relative;
padding:8px;
border-radius:999px;
background:radial-gradient(circle at 50% 50%, #ffffff 0%, #ffffff 42%, rgba(219,234,254,0.55) 43%, rgba(255,255,255,0) 70%);
}
.area-list{
display:flex;
flex-direction:column;
gap:12px;
}
.area-row{
display:grid;
grid-template-columns:auto 1fr auto;
align-items:center;
gap:12px;
border:1px solid #dbe5f2;
border-radius:14px;
background:rgba(255,255,255,0.78);
padding:13px 16px;
color:#334155;
}
.area-dot{
width:18px;
height:18px;
border-radius:999px;
background:#2563eb;
}
.area-count{
color:#2563eb;
font-weight:800;
}
.trend-bottom-grid{
margin-top:18px;
display:grid;
grid-template-columns:0.8fr 1.2fr 1.2fr;
gap:16px;
}
.trend-empty{
min-height:210px;
display:flex;
align-items:center;
justify-content:center;
text-align:center;
color:#64748b;
background:#f8fafc;
border-radius:12px;
padding:20px;
}
.trend-highlight{
background:linear-gradient(135deg,#dcfce7 0%,#f7fffb 100%);
border:1px solid #74d99b;
border-radius:18px;
padding:24px;
display:flex;
align-items:center;
gap:20px;
min-height:130px;
position:relative;
overflow:hidden;
}
.trend-highlight::after{
content:'';
position:absolute;
right:22px;
bottom:14px;
width:96px;
height:96px;
opacity:0.12;
background:linear-gradient(135deg,#16a34a,#86efac);
clip-path:polygon(46% 100%,46% 54%,10% 54%,10% 34%,68% 34%,68% 0,100% 50%,68% 100%,68% 68%,58% 68%,58% 100%);
}
.trend-highlight-icon,
.care-icon{
width:52px;
height:52px;
border-radius:999px;
display:flex;
align-items:center;
justify-content:center;
flex-shrink:0;
font-size:24px;
color:#fff;
}
.trend-highlight-icon{
background:linear-gradient(135deg,#fb923c,#f97316);
box-shadow:0 12px 26px rgba(249,115,22,0.22);
}
.trend-highlight span{
display:block;
color:#64748b;
font-size:13px;
font-weight:700;
text-transform:uppercase;
letter-spacing:0.04em;
margin-bottom:6px;
}
.trend-highlight strong{
display:block;
color:#114d3a;
font-family:var(--ff-heading);
font-size:19px;
line-height:1.35;
}
.care-box{
background:rgba(255,255,255,0.78);
border:1px solid #facc15;
border-radius:18px;
padding:20px;
display:flex;
gap:16px;
min-height:130px;
}
.care-box.prevention{
border-color:#c4a4f3;
}
.care-box.handling .care-icon{
background:linear-gradient(135deg,#facc15,#f59e0b);
box-shadow:0 12px 26px rgba(245,158,11,0.2);
}
.care-box.prevention .care-icon{
background:linear-gradient(135deg,#a78bfa,#7c3aed);
box-shadow:0 12px 26px rgba(124,58,237,0.18);
}
.care-box h3{
font-size:19px;
margin-bottom:10px;
}
.care-box p,
.care-box ul{
margin:0;
color:#334155;
}
.care-box ul{
padding-left:18px;
}
.care-box li + li{
margin-top:8px;
}
/* ===== FEATURES ===== */ /* ===== FEATURES ===== */
.features{ .features{
display:grid; display:grid;
@ -574,6 +870,11 @@
.features{ .features{
grid-template-columns:repeat(2,1fr); grid-template-columns:repeat(2,1fr);
} }
.trend-grid,
.trend-bottom-grid,
.area-donut-layout{
grid-template-columns:1fr;
}
section{ section{
margin-top:72px; margin-top:72px;
} }
@ -625,6 +926,54 @@
.features{ .features{
grid-template-columns:1fr; grid-template-columns:1fr;
} }
.health-trends{
padding:16px 12px;
border-radius:12px;
}
.trend-heading h2{
font-size:22px;
}
.trend-filter{
width:100%;
}
.trend-filter a{
flex:1;
text-align:center;
font-size:13px;
padding:8px;
}
.trend-panel{
padding:14px;
min-height:280px;
}
.trend-title-wrap{
gap:12px;
}
.trend-title-icon{
width:46px;
height:46px;
font-size:22px;
}
.trend-chart-wrap{
height:220px;
}
.disease-legend{
grid-template-columns:1fr;
}
.trend-highlight,
.care-box{
padding:16px;
align-items:flex-start;
}
.trend-highlight-icon,
.care-icon{
width:48px;
height:48px;
font-size:22px;
}
.trend-highlight strong{
font-size:18px;
}
.hero-content h2{ .hero-content h2{
font-size:20px; font-size:20px;
line-height:1.3; line-height:1.3;
@ -768,6 +1117,115 @@
</div> </div>
</section> </section>
<!-- TREN PENYAKIT -->
<section id="tren-penyakit" class="health-trends" data-aos="fade-up">
<div class="trend-heading">
<div class="trend-title-wrap">
<div class="trend-title-icon"><i class="bi bi-shield-check"></i></div>
<div>
<h2>Penyakit Terbanyak</h2>
<p>
Ringkasan kasus diagnosis dari data PawMedic pada periode {{ $diseaseNews['period_label'] ?? '30 hari terakhir' }}
({{ $diseaseNews['start_label'] ?? '-' }} - {{ $diseaseNews['end_label'] ?? '-' }}).
</p>
</div>
</div>
<div class="trend-filter" aria-label="Pilih periode tren penyakit">
<a href="{{ route('landing', ['range' => 'week']) }}#tren-penyakit" class="{{ ($diseaseNews['range'] ?? 'month') === 'week' ? 'active' : '' }}">1 Minggu</a>
<a href="{{ route('landing', ['range' => 'month']) }}#tren-penyakit" class="{{ ($diseaseNews['range'] ?? 'month') === 'month' ? 'active' : '' }}">1 Bulan</a>
</div>
</div>
@if(!empty($diseaseNews['top_disease']))
<div class="trend-grid">
<div class="trend-panel">
<h3><i class="bi bi-bar-chart-fill"></i> Kasus per Penyakit</h3>
<p>Penyakit yang paling sering muncul berdasarkan hasil diagnosis pengguna.</p>
<div class="trend-chart-wrap">
<canvas id="landingDiseaseChart"></canvas>
</div>
<div class="disease-legend">
@foreach(($diseaseNews['disease_labels'] ?? collect()) as $index => $label)
<div class="legend-row">
<span class="legend-dot"></span>
<span>{{ $label }}</span>
<span class="legend-total">{{ ($diseaseNews['disease_data'] ?? collect())[$index] ?? 0 }}</span>
</div>
@endforeach
</div>
</div>
<div class="trend-panel area-panel">
<h3><i class="bi bi-geo-alt-fill"></i> <span id="areaChartTitle">Daerah Kasus {{ $diseaseNews['top_disease'] }}</span></h3>
<p>Wilayah terbanyak untuk penyakit tertinggi pada periode yang dipilih.</p>
@if(($diseaseNews['area_data'] ?? collect())->count() > 0)
<div class="area-donut-layout">
<div class="area-donut-wrap">
<canvas id="landingAreaChart"></canvas>
</div>
<div class="area-list" id="areaChartList">
@foreach(($diseaseNews['area_labels'] ?? collect()) as $index => $label)
<div class="area-row">
<span class="area-dot" style="background:{{ $index === 0 ? '#2563eb' : '#93c5fd' }}"></span>
<span>{{ $label }}</span>
<span class="area-count">{{ ($diseaseNews['area_data'] ?? collect())[$index] ?? 0 }} kasus</span>
</div>
@endforeach
</div>
</div>
@else
<div class="trend-empty">Belum ada data alamat yang cukup untuk penyakit ini.</div>
@endif
</div>
</div>
<div class="trend-bottom-grid">
<div class="trend-highlight">
<div class="trend-highlight-icon"><i class="bi bi-exclamation-triangle-fill"></i></div>
<div>
<span>Kasus tertinggi</span>
<strong>{{ $diseaseNews['top_disease'] }}</strong>
<p style="text-align:left;margin:8px 0 0;color:#64748b;font-size:14px;">
{{ $diseaseNews['total_cases'] ?? 0 }} kasus pada {{ $diseaseNews['period_label'] ?? 'periode ini' }}.
</p>
</div>
</div>
<div class="care-box handling">
<div class="care-icon"><i class="bi bi-lightbulb"></i></div>
<div>
<h3>Penanganan</h3>
@if(!empty($diseaseNews['handling']))
<ul>
@foreach($diseaseNews['handling'] as $item)
<li>{{ $item }}</li>
@endforeach
</ul>
@else
<p style="text-align:left;margin:0;color:#64748b;">Data penanganan belum tersedia untuk penyakit ini.</p>
@endif
</div>
</div>
<div class="care-box prevention">
<div class="care-icon"><i class="bi bi-shield"></i></div>
<div>
<h3>Pencegahan</h3>
@if(!empty($diseaseNews['prevention']))
<ul>
@foreach($diseaseNews['prevention'] as $item)
<li>{{ $item }}</li>
@endforeach
</ul>
@else
<p style="text-align:left;margin:0;color:#64748b;">Data pencegahan belum tersedia untuk penyakit ini.</p>
@endif
</div>
</div>
</div>
@else
<div class="trend-empty">
Belum ada data diagnosis pada periode ini. Grafik akan otomatis tampil setelah data tersedia.
</div>
@endif
</section>
<!-- FITUR --> <!-- FITUR -->
<section id="fitur" data-aos="fade-up"> <section id="fitur" data-aos="fade-up">
@ -871,6 +1329,133 @@ function scrollToSection(id){
}); });
} }
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const landingDiseaseCanvas = document.getElementById('landingDiseaseChart');
const landingAreaCanvas = document.getElementById('landingAreaChart');
const landingDiseaseLabels = {!! json_encode($diseaseNews['disease_labels'] ?? []) !!};
const landingDiseaseData = {!! json_encode($diseaseNews['disease_data'] ?? []) !!};
const landingAreaLabels = {!! json_encode($diseaseNews['area_labels'] ?? []) !!};
const landingAreaData = {!! json_encode($diseaseNews['area_data'] ?? []) !!};
const landingAreaByDisease = {!! json_encode($diseaseNews['area_by_disease'] ?? []) !!};
const landingAreaColors = ['#2563eb', '#06b6d4', '#8b5cf6', '#f59e0b', '#10b981', '#ef4444'];
let landingAreaChart = null;
function renderLandingBarChart(canvas, labels, data, color, borderColor) {
if (!canvas || !labels.length || !data.length) return;
new Chart(canvas, {
type: 'bar',
data: {
labels,
datasets: [{
label: 'Jumlah Kasus',
data,
backgroundColor: color,
borderColor,
borderWidth: 1.5,
borderRadius: 8,
maxBarThickness: 52
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: { mode: 'index', intersect: false }
},
scales: {
x: { ticks: { display: false }, grid: { display: false } },
y: {
beginAtZero: true,
ticks: { precision: 0, color: '#475569' },
grid: { color: '#e5e7eb' }
}
},
onClick(event, elements) {
if (!elements.length || canvas.id !== 'landingDiseaseChart') return;
const index = elements[0].index;
updateAreaChart(labels[index]);
}
}
});
}
function renderAreaList(labels, data) {
const list = document.getElementById('areaChartList');
if (!list) return;
if (!labels.length || !data.length) {
list.innerHTML = '<div class="trend-empty" style="min-height:120px;">Belum ada data alamat yang cukup untuk penyakit ini.</div>';
return;
}
list.innerHTML = labels.map((label, index) => `
<div class="area-row">
<span class="area-dot" style="background:${landingAreaColors[index] || '#93c5fd'}"></span>
<span>${escapeHtml(label)}</span>
<span class="area-count">${data[index] || 0} kasus</span>
</div>
`).join('');
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function updateAreaChart(diseaseName) {
const source = landingAreaByDisease[diseaseName] || { labels: [], data: [] };
const labels = source.labels || [];
const data = source.data || [];
const title = document.getElementById('areaChartTitle');
if (title) title.textContent = `Daerah Kasus ${diseaseName}`;
renderAreaList(labels, data);
if (!landingAreaChart) return;
landingAreaChart.data.labels = labels;
landingAreaChart.data.datasets[0].data = data;
landingAreaChart.data.datasets[0].backgroundColor = landingAreaColors;
landingAreaChart.update();
}
renderLandingBarChart(landingDiseaseCanvas, landingDiseaseLabels, landingDiseaseData, '#6fcf97', '#4bb66f');
if (landingAreaCanvas && landingAreaLabels.length && landingAreaData.length) {
landingAreaChart = new Chart(landingAreaCanvas, {
type: 'pie',
data: {
labels: landingAreaLabels,
datasets: [{
data: landingAreaData,
backgroundColor: landingAreaColors,
borderColor: '#ffffff',
borderWidth: 5,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label(context) {
return `${context.label}: ${context.raw} kasus`;
}
}
}
}
}
});
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
const reveals = document.querySelectorAll('[data-aos]'); const reveals = document.querySelectorAll('[data-aos]');

View File

@ -5,7 +5,7 @@
use App\Http\Controllers\AdminController; use App\Http\Controllers\AdminController;
use App\Http\Controllers\GejalaController; use App\Http\Controllers\GejalaController;
use App\Http\Controllers\UlasanController; use App\Http\Controllers\UlasanController;
use App\Models\Ulasan; use App\Http\Controllers\LandingController;
Route::get('/admin/sort-diagnosis', [AdminController::class, 'sortDiagnosis']); Route::get('/admin/sort-diagnosis', [AdminController::class, 'sortDiagnosis']);
Route::get('/admin/export-diagnosis', [AdminController::class, 'exportDiagnosisExcel']) Route::get('/admin/export-diagnosis', [AdminController::class, 'exportDiagnosisExcel'])
@ -55,10 +55,7 @@
->name('ulasan.toggleHide') ->name('ulasan.toggleHide')
->middleware('auth'); ->middleware('auth');
Route::get('/', function () { Route::get('/', [LandingController::class, 'index'])->name('landing');
$ulasan = Ulasan::where('is_hidden', false)->latest()->take(3)->get();
return view('landing', compact('ulasan'));
});
Route::get('/biodata', function () { Route::get('/biodata', function () {
return view('biodata'); return view('biodata');