Compare commits
10 Commits
604c4de096
...
9e1a910c2f
| Author | SHA1 | Date |
|---|---|---|
|
|
9e1a910c2f | |
|
|
920e6caf3d | |
|
|
6ad6bb79f4 | |
|
|
3564fc3fb8 | |
|
|
ccdd44927b | |
|
|
243bb7cb49 | |
|
|
57cddbdc92 | |
|
|
6ad396bc27 | |
|
|
0d1ce24fee | |
|
|
2bb7f6b930 |
|
|
@ -0,0 +1,18 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
64
README.md
64
README.md
|
|
@ -1 +1,63 @@
|
|||
# sistem-diagnosa-penyakit-kucing
|
||||
HEAD
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
=======
|
||||
# PawMedic
|
||||
>>>>>>> 2bb7f6b9301b796972b3cc1427e9336f3820159e
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
# Kredensial Login Admin PawMedic
|
||||
|
||||
## Default Admin Credentials
|
||||
|
||||
**Email:** `admin@pawmedic.app`
|
||||
**Password:** `admin123`
|
||||
|
||||
## Cara Setup Admin
|
||||
|
||||
1. **Jalankan migration dan seeder:**
|
||||
```bash
|
||||
php artisan migrate
|
||||
php artisan db:seed
|
||||
```
|
||||
|
||||
2. **Atau buat user admin manual:**
|
||||
```bash
|
||||
php artisan tinker
|
||||
```
|
||||
Kemudian jalankan:
|
||||
```php
|
||||
App\Models\User::create([
|
||||
'name' => 'Admin PawMedic',
|
||||
'email' => 'admin@pawmedic.app',
|
||||
'password' => Hash::make('admin123'),
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
```
|
||||
|
||||
## Akses Login Admin
|
||||
|
||||
1. Buka halaman landing page
|
||||
2. Scroll ke footer
|
||||
3. Klik icon kunci (🔐) di pojok kanan bawah footer
|
||||
4. Atau langsung akses: `/admin/login`
|
||||
|
||||
## Keamanan
|
||||
|
||||
⚠️ **PENTING:** Setelah pertama kali login, segera ubah password admin untuk keamanan!
|
||||
|
||||
## Mengubah Password Admin
|
||||
|
||||
Anda dapat mengubah password melalui:
|
||||
1. Tinker: `php artisan tinker`
|
||||
2. Atau buat fitur change password di dashboard admin
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Cara Membaca Data dari Excel DatasetTraining.xlsx
|
||||
|
||||
Untuk membaca data gejala dari file Excel, Anda perlu menginstall library PhpSpreadsheet:
|
||||
|
||||
```bash
|
||||
composer require phpoffice/phpspreadsheet
|
||||
```
|
||||
|
||||
Setelah library terinstall, controller `DiagnosisController` akan otomatis membaca data dari file `public/data/DatasetTraining.xlsx`.
|
||||
|
||||
Jika library belum terinstall, sistem akan menggunakan data gejala default yang sudah tersedia.
|
||||
|
||||
## Struktur Excel yang Diharapkan
|
||||
|
||||
File Excel sebaiknya memiliki struktur:
|
||||
- Baris pertama: Header (akan di-skip)
|
||||
- Kolom-kolom: Berisi nama gejala
|
||||
- Kolom terakhir: Biasanya berisi nama penyakit/diagnosis
|
||||
|
||||
Controller akan membaca semua kolom kecuali kolom terakhir sebagai data gejala.
|
||||
|
|
@ -0,0 +1,965 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use App\Models\Biodata;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
use App\Models\Ulasan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\User;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AdminController extends Controller
|
||||
{
|
||||
public function login()
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
return view('admin.login');
|
||||
}
|
||||
|
||||
public function authenticate(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
if (!str_ends_with(strtolower($credentials['email']), '@pawmedic.app')) {
|
||||
return back()->withErrors([
|
||||
'email' => 'Login admin hanya untuk email @pawmedic.app.',
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
if (Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('admin.dashboard'));
|
||||
}
|
||||
|
||||
return back()->withErrors([
|
||||
'email' => 'Email atau password tidak valid.',
|
||||
])->onlyInput('email');
|
||||
}
|
||||
|
||||
public function forgotPasswordPage()
|
||||
{
|
||||
return view('admin.forgot-password');
|
||||
}
|
||||
|
||||
public function sendForgotOtp(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'otp_email' => 'required|email',
|
||||
]);
|
||||
|
||||
$otpEmail = strtolower((string)$data['otp_email']);
|
||||
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$cacheKey = 'admin_password_recovery_otp_' . sha1($otpEmail);
|
||||
|
||||
Cache::put($cacheKey, [
|
||||
'otp_hash' => Hash::make($otp),
|
||||
'otp_email' => $otpEmail,
|
||||
], now()->addMinutes(10));
|
||||
|
||||
Mail::raw(
|
||||
"Kode OTP reset password PawMedic Anda adalah: {$otp}\n\nKode berlaku 10 menit. Jangan bagikan kode ini kepada siapa pun.",
|
||||
function ($message) use ($otpEmail) {
|
||||
$message->to($otpEmail)->subject('OTP Reset Password PawMedic');
|
||||
}
|
||||
);
|
||||
|
||||
return back()
|
||||
->with('success', 'Kode OTP berhasil dikirim. Cek email Anda.')
|
||||
->with('otp_email', $otpEmail);
|
||||
}
|
||||
|
||||
public function verifyForgotOtp(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'otp_email' => 'required|email',
|
||||
'otp' => 'required|string|size:6',
|
||||
]);
|
||||
|
||||
$otpEmail = strtolower((string)$data['otp_email']);
|
||||
$cacheKey = 'admin_password_recovery_otp_' . sha1($otpEmail);
|
||||
$cached = Cache::get($cacheKey);
|
||||
|
||||
if (!is_array($cached) || empty($cached['otp_hash'])) {
|
||||
return back()->with('error', 'Kode OTP tidak ditemukan atau sudah kedaluwarsa.')->with('otp_email', $otpEmail);
|
||||
}
|
||||
|
||||
if (!Hash::check((string)$data['otp'], (string)$cached['otp_hash'])) {
|
||||
return back()->with('error', 'Kode tidak valid.')->with('otp_email', $otpEmail);
|
||||
}
|
||||
|
||||
session([
|
||||
'forgot_otp_verified' => true,
|
||||
'forgot_otp_email' => $otpEmail,
|
||||
]);
|
||||
|
||||
return redirect()->route('admin.forgot.reset.form');
|
||||
}
|
||||
|
||||
public function resetPasswordPage()
|
||||
{
|
||||
if (!session('forgot_otp_verified')) {
|
||||
return redirect()->route('admin.forgot.password')->with('error', 'Silakan verifikasi OTP terlebih dahulu.');
|
||||
}
|
||||
|
||||
return view('admin.reset-password');
|
||||
}
|
||||
|
||||
public function resetPasswordSubmit(Request $request)
|
||||
{
|
||||
if (!session('forgot_otp_verified')) {
|
||||
return redirect()->route('admin.forgot.password')->with('error', 'Silakan verifikasi OTP terlebih dahulu.');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'new_password' => 'required|string|min:6|confirmed',
|
||||
]);
|
||||
|
||||
$admin = User::query()
|
||||
->where('email', 'like', '%@pawmedic.app')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if (!$admin) {
|
||||
return redirect()->route('admin.forgot.password')->with('error', 'Akun admin @pawmedic.app tidak ditemukan.');
|
||||
}
|
||||
|
||||
$admin->password = Hash::make((string)$data['new_password']);
|
||||
$admin->save();
|
||||
|
||||
$otpEmail = (string)session('forgot_otp_email', '');
|
||||
if ($otpEmail !== '') {
|
||||
Cache::forget('admin_password_recovery_otp_' . sha1($otpEmail));
|
||||
}
|
||||
|
||||
$request->session()->forget(['forgot_otp_verified', 'forgot_otp_email']);
|
||||
|
||||
return redirect()->route('admin.login')->with('success', 'Password admin berhasil diganti. Silakan login.');
|
||||
}
|
||||
|
||||
public function sendRecoveryOtp(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'otp_email' => 'required|email',
|
||||
]);
|
||||
|
||||
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
$otpEmail = strtolower($data['otp_email']);
|
||||
$cacheKey = 'admin_password_recovery_otp_' . sha1($otpEmail);
|
||||
Cache::put($cacheKey, [
|
||||
'otp_hash' => Hash::make($otp),
|
||||
'otp_email' => $otpEmail,
|
||||
], now()->addMinutes(10));
|
||||
|
||||
Mail::raw(
|
||||
"Kode OTP reset password PawMedic Anda adalah: {$otp}\n\nKode berlaku 10 menit. Jangan berikan kode ini ke siapa pun.",
|
||||
function ($message) use ($otpEmail) {
|
||||
$message->to($otpEmail)->subject('OTP Reset Password PawMedic');
|
||||
}
|
||||
);
|
||||
|
||||
return back()->with('success', 'OTP berhasil dikirim ke email aplikasi. Cek inbox (atau log mail jika mode lokal).');
|
||||
}
|
||||
|
||||
public function recoverPassword(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'otp_email' => 'required|email',
|
||||
'email' => 'required|email',
|
||||
'otp' => 'required|string|size:6',
|
||||
'new_password' => 'required|string|min:6|confirmed',
|
||||
]);
|
||||
|
||||
$otpEmail = strtolower($data['otp_email']);
|
||||
$email = strtolower($data['email']);
|
||||
if (!str_ends_with($email, '@pawmedic.app')) {
|
||||
return back()->with('error', 'Reset password admin hanya untuk email @pawmedic.app.');
|
||||
}
|
||||
|
||||
$cacheKey = 'admin_password_recovery_otp_' . sha1($otpEmail);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (!is_array($cached) || empty($cached['otp_hash'])) {
|
||||
return back()->with('error', 'OTP tidak ditemukan atau sudah kedaluwarsa. Silakan kirim OTP ulang.');
|
||||
}
|
||||
|
||||
if (!hash_equals((string)($cached['otp_email'] ?? ''), $otpEmail)) {
|
||||
return back()->with('error', 'Email aplikasi tidak sesuai dengan OTP.');
|
||||
}
|
||||
|
||||
if (!Hash::check((string) $data['otp'], (string) $cached['otp_hash'])) {
|
||||
return back()->with('error', 'Kode OTP tidak valid.');
|
||||
}
|
||||
|
||||
$admin = User::where('email', $data['email'])->first();
|
||||
if (!$admin) {
|
||||
return back()->with('error', 'Email admin tidak ditemukan.');
|
||||
}
|
||||
|
||||
$admin->password = Hash::make($data['new_password']);
|
||||
$admin->save();
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
return redirect()->route('admin.login')->with('success', 'Password berhasil direset. Silakan login dengan password baru.');
|
||||
}
|
||||
|
||||
public function dashboard()
|
||||
{
|
||||
|
||||
// total diagnosis
|
||||
$totalDiagnosis = Biodata::count();
|
||||
|
||||
// hari ini
|
||||
$todayDiagnosis = Biodata::whereDate('created_at', Carbon::today())->count();
|
||||
|
||||
// kemarin
|
||||
$yesterday = Biodata::whereDate('created_at', Carbon::yesterday())->count();
|
||||
$diff = $todayDiagnosis - $yesterday;
|
||||
|
||||
// user list + diagnosis list
|
||||
$sort = request('sort');
|
||||
if ($sort == 'oldest') {
|
||||
$data = Biodata::orderBy('created_at', 'asc')->get();
|
||||
} else {
|
||||
$data = Biodata::orderBy('created_at', 'desc')->get();
|
||||
}
|
||||
|
||||
// Data pengguna unik (hindari duplikasi input yang sama)
|
||||
$userData = $data->unique(function ($item) {
|
||||
$phone = trim((string)($item->no_telepon ?? ''));
|
||||
if ($phone !== '') {
|
||||
return 'phone:' . $phone;
|
||||
}
|
||||
|
||||
return 'fallback:' . Str::lower(trim((string)($item->nama_pemilik ?? ''))) . '|' .
|
||||
Str::lower(trim((string)($item->nama_kucing ?? ''))) . '|' .
|
||||
Str::lower(trim((string)($item->alamat ?? '')));
|
||||
})->values();
|
||||
|
||||
// total user = jumlah data unik
|
||||
$totalUsers = $userData->count();
|
||||
|
||||
// penyakit paling umum
|
||||
$mostCommon = Biodata::select('hasil_diagnosis')
|
||||
->whereNotNull('hasil_diagnosis')
|
||||
->groupBy('hasil_diagnosis')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->value('hasil_diagnosis');
|
||||
|
||||
// penyakit terbanyak hari ini
|
||||
$todayDisease = Biodata::whereDate('created_at', Carbon::today())
|
||||
->select('hasil_diagnosis')
|
||||
->whereNotNull('hasil_diagnosis')
|
||||
->groupBy('hasil_diagnosis')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->value('hasil_diagnosis');
|
||||
|
||||
// diagnosis terbaru
|
||||
$recent = Biodata::select('hasil_diagnosis', 'created_at')
|
||||
->latest()
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
$recentFormatted = $recent->map(function ($item) {
|
||||
return [
|
||||
'date' => $item->created_at,
|
||||
'disease' => $item->hasil_diagnosis,
|
||||
'count' => 1
|
||||
];
|
||||
});
|
||||
|
||||
// chart penyakit
|
||||
$diseaseStats = Biodata::select('hasil_diagnosis')
|
||||
->whereNotNull('hasil_diagnosis')
|
||||
->get()
|
||||
->groupBy('hasil_diagnosis')
|
||||
->map(function ($item) {
|
||||
return count($item);
|
||||
});
|
||||
|
||||
$chartLabels = $diseaseStats->keys()->values();
|
||||
$chartData = $diseaseStats->values();
|
||||
|
||||
// Data tren: 7 hari terakhir
|
||||
$period = CarbonPeriod::create(Carbon::now()->subDays(6), Carbon::now());
|
||||
$trend7Labels = [];
|
||||
$trend7Data = [];
|
||||
|
||||
foreach ($period as $date) {
|
||||
$count = Biodata::whereDate('created_at', $date)->count();
|
||||
$trend7Labels[] = $date->format('d M');
|
||||
$trend7Data[] = $count;
|
||||
}
|
||||
|
||||
// Data tren: per bulan (tahun berjalan)
|
||||
$monthNames = [
|
||||
1 => 'Jan', 2 => 'Feb', 3 => 'Mar', 4 => 'Apr', 5 => 'Mei', 6 => 'Jun',
|
||||
7 => 'Jul', 8 => 'Agu', 9 => 'Sep', 10 => 'Okt', 11 => 'Nov', 12 => 'Des',
|
||||
];
|
||||
$monthlyRaw = Biodata::query()
|
||||
->selectRaw('MONTH(created_at) as m, COUNT(*) as total')
|
||||
->whereYear('created_at', Carbon::now()->year)
|
||||
->groupBy('m')
|
||||
->orderBy('m')
|
||||
->get()
|
||||
->keyBy('m');
|
||||
|
||||
$trendMonthLabels = [];
|
||||
$trendMonthData = [];
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$trendMonthLabels[] = $monthNames[$m];
|
||||
$trendMonthData[] = (int)($monthlyRaw[$m]->total ?? 0);
|
||||
}
|
||||
|
||||
// Data tren: seluruh periode (agregasi per bulan-tahun)
|
||||
$allRaw = Biodata::query()
|
||||
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as ym, COUNT(*) as total")
|
||||
->groupBy('ym')
|
||||
->orderBy('ym')
|
||||
->get();
|
||||
|
||||
$trendAllLabels = $allRaw->map(function ($row) use ($monthNames) {
|
||||
[$y, $m] = explode('-', (string)$row->ym);
|
||||
$month = (int)$m;
|
||||
return ($monthNames[$month] ?? $m) . ' ' . $y;
|
||||
})->values()->all();
|
||||
$trendAllData = $allRaw->pluck('total')->map(fn ($n) => (int)$n)->values()->all();
|
||||
|
||||
// kirim ke blade
|
||||
$stats = [
|
||||
'total_diagnosis' => $totalDiagnosis,
|
||||
'today_diagnosis' => $todayDiagnosis,
|
||||
'total_users' => $totalUsers,
|
||||
'most_common_disease' => $mostCommon,
|
||||
'recent_diagnosis' => $recentFormatted,
|
||||
'chart_labels' => $chartLabels,
|
||||
'chart_data' => $chartData,
|
||||
'diagnosis_diff' => $diff,
|
||||
'today_top_disease' => $todayDisease,
|
||||
'trend_7_labels' => $trend7Labels,
|
||||
'trend_7_data' => $trend7Data,
|
||||
'trend_month_labels' => $trendMonthLabels,
|
||||
'trend_month_data' => $trendMonthData,
|
||||
'trend_all_labels' => $trendAllLabels,
|
||||
'trend_all_data' => $trendAllData,
|
||||
];
|
||||
// 🔥 STAT
|
||||
$ratingChart = Ulasan::select('rating', DB::raw('count(*) as total'))
|
||||
->groupBy('rating')
|
||||
->orderBy('rating')
|
||||
->get();
|
||||
|
||||
$stats['rating_labels'] = $ratingChart->pluck('rating');
|
||||
$stats['rating_data'] = $ratingChart->pluck('total');
|
||||
|
||||
return view('admin.dashboard', compact('stats', 'data', 'userData'));
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
|
||||
private function getStatistics()
|
||||
{
|
||||
// Simulated statistics - bisa diganti dengan data real dari database
|
||||
return [
|
||||
'total_diagnosis' => 156,
|
||||
'today_diagnosis' => 12,
|
||||
'total_users' => 89,
|
||||
'most_common_disease' => 'Feline Panleukopenia',
|
||||
'recent_diagnosis' => [
|
||||
['date' => '2026-01-15', 'disease' => 'Feline Panleukopenia', 'count' => 5],
|
||||
['date' => '2026-01-14', 'disease' => 'Feline Calicivirus', 'count' => 3],
|
||||
['date' => '2026-01-13', 'disease' => 'Scabies', 'count' => 4],
|
||||
]
|
||||
];
|
||||
}
|
||||
public function statistik()
|
||||
{
|
||||
$diseaseStats = Biodata::select('hasil_diagnosis')
|
||||
->whereNotNull('hasil_diagnosis')
|
||||
->get()
|
||||
->groupBy('hasil_diagnosis')
|
||||
->map(function ($item) {
|
||||
return count($item);
|
||||
});
|
||||
|
||||
$chartLabels = $diseaseStats->keys()->values();
|
||||
$chartData = $diseaseStats->values();
|
||||
|
||||
return view('admin.statistik', compact('chartLabels', 'chartData'));
|
||||
}
|
||||
public function sortDiagnosis(Request $request)
|
||||
{
|
||||
$sort = $request->sort;
|
||||
|
||||
if ($sort == 'oldest') {
|
||||
$data = Biodata::orderBy('created_at', 'asc')->get();
|
||||
} else {
|
||||
$data = Biodata::orderBy('created_at', 'desc')->get();
|
||||
}
|
||||
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
public function exportDiagnosisExcel(): StreamedResponse
|
||||
{
|
||||
$rows = Biodata::orderBy('created_at', 'desc')->get();
|
||||
$filename = 'diagnosis-pawmedic-' . now()->format('Ymd-His') . '.xls';
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'application/vnd.ms-excel; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
'Cache-Control' => 'max-age=0',
|
||||
];
|
||||
|
||||
return response()->streamDownload(function () use ($rows) {
|
||||
echo '<html><head><meta charset="UTF-8"></head><body>';
|
||||
echo '<table border="1" cellpadding="6" cellspacing="0">';
|
||||
echo '<tr style="background:#e8f7ef;font-weight:bold;">';
|
||||
echo '<th>Tanggal</th>';
|
||||
echo '<th>Nama Pemilik</th>';
|
||||
echo '<th>Nama Kucing</th>';
|
||||
echo '<th>Umur Kucing</th>';
|
||||
echo '<th>Jenis Kelamin</th>';
|
||||
echo '<th>Berat Badan</th>';
|
||||
echo '<th>Ras Kucing</th>';
|
||||
echo '<th>Alamat</th>';
|
||||
echo '<th>No Telepon</th>';
|
||||
echo '<th>Hasil Diagnosis</th>';
|
||||
echo '<th>Jenis</th>';
|
||||
echo '</tr>';
|
||||
|
||||
foreach ($rows as $row) {
|
||||
echo '<tr>';
|
||||
echo '<td>' . e(optional($row->created_at)->format('d-m-Y H:i')) . '</td>';
|
||||
echo '<td>' . e($row->nama_pemilik ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->nama_kucing ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->umur_kucing ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->jenis_kelamin ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->berat_badan ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->ras_kucing ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->alamat ?? '-') . '</td>';
|
||||
echo '<td style="mso-number-format:\'\\@\';">' . e($row->no_telepon ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->hasil_diagnosis ?? '-') . '</td>';
|
||||
echo '<td>' . e($row->jenis ?? '-') . '</td>';
|
||||
echo '</tr>';
|
||||
}
|
||||
|
||||
echo '</table></body></html>';
|
||||
}, $filename, $headers);
|
||||
}
|
||||
|
||||
public function diseaseSettings()
|
||||
{
|
||||
$existing = $this->loadDiseaseExplanations();
|
||||
$diseasesFromData = Biodata::query()
|
||||
->whereNotNull('hasil_diagnosis')
|
||||
->where('hasil_diagnosis', '!=', '')
|
||||
->pluck('hasil_diagnosis')
|
||||
->map(fn ($d) => trim((string) $d))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$diseases = collect(array_merge($diseasesFromData, array_keys($existing)))
|
||||
->map(fn ($d) => trim((string) $d))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('admin.disease-settings', [
|
||||
'diseases' => $diseases,
|
||||
'descriptions' => $existing,
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveDiseaseSettings(Request $request)
|
||||
{
|
||||
$items = $request->input('descriptions', []);
|
||||
$normalized = [];
|
||||
|
||||
if (is_array($items)) {
|
||||
foreach ($items as $name => $description) {
|
||||
$diseaseName = trim((string) $name);
|
||||
if ($diseaseName === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$desc = trim((string) $description);
|
||||
if ($desc === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[$diseaseName] = $desc;
|
||||
}
|
||||
}
|
||||
|
||||
$path = $this->diseaseExplanationPath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($normalized, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
return redirect()
|
||||
->route('admin.disease.settings')
|
||||
->with('success', 'Penjelasan penyakit berhasil disimpan.');
|
||||
}
|
||||
|
||||
private function diseaseExplanationPath(): string
|
||||
{
|
||||
return storage_path('app/disease_explanations.json');
|
||||
}
|
||||
|
||||
private function loadDiseaseExplanations(): array
|
||||
{
|
||||
$path = $this->diseaseExplanationPath();
|
||||
if (!file_exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
public function faqSettings()
|
||||
{
|
||||
return view('admin.faq-settings', [
|
||||
'faqs' => $this->loadFaqItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveFaqSettings(Request $request)
|
||||
{
|
||||
$questions = $request->input('questions', []);
|
||||
$answers = $request->input('answers', []);
|
||||
|
||||
$faqs = [];
|
||||
if (is_array($questions) && is_array($answers)) {
|
||||
$count = max(count($questions), count($answers));
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$q = trim((string)($questions[$i] ?? ''));
|
||||
$a = trim((string)($answers[$i] ?? ''));
|
||||
if ($q === '' || $a === '') {
|
||||
continue;
|
||||
}
|
||||
$faqs[] = [
|
||||
'question' => $q,
|
||||
'answer' => $a,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$path = $this->faqPath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($faqs, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
return redirect()->route('admin.faq.settings')->with('success', 'FAQ berhasil disimpan.');
|
||||
}
|
||||
|
||||
public function faqPage()
|
||||
{
|
||||
return view('faq', [
|
||||
'faqs' => $this->loadFaqItems(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function trainingDataSettings()
|
||||
{
|
||||
$featureCols = $this->loadFeatureCols();
|
||||
$items = collect($this->loadTrainingItems())
|
||||
->map(fn ($item) => $this->normalizeTrainingItem($item, $featureCols))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return view('admin.training-data-settings', [
|
||||
'items' => $items,
|
||||
'featureCols' => $featureCols,
|
||||
'featureCount' => count($featureCols),
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveTrainingDataSettings(Request $request)
|
||||
{
|
||||
$ids = $request->input('id', []);
|
||||
$diseases = $request->input('disease', []);
|
||||
$categories = $request->input('category', []);
|
||||
$featureCols = $this->loadFeatureCols();
|
||||
$existingById = collect($this->loadTrainingItems())
|
||||
->keyBy(fn ($it) => (string)($it['id'] ?? ''));
|
||||
|
||||
$items = [];
|
||||
$count = max(count($ids), count($diseases), count($categories));
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$id = trim((string)($ids[$i] ?? ''));
|
||||
$disease = trim((string)($diseases[$i] ?? ''));
|
||||
$category = trim((string)($categories[$i] ?? ''));
|
||||
|
||||
if ($disease === '' && $category === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = $id !== '' ? ($existingById->get($id) ?? []) : [];
|
||||
if ($id === '') {
|
||||
$id = (string) Str::uuid();
|
||||
}
|
||||
$samples = is_array($existing['symptom_samples'] ?? null) ? $existing['symptom_samples'] : [];
|
||||
$samples = $this->normalizeSymptomSamples($samples, $featureCols);
|
||||
$filledSamples = $this->countFilledSamples($samples, $featureCols);
|
||||
|
||||
$items[] = [
|
||||
'id' => $id,
|
||||
'disease' => $disease,
|
||||
'category' => $category,
|
||||
'symptom_samples' => $samples,
|
||||
'samples' => $filledSamples,
|
||||
'status' => $filledSamples >= 10 ? 'ready-train' : 'need-samples',
|
||||
'updated_at' => now()->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
$path = $this->trainingItemsPath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
|
||||
return redirect()->route('admin.training.settings')->with('success', 'Data calon training berhasil disimpan.');
|
||||
}
|
||||
|
||||
public function editTrainingSymptoms(string $id)
|
||||
{
|
||||
$featureCols = $this->loadFeatureCols();
|
||||
$items = collect($this->loadTrainingItems())
|
||||
->map(fn ($item) => $this->normalizeTrainingItem($item, $featureCols));
|
||||
$item = $items->first(fn ($it) => (string)($it['id'] ?? '') === $id);
|
||||
|
||||
if (!$item) {
|
||||
return redirect()->route('admin.training.settings')->with('error', 'Data penyakit tidak ditemukan.');
|
||||
}
|
||||
|
||||
return view('admin.training-symptoms-settings', [
|
||||
'item' => $item,
|
||||
'featureCols' => $featureCols,
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveTrainingSymptoms(Request $request, string $id)
|
||||
{
|
||||
$featureCols = $this->loadFeatureCols();
|
||||
$all = collect($this->loadTrainingItems())
|
||||
->map(fn ($item) => $this->normalizeTrainingItem($item, $featureCols))
|
||||
->values();
|
||||
|
||||
$idx = $all->search(fn ($it) => (string)($it['id'] ?? '') === $id);
|
||||
if ($idx === false) {
|
||||
return redirect()->route('admin.training.settings')->with('error', 'Data penyakit tidak ditemukan.');
|
||||
}
|
||||
|
||||
$rowsInput = $request->input('sample_rows', []);
|
||||
$samples = [];
|
||||
for ($r = 0; $r < 10; $r++) {
|
||||
$row = (isset($rowsInput[$r]) && is_array($rowsInput[$r])) ? $rowsInput[$r] : [];
|
||||
$sample = [];
|
||||
foreach ($featureCols as $col) {
|
||||
$sample[$col] = isset($row[$col]) && (string)$row[$col] === '1' ? 'Ya' : 'Tidak';
|
||||
}
|
||||
$samples[] = $sample;
|
||||
}
|
||||
|
||||
$item = $all[$idx];
|
||||
$filled = $this->countFilledSamples($samples, $featureCols);
|
||||
$item['symptom_samples'] = $samples;
|
||||
$item['samples'] = $filled;
|
||||
$item['status'] = $filled >= 10 ? 'ready-train' : 'need-samples';
|
||||
$item['updated_at'] = now()->toDateTimeString();
|
||||
$all[$idx] = $item;
|
||||
|
||||
$this->storeTrainingItems($all->all());
|
||||
|
||||
return redirect()->route('admin.training.settings')->with('success', 'Data gejala per sample berhasil disimpan.');
|
||||
}
|
||||
|
||||
public function downloadTrainingData()
|
||||
{
|
||||
$featureCols = $this->loadFeatureCols();
|
||||
$items = collect($this->loadTrainingItems())
|
||||
->map(fn ($item) => $this->normalizeTrainingItem($item, $featureCols))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$filename = 'data-calon-training-pawmedic-' . now()->format('Ymd-His') . '.xls';
|
||||
$headers = [
|
||||
'Content-Type' => 'application/vnd.ms-excel; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
];
|
||||
|
||||
return response()->streamDownload(function () use ($items, $featureCols) {
|
||||
echo '<html><head><meta charset="UTF-8"></head><body>';
|
||||
echo '<table border="1" cellpadding="6" cellspacing="0">';
|
||||
echo '<tr style="background:#e8f7ef;font-weight:bold;">';
|
||||
echo '<th>No</th><th>Penyakit</th><th>Golongan</th><th>Sample Ke</th>';
|
||||
foreach ($featureCols as $col) {
|
||||
echo '<th>' . e($col) . '</th>';
|
||||
}
|
||||
echo '</tr>';
|
||||
|
||||
$no = 1;
|
||||
foreach ($items as $item) {
|
||||
$sampleRows = $this->normalizeSymptomSamples($item['symptom_samples'] ?? [], $featureCols);
|
||||
foreach ($sampleRows as $sampleIndex => $flags) {
|
||||
echo '<tr>';
|
||||
echo '<td>' . $no++ . '</td>';
|
||||
echo '<td>' . e((string)($item['disease'] ?? '')) . '</td>';
|
||||
echo '<td>' . e((string)($item['category'] ?? '')) . '</td>';
|
||||
echo '<td>' . e((string)($sampleIndex + 1)) . '</td>';
|
||||
foreach ($featureCols as $col) {
|
||||
$v = (string)($flags[$col] ?? 'Tidak');
|
||||
echo '<td>' . e(strcasecmp($v, 'Ya') === 0 ? 'Ya' : 'Tidak') . '</td>';
|
||||
}
|
||||
echo '</tr>';
|
||||
}
|
||||
}
|
||||
echo '</table></body></html>';
|
||||
}, $filename, $headers);
|
||||
}
|
||||
|
||||
private function trainingItemsPath(): string
|
||||
{
|
||||
return storage_path('app/training_items.json');
|
||||
}
|
||||
|
||||
private function loadTrainingItems(): array
|
||||
{
|
||||
$path = $this->trainingItemsPath();
|
||||
if (!file_exists($path)) {
|
||||
return [];
|
||||
}
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
private function storeTrainingItems(array $items): void
|
||||
{
|
||||
$path = $this->trainingItemsPath();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
File::makeDirectory($dir, 0755, true);
|
||||
}
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($items, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeTrainingItem($item, array $featureCols): array
|
||||
{
|
||||
$base = is_array($item) ? $item : [];
|
||||
$id = trim((string)($base['id'] ?? ''));
|
||||
if ($id === '') {
|
||||
$id = (string) Str::uuid();
|
||||
}
|
||||
|
||||
$samples = $this->normalizeSymptomSamples($base['symptom_samples'] ?? [], $featureCols);
|
||||
$filled = $this->countFilledSamples($samples, $featureCols);
|
||||
$status = $filled >= 10 ? 'ready-train' : 'need-samples';
|
||||
|
||||
// Migrasi data lama (symptom_flags tunggal) ke baris sample pertama.
|
||||
$legacyFlags = is_array($base['symptom_flags'] ?? null) ? $base['symptom_flags'] : [];
|
||||
if (!empty($legacyFlags) && $filled === 0) {
|
||||
$first = [];
|
||||
foreach ($featureCols as $col) {
|
||||
$v = (string)($legacyFlags[$col] ?? 'Tidak');
|
||||
$first[$col] = strcasecmp($v, 'Ya') === 0 ? 'Ya' : 'Tidak';
|
||||
}
|
||||
$samples[0] = $first;
|
||||
$filled = $this->countFilledSamples($samples, $featureCols);
|
||||
$status = $filled >= 10 ? 'ready-train' : 'need-samples';
|
||||
}
|
||||
|
||||
return array_merge($base, [
|
||||
'id' => $id,
|
||||
'symptom_samples' => $samples,
|
||||
'samples' => $filled,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeSymptomSamples($rows, array $featureCols): array
|
||||
{
|
||||
$list = is_array($rows) ? array_values($rows) : [];
|
||||
$normalized = [];
|
||||
|
||||
for ($r = 0; $r < 10; $r++) {
|
||||
$row = (isset($list[$r]) && is_array($list[$r])) ? $list[$r] : [];
|
||||
$sample = [];
|
||||
foreach ($featureCols as $col) {
|
||||
$v = (string)($row[$col] ?? 'Tidak');
|
||||
$sample[$col] = strcasecmp($v, 'Ya') === 0 ? 'Ya' : 'Tidak';
|
||||
}
|
||||
$normalized[] = $sample;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function countFilledSamples(array $samples, array $featureCols): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($samples as $row) {
|
||||
$hasYes = false;
|
||||
foreach ($featureCols as $col) {
|
||||
if (strcasecmp((string)($row[$col] ?? 'Tidak'), 'Ya') === 0) {
|
||||
$hasYes = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($hasYes) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function downloadTrainingTemplate()
|
||||
{
|
||||
$featureCols = $this->loadFeatureCols();
|
||||
$filename = 'template-training-pawmedic-' . now()->format('Ymd-His') . '.csv';
|
||||
|
||||
$headers = [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
|
||||
];
|
||||
|
||||
return response()->streamDownload(function () use ($featureCols) {
|
||||
$handle = fopen('php://output', 'w');
|
||||
fwrite($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
|
||||
|
||||
$cols = array_values(array_filter($featureCols, fn($c) => trim((string)$c) !== ''));
|
||||
$header = array_merge($cols, ['Golongan', 'Penyakit']);
|
||||
fputcsv($handle, $header, ';');
|
||||
|
||||
// Dua baris contoh isi sebagai panduan format ya/tidak.
|
||||
$rowYa = array_fill(0, count($cols), 'Ya');
|
||||
$rowTidak = array_fill(0, count($cols), 'Tidak');
|
||||
fputcsv($handle, array_merge($rowYa, ['ContohKategori', 'Contoh Penyakit A']), ';');
|
||||
fputcsv($handle, array_merge($rowTidak, ['ContohKategori', 'Contoh Penyakit B']), ';');
|
||||
|
||||
fclose($handle);
|
||||
}, $filename, $headers);
|
||||
}
|
||||
|
||||
private function loadFeatureCols(): array
|
||||
{
|
||||
$path = base_path('python_artifacts/feature_cols.json');
|
||||
if (!file_exists($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
if (!is_array($decoded)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($decoded, function ($col) {
|
||||
$name = trim((string)$col);
|
||||
if ($name === '') return false;
|
||||
if (stripos($name, 'Unnamed:') === 0) return false;
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
private function faqPath(): string
|
||||
{
|
||||
return storage_path('app/faqs.json');
|
||||
}
|
||||
|
||||
private function defaultFaqItems(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'question' => 'Apa itu PawMedic?',
|
||||
'answer' => 'PawMedic adalah aplikasi sistem pakar yang membantu pemilik kucing memahami gejala dan mendapatkan rekomendasi perawatan awal. Aplikasi ini menggunakan metode sistem pakar untuk menganalisis gejala yang dipilih dan memberikan diagnosis kemungkinan penyakit.',
|
||||
],
|
||||
[
|
||||
'question' => 'Bagaimana cara menggunakan PawMedic?',
|
||||
'answer' => "Cara menggunakan PawMedic sangat mudah:\n1. Isi biodata kucing Anda\n2. Pilih gejala yang Anda amati pada kucing\n3. Sistem akan menganalisis dan memberikan hasil diagnosis\n4. Baca rekomendasi perawatan yang diberikan",
|
||||
],
|
||||
[
|
||||
'question' => 'Apakah hasil diagnosis akurat?',
|
||||
'answer' => 'Hasil diagnosis dari PawMedic adalah sebagai panduan awal berdasarkan gejala yang Anda pilih. Untuk diagnosis yang akurat dan penanganan yang tepat, sangat disarankan untuk berkonsultasi langsung dengan dokter hewan profesional. PawMedic tidak menggantikan konsultasi medis profesional.',
|
||||
],
|
||||
[
|
||||
'question' => 'Apakah data saya aman?',
|
||||
'answer' => 'Ya, data yang Anda masukkan hanya digunakan untuk keperluan diagnosis dan tidak dibagikan kepada pihak ketiga.',
|
||||
],
|
||||
[
|
||||
'question' => 'Berapa banyak gejala yang harus dipilih?',
|
||||
'answer' => 'Pilih gejala yang benar-benar Anda amati. Semakin relevan gejala yang dipilih, semakin baik hasil analisis.',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function loadFaqItems(): array
|
||||
{
|
||||
$path = $this->faqPath();
|
||||
if (!file_exists($path)) {
|
||||
return $this->defaultFaqItems();
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) file_get_contents($path), true);
|
||||
if (!is_array($decoded) || empty($decoded)) {
|
||||
return $this->defaultFaqItems();
|
||||
}
|
||||
|
||||
$faqs = [];
|
||||
foreach ($decoded as $item) {
|
||||
$q = trim((string)($item['question'] ?? ''));
|
||||
$a = trim((string)($item['answer'] ?? ''));
|
||||
if ($q === '' || $a === '') {
|
||||
continue;
|
||||
}
|
||||
$faqs[] = ['question' => $q, 'answer' => $a];
|
||||
}
|
||||
|
||||
return !empty($faqs) ? $faqs : $this->defaultFaqItems();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use App\Models\Biodata;
|
||||
class DiagnosisController extends Controller
|
||||
{
|
||||
public function prosesDiagnosis(Request $request)
|
||||
{
|
||||
$rawInput = $request->input('gejala', []);
|
||||
$input = $rawInput;
|
||||
if (is_string($rawInput)) {
|
||||
$decoded = json_decode($rawInput, true);
|
||||
if (is_array($decoded)) {
|
||||
$input = $decoded;
|
||||
} else {
|
||||
$input = array_filter(array_map('trim', explode(',', $rawInput)));
|
||||
}
|
||||
}
|
||||
if (!is_array($input)) {
|
||||
$input = [];
|
||||
}
|
||||
|
||||
// validasi minimal 3 gejala
|
||||
if (count($input) < 3) {
|
||||
return redirect()->route('gejala')
|
||||
->with('error', 'Pilih minimal 3 gejala!');
|
||||
}
|
||||
|
||||
$inputNama = $input;
|
||||
|
||||
// ambil fitur dari Python
|
||||
$response = Http::get(env('API_MODEL') . '/gejala');
|
||||
|
||||
if (!$response->successful()) {
|
||||
return redirect()->route('gejala')
|
||||
->with('error', 'Tidak bisa mengambil data gejala dari API');
|
||||
}
|
||||
|
||||
$featureCols = $response->json();
|
||||
|
||||
// ubah ke format 1/0
|
||||
$fiturAssoc = [];
|
||||
|
||||
foreach ($featureCols as $col) {
|
||||
$fiturAssoc[$col] = in_array(trim($col), array_map('trim', $inputNama)) ? 1 : 0;
|
||||
}
|
||||
|
||||
// kirim ke Python API
|
||||
$response = Http::post(env('API_MODEL') . '/predict', $fiturAssoc);
|
||||
|
||||
if (!$response->successful()) {
|
||||
return redirect()->route('gejala')
|
||||
->with('error', 'Server AI tidak merespon!');
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
// 🔥 ambil semua hasil dari Python
|
||||
$diagnosis = [
|
||||
'nama' => $data['penyakit'] ?? '-',
|
||||
'kategori' => $data['jenis'] ?? '-',
|
||||
'pertolongan' => $data['pertolongan'] ?? [],
|
||||
'pencegahan' => $data['pencegahan'] ?? [],
|
||||
];
|
||||
|
||||
$biodataId = session('biodata_id');
|
||||
if ($biodataId) {
|
||||
\App\Models\Biodata::where('id', $biodataId)->update([
|
||||
'hasil_diagnosis' => $diagnosis['nama'],
|
||||
'jenis' => $diagnosis['kategori'],
|
||||
'gejala_dipilih' => json_encode(array_values($inputNama), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
||||
]);
|
||||
}
|
||||
|
||||
session([
|
||||
'diagnosis' => $diagnosis,
|
||||
'gejala' => $inputNama,
|
||||
]);
|
||||
|
||||
return redirect()->route('hasil-diagnosis');
|
||||
}
|
||||
|
||||
// 🔥 halaman hasil
|
||||
public function hasil()
|
||||
{
|
||||
$diagnosis = session('diagnosis', []);
|
||||
$diseaseName = trim((string)($diagnosis['nama'] ?? ''));
|
||||
$description = $this->getDiseaseDescription($diseaseName);
|
||||
$history = collect();
|
||||
|
||||
$biodataId = session('biodata_id');
|
||||
if ($biodataId) {
|
||||
$current = Biodata::find($biodataId);
|
||||
$phone = trim((string)($current->no_telepon ?? ''));
|
||||
if ($phone !== '') {
|
||||
$history = Biodata::query()
|
||||
->where('no_telepon', $phone)
|
||||
->whereNotNull('hasil_diagnosis')
|
||||
->orderByDesc('created_at')
|
||||
->take(10)
|
||||
->get(['nama_kucing', 'hasil_diagnosis', 'created_at']);
|
||||
}
|
||||
}
|
||||
|
||||
return view('hasil-diagnosis', [
|
||||
'diseaseDescription' => $description,
|
||||
'diagnosisHistory' => $history,
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadPdf()
|
||||
{
|
||||
$diagnosis = session('diagnosis', []);
|
||||
$gejala = session('gejala', []);
|
||||
$diseaseName = trim((string)($diagnosis['nama'] ?? ''));
|
||||
$description = $this->getDiseaseDescription($diseaseName);
|
||||
$generatedAt = now()->format('d M Y H:i');
|
||||
|
||||
$pdf = Pdf::loadView('pdf.hasil-diagnosis-pdf', [
|
||||
'diagnosis' => $diagnosis,
|
||||
'gejala' => is_array($gejala) ? $gejala : [],
|
||||
'diseaseDescription' => $description,
|
||||
'generatedAt' => $generatedAt,
|
||||
])->setPaper('a4', 'portrait');
|
||||
|
||||
$filename = 'hasil-diagnosis-pawmedic-' . now()->format('Ymd-His') . '.pdf';
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
|
||||
public function simpanBiodata(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'nama_pemilik' => 'required',
|
||||
'nama_kucing' => 'required',
|
||||
'umur_kucing' => 'required|numeric',
|
||||
'jenis_kelamin' => 'required',
|
||||
'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([
|
||||
'nama_pemilik' => $request->nama_pemilik,
|
||||
'nama_kucing' => $request->nama_kucing,
|
||||
'umur_kucing' => $request->umur_kucing,
|
||||
'jenis_kelamin' => $request->jenis_kelamin,
|
||||
'berat_badan' => $request->berat_badan,
|
||||
'ras_kucing' => $request->ras_kucing,
|
||||
'alamat' => $request->alamat,
|
||||
'no_telepon' => $request->no_telepon,
|
||||
]);
|
||||
|
||||
session(['biodata_id' => $data->id]);
|
||||
|
||||
return redirect()->route('gejala');
|
||||
}
|
||||
|
||||
private function getDiseaseDescription(string $diseaseName): string
|
||||
{
|
||||
if ($diseaseName === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path = storage_path('app/disease_explanations.json');
|
||||
if (!file_exists($path)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)file_get_contents($path), true);
|
||||
if (!is_array($decoded)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
foreach ($decoded as $name => $description) {
|
||||
if (trim((string)$name) === $diseaseName) {
|
||||
return trim((string)$description);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
class GejalaController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$response = Http::get(env('API_MODEL') . '/gejala');
|
||||
if (!$response->successful()) {
|
||||
return back()->with('error', 'Tidak bisa mengambil data gejala dari API');
|
||||
}
|
||||
$gejala = $response->json();
|
||||
return view('gejala', compact('gejala'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Ulasan;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class UlasanController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$query = Ulasan::query()->latest();
|
||||
$isAdmin = Auth::check() && Auth::user()->email === 'admin@pawmedic.app';
|
||||
if (!$isAdmin) {
|
||||
$query->where('is_hidden', false);
|
||||
}
|
||||
$ulasan = $query->get();
|
||||
// 🔥 TOTAL ULASAN
|
||||
$total = $ulasan->count();
|
||||
// 🔥 RATING RATA-RATA
|
||||
$avg = $ulasan->avg('rating') ?? 0;
|
||||
// 🔥 5 BINTANG %
|
||||
$fiveStar = $ulasan->where('rating', 5)->count();
|
||||
$fivePercent = $total > 0 ? round(($fiveStar / $total) * 100) : 0;
|
||||
|
||||
return view('ulasan', compact('ulasan', 'total', 'avg', 'fivePercent'));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
Ulasan::create($request->all());
|
||||
|
||||
return redirect()->back()->with('success', 'Ulasan berhasil dikirim!');
|
||||
}
|
||||
public function destroy($id)
|
||||
{
|
||||
if (!Auth::check() || Auth::user()->email !== 'admin@pawmedic.app') {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ulasan = Ulasan::findOrFail($id);
|
||||
$ulasan->delete();
|
||||
|
||||
return redirect()->back()->with('success', 'Ulasan berhasil dihapus');
|
||||
}
|
||||
|
||||
public function toggleHide($id)
|
||||
{
|
||||
if (!Auth::check() || Auth::user()->email !== 'admin@pawmedic.app') {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$ulasan = Ulasan::findOrFail($id);
|
||||
$ulasan->is_hidden = !$ulasan->is_hidden;
|
||||
$ulasan->save();
|
||||
|
||||
return redirect()->back()->with(
|
||||
'success',
|
||||
$ulasan->is_hidden ? 'Ulasan disembunyikan.' : 'Ulasan ditampilkan kembali.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Biodata extends Model
|
||||
{
|
||||
protected $table = 'biodata';
|
||||
|
||||
protected $fillable = [
|
||||
'nama_pemilik',
|
||||
'nama_kucing',
|
||||
'umur_kucing',
|
||||
'jenis_kelamin',
|
||||
'berat_badan',
|
||||
'ras_kucing',
|
||||
'alamat',
|
||||
'no_telepon',
|
||||
'hasil_diagnosis',
|
||||
'jenis',
|
||||
'gejala_dipilih',
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Gejala extends Model
|
||||
{
|
||||
protected $table = 'gejala';
|
||||
public $timestamps = false;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Ulasan extends Model
|
||||
{
|
||||
protected $table = 'ulasans';
|
||||
protected $fillable = [
|
||||
'nama',
|
||||
'nama_kucing',
|
||||
'hasil_diagnosis',
|
||||
'rating',
|
||||
'komentar',
|
||||
'is_hidden',
|
||||
];
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "octane",
|
||||
| "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => 'prefer',
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => env('APP_URL').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1 @@
|
|||
*.sqlite*
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('biodata', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nama_pemilik');
|
||||
$table->string('nama_kucing');
|
||||
$table->integer('umur_kucing');
|
||||
$table->string('jenis_kelamin');
|
||||
$table->float('berat_badan');
|
||||
$table->string('ras_kucing')->nullable();
|
||||
$table->text('alamat')->nullable();
|
||||
$table->string('no_telepon')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('biodata');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('biodata', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('biodata', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('biodata', function (Blueprint $table) {
|
||||
$table->string('hasil_diagnosis')->nullable();
|
||||
$table->string('jenis')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('biodata', function (Blueprint $table) {
|
||||
$table->dropColumn(['hasil_diagnosis', 'jenis']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ulasans', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ulasans');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('biodata', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('biodata', 'gejala_dipilih')) {
|
||||
$table->longText('gejala_dipilih')->nullable()->after('jenis');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('biodata', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('biodata', 'gejala_dipilih')) {
|
||||
$table->dropColumn('gejala_dipilih');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('ulasans', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('ulasans', 'is_hidden')) {
|
||||
$table->boolean('is_hidden')->default(false)->after('komentar');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('ulasans', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('ulasans', 'is_hidden')) {
|
||||
$table->dropColumn('is_hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Create Admin User
|
||||
User::query()->updateOrCreate(
|
||||
['email' => 'admin@pawmedic.app'],
|
||||
[
|
||||
'name' => 'Admin PawMedic',
|
||||
'password' => Hash::make('admin123'),
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->call(BiodataTrendSeeder::class);
|
||||
|
||||
// Optional: Create test user
|
||||
// User::factory()->create([
|
||||
// 'name' => 'Test User',
|
||||
// 'email' => 'test@example.com',
|
||||
// ]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
Binary file not shown.
|
|
@ -0,0 +1,15 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#6fcf97"/>
|
||||
<stop offset="100%" stop-color="#4bb66f"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||
<g fill="#ffffff">
|
||||
<circle cx="21" cy="23" r="6"/>
|
||||
<circle cx="32" cy="18" r="6"/>
|
||||
<circle cx="43" cy="23" r="6"/>
|
||||
<path d="M32 29c-8.5 0-14 6.1-14 12.2 0 5.6 4.6 9.8 10 9.8 3.3 0 4.6-1.8 6-1.8s2.7 1.8 6 1.8c5.4 0 10-4.2 10-9.8C50 35.1 40.5 29 32 29z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
from flask import Flask, request, jsonify
|
||||
import joblib
|
||||
import json
|
||||
import pandas as pd
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# =========================
|
||||
# LOAD MODEL
|
||||
# =========================
|
||||
model = joblib.load("../python_artifacts/model.joblib")
|
||||
|
||||
# =========================
|
||||
# LOAD FEATURE/GEJALA
|
||||
# =========================
|
||||
with open("../python_artifacts/feature_cols.json") as f:
|
||||
feature_cols = json.load(f)
|
||||
|
||||
# =========================
|
||||
# LOAD EXCEL
|
||||
# =========================
|
||||
df_info = pd.read_excel("../public/data/Bissmilah lagi.xlsx")
|
||||
|
||||
# bersihin data
|
||||
df_info.columns = df_info.columns.str.strip()
|
||||
|
||||
for col in df_info.columns:
|
||||
df_info[col] = df_info[col].astype(str).str.strip()
|
||||
|
||||
df_info['Penyakit'] = df_info['Penyakit'].str.lower()
|
||||
|
||||
# buat dictionary
|
||||
info_penyakit = {}
|
||||
|
||||
for _, row in df_info.iterrows():
|
||||
info_penyakit[row['Penyakit']] = {
|
||||
"jenis": row['Jenis'],
|
||||
"pertolongan": [p.strip() for p in row['Pertolongan'].split(";")],
|
||||
"pencegahan": [p.strip() for p in row['Pencegahan'].split(";")]
|
||||
}
|
||||
|
||||
print("DATA PENYAKIT:", info_penyakit.keys())
|
||||
|
||||
# =========================
|
||||
# ENDPOINT GEJALA
|
||||
# =========================
|
||||
@app.route('/gejala', methods=['GET'])
|
||||
def get_gejala():
|
||||
return jsonify(feature_cols)
|
||||
|
||||
# =========================
|
||||
# ENDPOINT PREDICT
|
||||
# =========================
|
||||
@app.route('/predict', methods=['POST'])
|
||||
def predict():
|
||||
data = request.json
|
||||
|
||||
print("INPUT FROM LARAVEL:", data)
|
||||
|
||||
input_data = []
|
||||
|
||||
for col in feature_cols:
|
||||
val = data.get(col, 0)
|
||||
input_data.append(1 if str(val) == "1" else 0)
|
||||
|
||||
print("INPUT VECTOR:", input_data)
|
||||
|
||||
input_df = pd.DataFrame([input_data], columns=feature_cols)
|
||||
#melakukan prediksi
|
||||
hasil = model.predict(input_df)[0]
|
||||
penyakit = str(hasil).lower().strip()
|
||||
|
||||
print("HASIL MODEL:", hasil)
|
||||
print("CARI DI EXCEL:", penyakit)
|
||||
|
||||
info = info_penyakit.get(penyakit)
|
||||
|
||||
if not info:
|
||||
return jsonify({
|
||||
"penyakit": hasil,
|
||||
"jenis": "-",
|
||||
"pertolongan": [],
|
||||
"pencegahan": []
|
||||
})
|
||||
#mengembalikan hasil prediksi ke laravel
|
||||
return jsonify({
|
||||
"penyakit": hasil,
|
||||
"jenis": info["jenis"],
|
||||
"pertolongan": info["pertolongan"],
|
||||
"pencegahan": info["pencegahan"]
|
||||
})
|
||||
|
||||
# =========================
|
||||
# RUN APP
|
||||
# =========================
|
||||
if __name__ == '__main__':
|
||||
print("API MODEL SIAP DIGUNAKAN 🔥")
|
||||
app.run(debug=True)
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"Cacingan": "Parasit", "Diare Non Spesifik": "Virus / Parasit", "Diare Spesifik": "Virus / Parasit", "Earmite": "Parasit", "FLUTD (Feline Lower Urinary Tract Diseases)": "Virus / Lingkungan", "Feline Infectious Peritonitis": "Virus", "Feline calicivirus": "Virus", "Feline herpesvirus/rhinotracheitis": "Virus", "Feline panleukopenia ": "Virus", "Jamur/Ringworm": "Parasit", "Penyakit kutu dan pinjal serta gejala alergi kulit yang menyerta": "Parasit", "Scabies": "Parasit", "URI (upper respirstory infection) bakterial": "Virus"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
["Demam Tinggi", "Muntah", "Nafsu Makan Turun", "Kelemahan", "Radang Telinga / Otitis", "Pincang", "Luka pada Mulut", "Gangguan Mata", "Flu / Pilek (Gangguan Hidung)", "Sesak Napas", "Batuk", "Selaput Lendir Kuning (Jaundice)", "Perut Membesar/Buncit", "Anemia", "Diare", "Diare Berdarah", "Luka Garukan / Gatal", "Kebotakan / Rontok", "Infeksi Kulit", "Kutu / Pinjal Terlihat", "Overgrooming (Menjilat Berlebihan)", "Sulit Kencing / Sering Berjongkok Lama di Kotak Pasir", "Perut Bawah Keras", "Sakit Perut / Nyeri Abdomen", "Penurunan Berat Badan Cepat"]
|
||||
|
|
@ -0,0 +1 @@
|
|||
["Demam Tinggi", "Muntah", "Nafsu Makan Turun", "Kelemahan", "Dehidrasi", "Pincang", "Sesak Napas", "Batuk", "Selaput Lendir Kuning (Jaundice)", "Anemia", "Diare", "Diare Berdarah", "Kutu / Pinjal Terlihat", "Overgrooming (Menjilat Berlebihan)", "Perut Bawah Keras", "Sakit Perut / Nyeri Abdomen", "Penurunan Berat Badan Cepat"]
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,11 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
import './bootstrap';
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
|
@ -0,0 +1,967 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard Admin - 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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--ff-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--primary: #6fcf97;
|
||||
--primary-dark: #4bb66f;
|
||||
--primary-light: #e8f7ef;
|
||||
--text-dark: #114d3a;
|
||||
--text-muted: #64748b;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--info: #3b82f6;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg, #f0fdf4 0%, #eaf7f0 50%, #f0f9ff 100%);
|
||||
background-attachment:fixed;
|
||||
color:#333;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.6;
|
||||
min-height:100vh;
|
||||
}
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
.admin-header{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-bottom:1px solid rgba(111,207,151,0.2);
|
||||
padding:16px 0;
|
||||
position:sticky;
|
||||
top:0;
|
||||
z-index:100;
|
||||
box-shadow:0 2px 12px rgba(17,77,58,0.08);
|
||||
}
|
||||
|
||||
.header-content{
|
||||
max-width:1400px;
|
||||
margin:0 auto;
|
||||
padding:0 32px;
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
.logo-section{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.logo-icon{
|
||||
width:44px;
|
||||
height:44px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:12px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:22px;
|
||||
color:#fff;
|
||||
}
|
||||
|
||||
.logo-text{
|
||||
font-size:20px;
|
||||
font-weight:700;
|
||||
font-family:var(--ff-heading);
|
||||
color:var(--text-dark);
|
||||
}
|
||||
|
||||
.user-menu{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:16px;
|
||||
}
|
||||
|
||||
.user-info{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.avatar{
|
||||
width:40px;
|
||||
height:40px;
|
||||
border-radius:50%;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color:white;
|
||||
font-weight:700;
|
||||
font-size:16px;
|
||||
}
|
||||
|
||||
.logout-btn{
|
||||
padding:10px 20px;
|
||||
background:white;
|
||||
border:2px solid var(--primary);
|
||||
border-radius:10px;
|
||||
color:var(--primary-dark);
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
font-size:14px;
|
||||
transition:all 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover{
|
||||
background:var(--primary-light);
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
|
||||
/* ===== MAIN CONTENT ===== */
|
||||
.container{
|
||||
max-width:1400px;
|
||||
margin:0 auto;
|
||||
padding:40px 32px;
|
||||
}
|
||||
|
||||
.page-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:32px;
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:32px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
|
||||
.admin-shortcuts{
|
||||
display:flex;
|
||||
gap:10px;
|
||||
flex-wrap:wrap;
|
||||
margin-bottom:22px;
|
||||
}
|
||||
|
||||
.admin-shortcut{
|
||||
text-decoration:none;
|
||||
padding:9px 14px;
|
||||
border-radius:999px;
|
||||
border:1px solid rgba(111,207,151,0.35);
|
||||
background:#fff;
|
||||
color:var(--text-dark);
|
||||
font-weight:600;
|
||||
font-size:13px;
|
||||
}
|
||||
|
||||
.admin-shortcut i{
|
||||
margin-right:6px;
|
||||
}
|
||||
|
||||
/* ===== STATS GRID ===== */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:20px;
|
||||
padding:28px;
|
||||
box-shadow:0 8px 24px rgba(17,77,58,0.1);
|
||||
border:1px solid rgba(111,207,151,0.15);
|
||||
transition:all 0.3s ease;
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.stat-card::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
height:4px;
|
||||
background:linear-gradient(90deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
}
|
||||
|
||||
.stat-card:hover{
|
||||
transform:translateY(-4px);
|
||||
box-shadow:0 12px 32px rgba(17,77,58,0.15);
|
||||
}
|
||||
|
||||
.stat-header{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:flex-start;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
|
||||
.stat-icon{
|
||||
width:56px;
|
||||
height:56px;
|
||||
border-radius:14px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:28px;
|
||||
background:var(--primary-light);
|
||||
}
|
||||
|
||||
.stat-value{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:36px;
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:4px;
|
||||
}
|
||||
|
||||
.stat-label{
|
||||
font-size:14px;
|
||||
color:var(--text-muted);
|
||||
font-weight:600;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.5px;
|
||||
}
|
||||
|
||||
.stat-change{
|
||||
font-size:13px;
|
||||
color:var(--primary-dark);
|
||||
font-weight:600;
|
||||
margin-top:8px;
|
||||
}
|
||||
|
||||
/* ===== DATA TABLE ===== */
|
||||
.data-section{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:24px;
|
||||
padding:32px;
|
||||
box-shadow:0 8px 24px rgba(17,77,58,0.1);
|
||||
border:1px solid rgba(111,207,151,0.15);
|
||||
margin-bottom:32px;
|
||||
}
|
||||
|
||||
.section-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:22px;
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:24px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.table-wrap{
|
||||
width:100%;
|
||||
max-height:420px;
|
||||
overflow:auto;
|
||||
border:1px solid #e2e8f0;
|
||||
border-radius:14px;
|
||||
}
|
||||
|
||||
.table-controls{
|
||||
display:flex;
|
||||
gap:10px;
|
||||
margin-bottom:12px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
.form-control{
|
||||
padding:10px 12px;
|
||||
border:1px solid #cbd5e1;
|
||||
border-radius:10px;
|
||||
background:#fff;
|
||||
font-size:14px;
|
||||
min-width:180px;
|
||||
}
|
||||
|
||||
.btn-export{
|
||||
padding:10px 14px;
|
||||
border-radius:10px;
|
||||
border:1px solid var(--primary);
|
||||
background:var(--primary-light);
|
||||
color:var(--text-dark);
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.table{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
}
|
||||
|
||||
.table th{
|
||||
text-align:left;
|
||||
padding:16px;
|
||||
background:var(--primary-light);
|
||||
color:var(--text-dark);
|
||||
font-weight:700;
|
||||
font-size:14px;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.5px;
|
||||
border-bottom:2px solid var(--primary);
|
||||
}
|
||||
|
||||
.table td{
|
||||
padding:16px;
|
||||
border-bottom:1px solid var(--border, #e2e8f0);
|
||||
color:var(--text-dark);
|
||||
}
|
||||
|
||||
.table tr:hover{
|
||||
background:var(--primary-light);
|
||||
}
|
||||
|
||||
.badge{
|
||||
padding:6px 12px;
|
||||
border-radius:20px;
|
||||
font-size:12px;
|
||||
font-weight:600;
|
||||
display:inline-block;
|
||||
}
|
||||
|
||||
.badge-success{
|
||||
background:var(--primary-light);
|
||||
color:var(--primary-dark);
|
||||
}
|
||||
|
||||
.badge-warning{
|
||||
background:#fef3c7;
|
||||
color:#92400e;
|
||||
}
|
||||
|
||||
@media(max-width:768px){
|
||||
.header-content{
|
||||
padding:0 20px;
|
||||
flex-direction:column;
|
||||
gap:16px;
|
||||
}
|
||||
|
||||
.stats-grid{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
|
||||
.container{
|
||||
padding:24px 20px;
|
||||
}
|
||||
|
||||
.table{
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td{
|
||||
padding:12px 8px;
|
||||
}
|
||||
}
|
||||
#chartBox {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- HEADER -->
|
||||
<div class="admin-header">
|
||||
<div class="header-content">
|
||||
<div class="logo-section">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">PawMedic Admin</div>
|
||||
</div>
|
||||
<div class="user-menu">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ substr(Auth::user()->name ?? 'A', 0, 1) }}</div>
|
||||
<div>
|
||||
<div style="font-weight:600; color:var(--text-dark);">{{ Auth::user()->name ?? 'Admin' }}</div>
|
||||
<div style="font-size:12px; color:var(--text-muted);">{{ Auth::user()->email ?? 'admin@pawmedic.app' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('admin.logout') }}" style="display:inline;">
|
||||
@csrf
|
||||
<button type="submit" class="logout-btn" style="border:none; background:transparent; cursor:pointer; padding:10px 20px; background:white; border:2px solid var(--primary); border-radius:10px; color:var(--primary-dark); font-weight:600; font-size:14px;">
|
||||
Keluar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<div class="container">
|
||||
<h1 class="page-title">Dashboard Admin</h1>
|
||||
<div class="admin-shortcuts">
|
||||
<a href="#" class="admin-shortcut" onclick="toggleDiagnosis(); return false;"><i class="bi bi-card-list"></i>Data Diagnosis</a>
|
||||
<a href="#" class="admin-shortcut" onclick="toggleUsers(); return false;"><i class="bi bi-people"></i>Data Pengguna</a>
|
||||
<a href="#" class="admin-shortcut" onclick="toggleChart(); return false;"><i class="bi bi-bar-chart"></i>Statistik</a>
|
||||
<a href="{{ route('admin.disease.settings') }}" class="admin-shortcut"><i class="bi bi-journal-medical"></i>Penjelasan Penyakit</a>
|
||||
<a href="{{ route('admin.faq.settings') }}" class="admin-shortcut"><i class="bi bi-wrench-adjustable-circle-fill"></i>Kelola FAQ</a>
|
||||
<a href="{{ route('admin.training.settings') }}" class="admin-shortcut"><i class="bi bi-database-add"></i>Calon Data Training</a>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="stats-grid">
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value">{{ $stats['total_diagnosis'] }}</div>
|
||||
<div class="stat-label">Total Diagnosis</div>
|
||||
</div>
|
||||
<div class="stat-icon"><i class="bi bi-bar-chart-fill"></i></div>
|
||||
</div>
|
||||
<div class="stat-change">+{{ $stats['today_diagnosis'] }} hari ini</div>
|
||||
<a href="#" onclick="toggleDiagnosis(); return false;"
|
||||
style="margin-top:10px; display:inline-block; font-size:13px; color:#4bb66f; font-weight:600;">
|
||||
Lihat Data →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value">{{ $stats['today_diagnosis'] }}</div>
|
||||
<div class="stat-label">Diagnosis Hari Ini</div>
|
||||
</div>
|
||||
<div class="stat-icon"><i class="bi bi-graph-up-arrow"></i></div>
|
||||
</div>
|
||||
<div class="stat-change">Aktif hari ini</div>
|
||||
<div class="stat-change">
|
||||
@if($stats['diagnosis_diff'] > 0)
|
||||
↑ +{{ $stats['diagnosis_diff'] }} dari kemarin
|
||||
@elseif($stats['diagnosis_diff'] < 0)
|
||||
↓ {{ $stats['diagnosis_diff'] }} dari kemarin
|
||||
@else
|
||||
Sama dengan kemarin
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px; color:#64748b; margin-top:4px;">
|
||||
Terbanyak: {{ $stats['today_top_disease'] ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value">{{ $stats['total_users'] }}</div>
|
||||
<div class="stat-label">Total Pengguna</div>
|
||||
</div>
|
||||
<div class="stat-icon"><i class="bi bi-people-fill"></i></div>
|
||||
</div>
|
||||
<div class="stat-change">Pengguna aktif</div>
|
||||
<a href="#" onclick="toggleUsers(); return false;"
|
||||
style="margin-top:10px; display:inline-block; font-size:13px; color:#4bb66f; font-weight:600;">
|
||||
Lihat Data →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<div>
|
||||
<div class="stat-value" style="font-size:24px;">{{ $stats['most_common_disease'] }}</div>
|
||||
<div class="stat-label">Penyakit Paling Umum</div>
|
||||
</div>
|
||||
<div class="stat-icon"><i class="bi bi-heart-pulse-fill"></i></div>
|
||||
</div>
|
||||
<div class="stat-change">Paling sering didiagnosis</div>
|
||||
|
||||
<a href="#" onclick="toggleChart(); return false;"
|
||||
style="margin-top:10px; display:inline-block; font-size:13px; color:#4bb66f; font-weight:600;">
|
||||
Lihat Data →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chartBox" style="display:none; margin-top:20px;">
|
||||
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap: 20px;">
|
||||
<div class="data-section">
|
||||
<div class="section-title">📊 Statistik Penyakit</div>
|
||||
<div style="max-width:600px; margin:auto;">
|
||||
<canvas id="chartPenyakit"></canvas></div>
|
||||
</div>
|
||||
|
||||
<div class="data-section">
|
||||
<div class="section-title">⭐ Distribusi Rating Ulasan</div>
|
||||
<div style="max-width:400px; margin:auto;">
|
||||
<canvas id="chartRating" height="250"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="data-section">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<div class="section-title">📈 Tren Diagnosis</div>
|
||||
<select id="trendMode" class="form-control" style="max-width:260px;">
|
||||
<option value="7d">7 Hari Terakhir</option>
|
||||
<option value="month">Per Bulan (Tahun Ini)</option>
|
||||
<option value="all">Seluruh Periode</option>
|
||||
</select>
|
||||
</div>
|
||||
<canvas id="chartHarian"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="diagnosisBox" style="display:none; margin-top:20px;">
|
||||
<div class="data-section">
|
||||
<div class="section-title"><i class="bi bi-card-list"></i> Data Diagnosis</div>
|
||||
|
||||
<div class="table-controls">
|
||||
<input type="text" id="searchDiagnosis" class="form-control" placeholder="Cari data diagnosis...">
|
||||
<select id="filterDiagnosis" class="form-control">
|
||||
<option value="">Semua Penyakit</option>
|
||||
@foreach($data->pluck('hasil_diagnosis')->unique() as $penyakit)
|
||||
<option value="{{ strtolower($penyakit) }}">{{ $penyakit }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<select id="sortDiagnosis" class="form-control">
|
||||
<option value="latest">Terbaru</option>
|
||||
<option value="oldest">Terlama</option>
|
||||
<option value="name_asc">Nama Pemilik A-Z</option>
|
||||
<option value="name_desc">Nama Pemilik Z-A</option>
|
||||
</select>
|
||||
<a href="{{ route('admin.export.diagnosis') }}" class="btn-export">⬇ Export Excel</a>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nama Pemilik</th>
|
||||
<th>Nama Kucing</th>
|
||||
<th>Umur Kucing</th>
|
||||
<th>Jenis Kelamin</th>
|
||||
<th>Penyakit</th>
|
||||
<th>Gejala Dipilih</th>
|
||||
<th>Tanggal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="diagnosisTable">
|
||||
@foreach($data as $item)
|
||||
<tr data-date="{{ $item->created_at }}" data-name="{{ strtolower($item->nama_pemilik ?? '') }}" data-disease="{{ strtolower($item->hasil_diagnosis ?? '') }}">
|
||||
<td>{{ $item->nama_pemilik }}</td>
|
||||
<td>{{ $item->nama_kucing }}</td>
|
||||
<td>{{ $item->umur_kucing ?? '-' }}</td>
|
||||
<td>{{ $item->jenis_kelamin ?? '-' }}</td>
|
||||
<td>{{ $item->hasil_diagnosis ?? '-' }}</td>
|
||||
<td style="max-width:340px;text-align:center;">
|
||||
@php
|
||||
$sym = $item->gejala_dipilih ?? '';
|
||||
$arr = json_decode((string)$sym, true);
|
||||
if (!is_array($arr)) $arr = [];
|
||||
$arr = array_values(array_filter(array_map('trim', $arr), fn($v) => $v !== ''));
|
||||
$count = count($arr);
|
||||
@endphp
|
||||
@if($count === 0)
|
||||
<span style="color:#64748b;">-</span>
|
||||
@else
|
||||
<a href="#"
|
||||
class="symptoms-link"
|
||||
data-symptoms='@json($arr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)'
|
||||
style="color:#2f855a;font-weight:700;text-decoration:none;">
|
||||
Lihat gejala ({{ $count }})
|
||||
</a>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ \Carbon\Carbon::parse($item->created_at)->format('d M Y') }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="userBox" style="display:none; margin-top:20px;">
|
||||
<div class="data-section">
|
||||
<div class="section-title"><i class="bi bi-people"></i> Data Pengguna</div>
|
||||
<div class="table-controls">
|
||||
<input type="text" id="searchUser" class="form-control" placeholder="Cari pengguna...">
|
||||
<select id="sortUser" class="form-control">
|
||||
<option value="latest">Terbaru</option>
|
||||
<option value="oldest">Terlama</option>
|
||||
<option value="name_asc">Nama Pemilik A-Z</option>
|
||||
<option value="name_desc">Nama Pemilik Z-A</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tanggal</th>
|
||||
<th>Nama Pemilik</th>
|
||||
<th>No Telepon</th>
|
||||
<th>Alamat</th>
|
||||
<th>Nama Kucing</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTable">
|
||||
@foreach(($userData ?? collect()) as $item)
|
||||
<tr data-date="{{ $item->created_at }}" data-name="{{ strtolower($item->nama_pemilik ?? '') }}">
|
||||
<td>{{ \Carbon\Carbon::parse($item->created_at)->format('d M Y') }}</td>
|
||||
<td>{{ $item->nama_pemilik }}</td>
|
||||
<td>{{ $item->no_telepon }}</td>
|
||||
<td>{{ $item->alamat ?? 'Tidak tersedia'}}</td>
|
||||
<td>{{ $item->nama_kucing }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Diagnosis -->
|
||||
<div class="data-section">
|
||||
<div class="section-title">
|
||||
<span><i class="bi bi-clock-history"></i></span>
|
||||
<span>Diagnosis Terbaru</span>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tanggal</th>
|
||||
<th>Penyakit</th>
|
||||
<th>Jumlah</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($stats['recent_diagnosis'] as $diagnosis)
|
||||
<tr>
|
||||
<td>{{ \Carbon\Carbon::parse($diagnosis['date'])->format('d M Y') }}</td>
|
||||
<td>{{ $diagnosis['disease'] }}</td>
|
||||
<td>{{ $diagnosis['count'] }} kasus</td>
|
||||
<td><span class="badge badge-success">Aktif</span></td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="data-section">
|
||||
<div class="section-title">
|
||||
<span><i class="bi bi-lightning-charge-fill"></i></span>
|
||||
<span>Aksi Cepat</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:16px; flex-wrap:wrap;">
|
||||
<a href="/" style="padding:12px 24px; background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); color:white; text-decoration:none; border-radius:12px; font-weight:600; transition:all 0.3s ease;">
|
||||
<i class="bi bi-house-door-fill"></i> Lihat Website
|
||||
</a>
|
||||
<a href="{{ route('ulasan') }}" style="padding:12px 24px; background:white; border:2px solid var(--primary); color:var(--primary-dark); text-decoration:none; border-radius:12px; font-weight:600; transition:all 0.3s ease;">
|
||||
<i class="bi bi-chat-dots-fill"></i> Lihat Ulasan
|
||||
</a>
|
||||
<a href="{{ route('faq') }}" style="padding:12px 24px; background:white; border:2px solid var(--primary); color:var(--primary-dark); text-decoration:none; border-radius:12px; font-weight:600; transition:all 0.3s ease;">
|
||||
<i class="bi bi-question-circle-fill"></i> Lihat FAQ
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('components.scroll-top')
|
||||
|
||||
<script>
|
||||
const diagnosisBox = document.getElementById('diagnosisBox');
|
||||
const userBox = document.getElementById('userBox');
|
||||
const chartBox = document.getElementById('chartBox');
|
||||
|
||||
const diagnosisTableBody = document.getElementById('diagnosisTable');
|
||||
const diagnosisRows = Array.from(diagnosisTableBody.querySelectorAll('tr'));
|
||||
|
||||
const userTableBody = document.getElementById('userTable');
|
||||
const userRows = Array.from(userTableBody.querySelectorAll('tr'));
|
||||
|
||||
function sortRows(rows, sortType, nameAttr = 'data-name') {
|
||||
const cloned = [...rows];
|
||||
cloned.sort((a, b) => {
|
||||
if (sortType === 'oldest') {
|
||||
return new Date(a.getAttribute('data-date')) - new Date(b.getAttribute('data-date'));
|
||||
}
|
||||
if (sortType === 'name_asc') {
|
||||
return (a.getAttribute(nameAttr) || '').localeCompare((b.getAttribute(nameAttr) || ''));
|
||||
}
|
||||
if (sortType === 'name_desc') {
|
||||
return (b.getAttribute(nameAttr) || '').localeCompare((a.getAttribute(nameAttr) || ''));
|
||||
}
|
||||
return new Date(b.getAttribute('data-date')) - new Date(a.getAttribute('data-date'));
|
||||
});
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function applyDiagnosisFilters() {
|
||||
const search = (document.getElementById('searchDiagnosis').value || '').toLowerCase();
|
||||
const diseaseFilter = document.getElementById('filterDiagnosis').value;
|
||||
const sort = document.getElementById('sortDiagnosis').value;
|
||||
|
||||
const filtered = diagnosisRows.filter((row) => {
|
||||
const text = row.innerText.toLowerCase();
|
||||
const disease = row.getAttribute('data-disease') || '';
|
||||
return text.includes(search) && (diseaseFilter === '' || disease === diseaseFilter);
|
||||
});
|
||||
|
||||
const sorted = sortRows(filtered, sort);
|
||||
diagnosisTableBody.innerHTML = '';
|
||||
sorted.forEach((row) => diagnosisTableBody.appendChild(row));
|
||||
}
|
||||
|
||||
function applyUserFilters() {
|
||||
const search = (document.getElementById('searchUser').value || '').toLowerCase();
|
||||
const sort = document.getElementById('sortUser').value;
|
||||
|
||||
const filtered = userRows.filter((row) => row.innerText.toLowerCase().includes(search));
|
||||
const sorted = sortRows(filtered, sort);
|
||||
|
||||
userTableBody.innerHTML = '';
|
||||
sorted.forEach((row) => userTableBody.appendChild(row));
|
||||
}
|
||||
|
||||
function toggleChart() {
|
||||
const isHidden = window.getComputedStyle(chartBox).display === 'none';
|
||||
if (isHidden) {
|
||||
chartBox.style.display = 'block';
|
||||
chartBox.scrollIntoView({ behavior: 'smooth' });
|
||||
loadMainChart();
|
||||
loadRatingChart();
|
||||
} else {
|
||||
chartBox.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function toggleUsers() {
|
||||
chartBox.style.display = 'none';
|
||||
const hidden = window.getComputedStyle(userBox).display === 'none';
|
||||
userBox.style.display = hidden ? 'block' : 'none';
|
||||
if (hidden) userBox.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function toggleDiagnosis() {
|
||||
chartBox.style.display = 'none';
|
||||
userBox.style.display = 'none';
|
||||
const hidden = window.getComputedStyle(diagnosisBox).display === 'none';
|
||||
diagnosisBox.style.display = hidden ? 'block' : 'none';
|
||||
if (hidden) diagnosisBox.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels"></script>
|
||||
<script>
|
||||
let diseaseChart = null;
|
||||
let ratingChart = null;
|
||||
let trendChart = null;
|
||||
const trendDatasets = {
|
||||
'7d': {
|
||||
labels: {!! json_encode($stats['trend_7_labels']) !!},
|
||||
data: {!! json_encode($stats['trend_7_data']) !!},
|
||||
label: 'Diagnosis (7 hari)',
|
||||
},
|
||||
'month': {
|
||||
labels: {!! json_encode($stats['trend_month_labels']) !!},
|
||||
data: {!! json_encode($stats['trend_month_data']) !!},
|
||||
label: 'Diagnosis per bulan',
|
||||
},
|
||||
'all': {
|
||||
labels: {!! json_encode($stats['trend_all_labels']) !!},
|
||||
data: {!! json_encode($stats['trend_all_data']) !!},
|
||||
label: 'Diagnosis seluruh periode',
|
||||
}
|
||||
};
|
||||
|
||||
function loadMainChart() {
|
||||
if (diseaseChart) return;
|
||||
const ctx = document.getElementById('chartPenyakit');
|
||||
diseaseChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {!! json_encode($stats['chart_labels']) !!},
|
||||
datasets: [{
|
||||
label: 'Jumlah Kasus',
|
||||
data: {!! json_encode($stats['chart_data']) !!},
|
||||
backgroundColor: '#6fcf97',
|
||||
borderColor: '#4bb66f',
|
||||
borderWidth: 1.5,
|
||||
borderRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: { mode: 'index', intersect: false }
|
||||
},
|
||||
scales: {
|
||||
// 🔥 TAMBAHKAN INI
|
||||
x: {
|
||||
ticks: {
|
||||
display: false // ❗ ini yang menghilangkan nama penyakit
|
||||
},
|
||||
grid: {
|
||||
display: false // opsional biar lebih bersih
|
||||
}
|
||||
},
|
||||
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { precision: 0 },
|
||||
grid: { color: '#e5e7eb' }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTrendChart(mode = '7d') {
|
||||
const source = trendDatasets[mode] || trendDatasets['7d'];
|
||||
if (!trendChart) {
|
||||
trendChart = new Chart(document.getElementById('chartHarian'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: source.labels,
|
||||
datasets: [{
|
||||
label: source.label,
|
||||
data: source.data,
|
||||
tension: 0.35,
|
||||
fill: true,
|
||||
backgroundColor: 'rgba(111, 207, 151, 0.2)',
|
||||
borderColor: '#4bb66f',
|
||||
borderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#4bb66f'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: true } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { precision: 0 }, grid: { color: '#e5e7eb' } },
|
||||
x: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
trendChart.data.labels = source.labels;
|
||||
trendChart.data.datasets[0].data = source.data;
|
||||
trendChart.data.datasets[0].label = source.label;
|
||||
trendChart.update();
|
||||
}
|
||||
|
||||
function loadRatingChart() {
|
||||
if (ratingChart) return;
|
||||
ratingChart = new Chart(document.getElementById('chartRating'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: {!! json_encode($stats['rating_labels']) !!}.map(r => 'Bintang ' + r),
|
||||
datasets: [{
|
||||
data: {!! json_encode($stats['rating_data']) !!},
|
||||
backgroundColor: ['#22c55e', '#84cc16', '#f59e0b', '#f97316', '#ef4444']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
formatter: (value, context) => {
|
||||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||||
if (!total) return '0%';
|
||||
return ((value / total) * 100).toFixed(1) + '%';
|
||||
},
|
||||
font: { weight: 'bold' }
|
||||
},
|
||||
legend: { position: 'bottom' }
|
||||
}
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('searchDiagnosis').addEventListener('input', applyDiagnosisFilters);
|
||||
document.getElementById('filterDiagnosis').addEventListener('change', applyDiagnosisFilters);
|
||||
document.getElementById('sortDiagnosis').addEventListener('change', applyDiagnosisFilters);
|
||||
document.getElementById('searchUser').addEventListener('input', applyUserFilters);
|
||||
document.getElementById('sortUser').addEventListener('change', applyUserFilters);
|
||||
document.getElementById('trendMode').addEventListener('change', (e) => renderTrendChart(e.target.value));
|
||||
|
||||
applyDiagnosisFilters();
|
||||
applyUserFilters();
|
||||
renderTrendChart('7d');
|
||||
</script>
|
||||
|
||||
<dialog id="symptomsDialog" style="border:none;border-radius:14px;max-width:520px;width:92%;padding:0;box-shadow:0 18px 50px rgba(17,77,58,.22);position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);margin:0;">
|
||||
<div style="padding:16px 16px 10px;background:#fff;border-bottom:1px solid #e2e8f0;display:flex;justify-content:space-between;align-items:center;gap:12px;">
|
||||
<div style="font-weight:800;color:#114d3a;">Gejala Dipilih</div>
|
||||
<button id="closeSymptomsDialog" type="button" style="border:none;background:#f1f5f9;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:800;">✕</button>
|
||||
</div>
|
||||
<div style="padding:14px 16px 16px;background:#fff;">
|
||||
<div id="symptomsDialogBody" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
|
||||
<div style="margin-top:14px;color:#64748b;font-size:12px;">Data ini tersimpan untuk evaluasi & peningkatan dataset.</div>
|
||||
</div>
|
||||
</dialog>
|
||||
<style>
|
||||
#symptomsDialog::backdrop{
|
||||
background:rgba(15,23,42,.36);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const symptomsDialog = document.getElementById('symptomsDialog');
|
||||
const symptomsDialogBody = document.getElementById('symptomsDialogBody');
|
||||
const closeSymptomsDialog = document.getElementById('closeSymptomsDialog');
|
||||
|
||||
function openSymptomsDialog(list) {
|
||||
if (!symptomsDialog || !symptomsDialogBody) return;
|
||||
symptomsDialogBody.innerHTML = '';
|
||||
(list || []).forEach((g) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.textContent = g;
|
||||
chip.style.cssText = 'font-size:12px;padding:6px 10px;border-radius:999px;background:#e8f7ef;border:1px solid #b7ebcf;color:#114d3a;font-weight:700;';
|
||||
symptomsDialogBody.appendChild(chip);
|
||||
});
|
||||
if (typeof symptomsDialog.showModal === 'function') {
|
||||
symptomsDialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.symptoms-link').forEach((a) => {
|
||||
a.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
let list = [];
|
||||
try {
|
||||
list = JSON.parse(a.getAttribute('data-symptoms') || '[]');
|
||||
} catch (_) {
|
||||
list = [];
|
||||
}
|
||||
openSymptomsDialog(list);
|
||||
});
|
||||
});
|
||||
|
||||
if (closeSymptomsDialog && symptomsDialog) {
|
||||
closeSymptomsDialog.addEventListener('click', () => symptomsDialog.close());
|
||||
symptomsDialog.addEventListener('click', (e) => {
|
||||
const rect = symptomsDialog.getBoundingClientRect();
|
||||
const inDialog = rect.top <= e.clientY && e.clientY <= rect.bottom && rect.left <= e.clientX && e.clientX <= rect.right;
|
||||
if (!inDialog) symptomsDialog.close();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pengaturan Penjelasan Penyakit - 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">
|
||||
<style>
|
||||
:root{
|
||||
--ff-heading:'Poppins',system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial;
|
||||
--ff-body:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial;
|
||||
--primary:#6fcf97;
|
||||
--primary-dark:#4bb66f;
|
||||
--primary-light:#e8f7ef;
|
||||
--text-dark:#114d3a;
|
||||
--text-muted:#64748b;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg,#f0fdf4 0%,#eaf7f0 50%,#f0f9ff 100%);
|
||||
color:#333;
|
||||
}
|
||||
.container{max-width:1200px;margin:0 auto;padding:32px 20px;}
|
||||
.topbar{
|
||||
display:flex;justify-content:space-between;align-items:center;gap:12px;
|
||||
margin-bottom:18px;
|
||||
}
|
||||
.title{font-family:var(--ff-heading);color:var(--text-dark);font-size:30px;font-weight:800;margin:0;}
|
||||
.title-wrap{display:flex;align-items:center;gap:10px}
|
||||
.logo-icon{width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,#6fcf97,#4bb66f);display:flex;align-items:center;justify-content:center;color:#fff}
|
||||
.muted{color:var(--text-muted);margin:6px 0 0;}
|
||||
.back{
|
||||
text-decoration:none;padding:10px 14px;border-radius:10px;border:1px solid var(--primary);
|
||||
background:#fff;color:var(--text-dark);font-weight:600;
|
||||
}
|
||||
.card{
|
||||
background:rgba(255,255,255,.95);border:1px solid rgba(111,207,151,.2);border-radius:18px;
|
||||
box-shadow:0 8px 24px rgba(17,77,58,.1);padding:20px;
|
||||
}
|
||||
.notice{
|
||||
padding:10px 14px;border-radius:10px;background:var(--primary-light);color:var(--text-dark);
|
||||
border:1px solid rgba(111,207,151,.3);margin-bottom:14px;font-weight:600;
|
||||
}
|
||||
.search-wrap{margin-bottom:12px}
|
||||
.search-input{
|
||||
width:100%;max-width:320px;padding:10px 12px;border:1px solid #cbd5e1;border-radius:10px;font:inherit
|
||||
}
|
||||
.table-wrap{max-height:70vh;overflow:auto;border:1px solid #e2e8f0;border-radius:12px;}
|
||||
table{width:100%;border-collapse:collapse;background:#fff;}
|
||||
th,td{padding:12px 10px;border-bottom:1px solid #e2e8f0;vertical-align:top;}
|
||||
th{background:var(--primary-light);text-align:left;color:var(--text-dark);}
|
||||
.disease-name{font-weight:700;color:#14532d}
|
||||
.count{font-size:12px;color:#64748b;margin-top:4px}
|
||||
textarea{
|
||||
width:100%;min-height:90px;resize:vertical;padding:10px;border:1px solid #cbd5e1;border-radius:10px;
|
||||
font-family:var(--ff-body);font-size:14px;line-height:1.5;
|
||||
}
|
||||
.actions{margin-top:16px;display:flex;justify-content:flex-end;}
|
||||
.btn{
|
||||
border:none;border-radius:12px;padding:12px 18px;font-weight:700;cursor:pointer;
|
||||
background:linear-gradient(135deg,var(--primary),var(--primary-dark));color:#fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<div class="title-wrap">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="title">Pengaturan Penjelasan Penyakit</h1>
|
||||
</div>
|
||||
<p class="muted">Atur deskripsi penyakit yang tampil di halaman hasil diagnosis.</p>
|
||||
</div>
|
||||
<a href="{{ route('admin.dashboard') }}" class="back">← Kembali ke Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@if(session('success'))
|
||||
<div class="notice">{{ session('success') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.disease.settings.save') }}">
|
||||
@csrf
|
||||
<div class="search-wrap">
|
||||
<input type="text" id="searchDisease" class="search-input" placeholder="Cari nama penyakit...">
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:80px;">No</th>
|
||||
<th style="width:32%;">Nama Penyakit</th>
|
||||
<th>Penjelasan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse($diseases as $disease)
|
||||
<tr class="disease-row">
|
||||
<td class="row-no"></td>
|
||||
<td>
|
||||
<div class="disease-name">{{ $disease }}</div>
|
||||
<div class="count">Kunci: {{ \Illuminate\Support\Str::slug($disease, '-') }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<textarea name="descriptions[{{ $disease }}]" placeholder="Tulis penjelasan singkat penyakit...">{{ $descriptions[$disease] ?? '' }}</textarea>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="3">Belum ada data penyakit.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn">Simpan Penjelasan</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const diseaseSearch = document.getElementById('searchDisease');
|
||||
if (diseaseSearch) {
|
||||
diseaseSearch.addEventListener('input', () => {
|
||||
const q = diseaseSearch.value.toLowerCase().trim();
|
||||
document.querySelectorAll('.disease-row').forEach((row) => {
|
||||
const name = row.querySelector('.disease-name')?.textContent?.toLowerCase() || '';
|
||||
row.style.display = name.includes(q) ? '' : 'none';
|
||||
});
|
||||
renumberDiseaseRows();
|
||||
});
|
||||
}
|
||||
function renumberDiseaseRows() {
|
||||
const rows = document.querySelectorAll('.disease-row');
|
||||
let i = 1;
|
||||
rows.forEach((row) => {
|
||||
if (row.style.display === 'none') return;
|
||||
const no = row.querySelector('.row-no');
|
||||
if (no) no.textContent = i++;
|
||||
});
|
||||
}
|
||||
renumberDiseaseRows();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kelola FAQ - PawMedic Admin</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://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body{margin:0;font-family:'Inter',sans-serif;background:#f4faf7;color:#1f2937}
|
||||
.container{max-width:1100px;margin:0 auto;padding:28px 16px}
|
||||
.head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:18px}
|
||||
.title-wrap{display:flex;align-items:center;gap:10px}
|
||||
.logo-icon{width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,#6fcf97,#4bb66f);display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px}
|
||||
.title{font-family:'Poppins',sans-serif;font-size:30px;margin:0;color:#114d3a}
|
||||
.card{background:#fff;border:1px solid #d1fae5;border-radius:16px;padding:16px;box-shadow:0 8px 20px rgba(17,77,58,.08)}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:10px;border-bottom:1px solid #e2e8f0;vertical-align:top}
|
||||
th{background:#e8f7ef;text-align:left}
|
||||
input,textarea{width:100%;padding:10px;border:1px solid #cbd5e1;border-radius:10px;font:inherit}
|
||||
textarea{min-height:92px;resize:vertical}
|
||||
.btn{padding:10px 14px;border-radius:10px;border:1px solid #6fcf97;background:#6fcf97;color:#fff;font-weight:700;cursor:pointer}
|
||||
.btn.secondary{background:#fff;color:#114d3a}
|
||||
.btn.danger{background:#fff;border-color:#fca5a5;color:#b91c1c}
|
||||
.actions{display:flex;justify-content:space-between;align-items:center;margin-top:12px;gap:10px}
|
||||
.notice{background:#e8f7ef;border:1px solid #b7ebcf;padding:10px;border-radius:10px;color:#114d3a;margin-bottom:10px}
|
||||
@media(max-width:768px){.title{font-size:24px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="head">
|
||||
<div class="title-wrap">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="title">Kelola FAQ</h1>
|
||||
</div>
|
||||
<a class="btn secondary" href="{{ route('admin.dashboard') }}">← Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@if(session('success'))
|
||||
<div class="notice">{{ session('success') }}</div>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('admin.faq.settings.save') }}">
|
||||
@csrf
|
||||
<div style="overflow:auto;border:1px solid #e2e8f0;border-radius:10px;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width:60px;">No</th>
|
||||
<th style="min-width:260px;">Pertanyaan</th>
|
||||
<th style="min-width:420px;">Jawaban</th>
|
||||
<th style="min-width:120px;">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="faqRows">
|
||||
@forelse($faqs as $faq)
|
||||
<tr class="faq-row">
|
||||
<td class="faq-no"></td>
|
||||
<td><input type="text" name="questions[]" value="{{ $faq['question'] }}" placeholder="Pertanyaan"></td>
|
||||
<td><textarea name="answers[]" placeholder="Jawaban">{{ $faq['answer'] }}</textarea></td>
|
||||
<td><button type="button" class="btn danger" onclick="askDelete(this)">Hapus</button></td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr class="faq-row">
|
||||
<td class="faq-no"></td>
|
||||
<td><input type="text" name="questions[]" placeholder="Pertanyaan"></td>
|
||||
<td><textarea name="answers[]" placeholder="Jawaban"></textarea></td>
|
||||
<td><button type="button" class="btn danger" onclick="askDelete(this)">Hapus</button></td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn secondary" onclick="addFaqRow()">+ Tambah FAQ</button>
|
||||
<button type="submit" class="btn">Simpan FAQ</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="deleteFaqModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Konfirmasi Hapus</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">Apakah yakin untuk dihapus?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn secondary" data-bs-dismiss="modal">Batal</button>
|
||||
<button type="button" class="btn danger" id="confirmDeleteBtn">Ya, Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
let pendingDeleteRow = null;
|
||||
function addFaqRow() {
|
||||
const wrap = document.getElementById('faqRows');
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'faq-row';
|
||||
row.innerHTML = `
|
||||
<td class="faq-no"></td>
|
||||
<td><input type="text" name="questions[]" placeholder="Pertanyaan"></td>
|
||||
<td><textarea name="answers[]" placeholder="Jawaban"></textarea></td>
|
||||
<td><button type="button" class="btn danger" onclick="askDelete(this)">Hapus</button></td>
|
||||
`;
|
||||
wrap.appendChild(row);
|
||||
renumberRows();
|
||||
}
|
||||
function askDelete(btn) {
|
||||
const row = btn.closest('.faq-row');
|
||||
if (!row) return;
|
||||
const wrap = document.getElementById('faqRows');
|
||||
if (wrap.querySelectorAll('.faq-row').length <= 1) {
|
||||
alert('Minimal harus ada 1 baris FAQ.');
|
||||
return;
|
||||
}
|
||||
pendingDeleteRow = row;
|
||||
const modalEl = document.getElementById('deleteFaqModal');
|
||||
if (window.bootstrap && modalEl) {
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
} else if (confirm('Apakah yakin untuk dihapus?')) {
|
||||
row.remove();
|
||||
renumberRows();
|
||||
}
|
||||
}
|
||||
document.getElementById('confirmDeleteBtn')?.addEventListener('click', () => {
|
||||
if (pendingDeleteRow) {
|
||||
pendingDeleteRow.remove();
|
||||
renumberRows();
|
||||
pendingDeleteRow = null;
|
||||
}
|
||||
const modalEl = document.getElementById('deleteFaqModal');
|
||||
if (window.bootstrap && modalEl) {
|
||||
bootstrap.Modal.getInstance(modalEl)?.hide();
|
||||
}
|
||||
});
|
||||
function renumberRows() {
|
||||
document.querySelectorAll('#faqRows .faq-row').forEach((row, idx) => {
|
||||
const cell = row.querySelector('.faq-no');
|
||||
if (cell) cell.textContent = idx + 1;
|
||||
});
|
||||
}
|
||||
renumberRows();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Lupa Password Admin - PawMedic</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4" style="max-width:560px;">
|
||||
<div class="card shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="mb-3">Lupa Password Admin</h3>
|
||||
<p class="text-muted mb-3">Masukkan email aktif untuk menerima OTP, lalu verifikasi kode.</p>
|
||||
|
||||
@if(session('success'))
|
||||
<div class="alert alert-success">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger">{{ session('error') }}</div>
|
||||
@endif
|
||||
@if($errors->any())
|
||||
<div class="alert alert-danger">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.forgot.sendOtp') }}" class="mb-4">
|
||||
@csrf
|
||||
<label class="form-label">Email aktif (penerima OTP)</label>
|
||||
<input type="email" class="form-control mb-3" name="otp_email" value="{{ old('otp_email', session('otp_email')) }}" placeholder="contoh@email.com" required>
|
||||
<button type="submit" class="btn btn-success">Kirim kode sekarang</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('admin.forgot.verifyOtp') }}">
|
||||
@csrf
|
||||
<label class="form-label">Email aktif (yang tadi dipakai)</label>
|
||||
<input type="email" class="form-control mb-3" name="otp_email" value="{{ old('otp_email', session('otp_email')) }}" required>
|
||||
<label class="form-label">Masukkan kode OTP</label>
|
||||
<input type="text" class="form-control mb-3" name="otp" maxlength="6" placeholder="6 digit OTP" required>
|
||||
<button type="submit" class="btn btn-primary">Verifikasi Kode</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ route('admin.login') }}" class="btn btn-link ps-0 mt-2">Kembali ke login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login Admin - 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://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="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--ff-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--primary: #6fcf97;
|
||||
--primary-dark: #4bb66f;
|
||||
--primary-light: #e8f7ef;
|
||||
--text-dark: #114d3a;
|
||||
--text-muted: #64748b;
|
||||
--danger: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg, #f0fdf4 0%, #eaf7f0 50%, #f0f9ff 100%);
|
||||
background-attachment:fixed;
|
||||
color:#333;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.6;
|
||||
min-height:100vh;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
padding:20px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
body::before{
|
||||
content:'';
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(111,207,151,0.12) 0%, transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(111,207,151,0.08) 0%, transparent 60%);
|
||||
pointer-events:none;
|
||||
z-index:0;
|
||||
}
|
||||
|
||||
.login-container{
|
||||
width:100%;
|
||||
max-width:450px;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
.login-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:28px;
|
||||
padding:48px;
|
||||
box-shadow:
|
||||
0 25px 80px rgba(17,77,58,0.15),
|
||||
0 0 0 1px rgba(111,207,151,0.1);
|
||||
border:1px solid rgba(111,207,151,0.2);
|
||||
animation:fadeUp 0.8s ease;
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.login-card::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
height:5px;
|
||||
background:linear-gradient(90deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
}
|
||||
|
||||
.logo-section{
|
||||
text-align:center;
|
||||
margin-bottom:40px;
|
||||
}
|
||||
|
||||
.logo{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
margin-bottom:24px;
|
||||
}
|
||||
|
||||
.logo-icon{
|
||||
width:56px;
|
||||
height:56px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:14px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:28px;
|
||||
box-shadow:0 8px 24px rgba(111,207,151,0.3);
|
||||
}
|
||||
|
||||
.logo-text{
|
||||
font-size:26px;
|
||||
font-weight:700;
|
||||
font-family:var(--ff-heading);
|
||||
color:var(--text-dark);
|
||||
}
|
||||
|
||||
.login-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:28px;
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:8px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
|
||||
.login-subtitle{
|
||||
color:var(--text-muted);
|
||||
font-size:15px;
|
||||
}
|
||||
|
||||
.form-group{
|
||||
margin-bottom:24px;
|
||||
}
|
||||
|
||||
.form-group label{
|
||||
display:block;
|
||||
font-weight:600;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:10px;
|
||||
font-size:15px;
|
||||
}
|
||||
|
||||
.form-group input{
|
||||
width:100%;
|
||||
padding:14px 18px;
|
||||
border:2px solid var(--border, #e2e8f0);
|
||||
border-radius:12px;
|
||||
font-size:15px;
|
||||
font-family:var(--ff-body);
|
||||
transition:all 0.3s ease;
|
||||
background:#fafafa;
|
||||
}
|
||||
/* Hilangkan ikon "reveal password" bawaan browser (Windows/Edge) */
|
||||
input::-ms-reveal,
|
||||
input::-ms-clear{
|
||||
display:none;
|
||||
}
|
||||
.password-wrap{
|
||||
position:relative;
|
||||
}
|
||||
.password-wrap input{
|
||||
padding-right:48px;
|
||||
}
|
||||
.toggle-pass{
|
||||
position:absolute;
|
||||
right:10px;
|
||||
top:50%;
|
||||
transform:translateY(-50%);
|
||||
width:38px;
|
||||
height:38px;
|
||||
border-radius:10px;
|
||||
border:1px solid rgba(100,116,139,.25);
|
||||
background:#fff;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
cursor:pointer;
|
||||
color:#0f3f33;
|
||||
transition:all .2s ease;
|
||||
}
|
||||
.toggle-pass:hover{
|
||||
background:var(--primary-light);
|
||||
border-color:rgba(111,207,151,.55);
|
||||
}
|
||||
.toggle-pass svg{
|
||||
width:18px;
|
||||
height:18px;
|
||||
display:block;
|
||||
fill:currentColor;
|
||||
}
|
||||
|
||||
.form-group input:focus{
|
||||
outline:none;
|
||||
border-color:var(--primary);
|
||||
background:white;
|
||||
box-shadow:0 0 0 4px rgba(111,207,151,0.1);
|
||||
}
|
||||
|
||||
.remember-forgot{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
margin-bottom:28px;
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.remember{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
}
|
||||
|
||||
.remember input[type="checkbox"]{
|
||||
width:18px;
|
||||
height:18px;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.forgot-link{
|
||||
color:var(--primary-dark);
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
transition:color 0.2s ease;
|
||||
}
|
||||
|
||||
.forgot-link:hover{
|
||||
color:var(--primary);
|
||||
}
|
||||
|
||||
.btn{
|
||||
width:100%;
|
||||
padding:16px;
|
||||
border:none;
|
||||
border-radius:14px;
|
||||
font-weight:600;
|
||||
font-size:16px;
|
||||
cursor:pointer;
|
||||
transition:all 0.3s ease;
|
||||
font-family:var(--ff-body);
|
||||
}
|
||||
|
||||
.btn-primary{
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
color:white;
|
||||
box-shadow:0 4px 16px rgba(111,207,151,0.3);
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.btn-primary::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:-100%;
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:linear-gradient(90deg, transparent, rgba(255,255,255,0.25), transparent);
|
||||
transition:left 0.6s;
|
||||
}
|
||||
|
||||
.btn-primary:hover::before{
|
||||
left:100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover{
|
||||
transform:translateY(-2px);
|
||||
box-shadow:0 8px 24px rgba(111,207,151,0.4);
|
||||
}
|
||||
|
||||
.error-message{
|
||||
background:#fee2e2;
|
||||
border-left:4px solid var(--danger);
|
||||
padding:12px 16px;
|
||||
border-radius:8px;
|
||||
color:#991b1b;
|
||||
font-size:14px;
|
||||
margin-bottom:24px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
}
|
||||
|
||||
.back-link{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
color:var(--text-muted);
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
margin-top:24px;
|
||||
transition:all 0.3s ease;
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.back-link:hover{
|
||||
color:var(--primary-dark);
|
||||
transform:translateX(-4px);
|
||||
}
|
||||
|
||||
@keyframes fadeUp{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(30px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="logo-section">
|
||||
<div class="logo">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">PawMedic</div>
|
||||
</div>
|
||||
<h1 class="login-title">Admin Login</h1>
|
||||
<p class="login-subtitle">Masuk ke dashboard admin</p>
|
||||
</div>
|
||||
|
||||
@if($errors->any())
|
||||
<div class="error-message">
|
||||
<span><i class="bi bi-exclamation-triangle-fill"></i></span>
|
||||
<span>{{ $errors->first() }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if(session('success'))
|
||||
<div class="error-message" style="background:#ecfdf3;border-left-color:#22c55e;color:#166534;">
|
||||
<span><i class="bi bi-check-circle-fill"></i></span>
|
||||
<span>{{ session('success') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="error-message">
|
||||
<span><i class="bi bi-exclamation-triangle-fill"></i></span>
|
||||
<span>{{ session('error') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.authenticate') }}">
|
||||
@csrf
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value="{{ old('email') }}"
|
||||
placeholder="Masukkan email aplikasi"
|
||||
required
|
||||
autofocus
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<div class="password-wrap">
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Masukkan password"
|
||||
required
|
||||
>
|
||||
<button type="button" class="toggle-pass" id="togglePassword" aria-label="Tampilkan password" aria-pressed="false">
|
||||
<svg id="eyeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 5c5.5 0 9.6 4.2 11 6.7a.6.6 0 0 1 0 .6C21.6 14.8 17.5 19 12 19S2.4 14.8 1 12.3a.6.6 0 0 1 0-.6C2.4 9.2 6.5 5 12 5zm0 2c-3.8 0-7 2.7-8.8 5 1.8 2.3 5 5 8.8 5s7-2.7 8.8-5c-1.8-2.3-5-5-8.8-5zm0 2.3A2.7 2.7 0 1 1 12 14.7a2.7 2.7 0 0 1 0-5.4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="remember-forgot">
|
||||
<label class="remember">
|
||||
<input type="checkbox" name="remember">
|
||||
<span>Ingat saya</span>
|
||||
</label>
|
||||
<a href="{{ route('admin.forgot.password') }}" class="forgot-link">Lupa password?</a>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Masuk
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a href="/" class="back-link">
|
||||
← Kembali ke Beranda
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const passInput = document.getElementById('password');
|
||||
const toggleBtn = document.getElementById('togglePassword');
|
||||
const eyeIcon = document.getElementById('eyeIcon');
|
||||
|
||||
function setEye(open) {
|
||||
if (!eyeIcon) return;
|
||||
eyeIcon.innerHTML = open
|
||||
? '<path d="M12 5c5.5 0 9.6 4.2 11 6.7a.6.6 0 0 1 0 .6C21.6 14.8 17.5 19 12 19S2.4 14.8 1 12.3a.6.6 0 0 1 0-.6C2.4 9.2 6.5 5 12 5zm0 2c-3.8 0-7 2.7-8.8 5 1.8 2.3 5 5 8.8 5s7-2.7 8.8-5c-1.8-2.3-5-5-8.8-5zm0 2.3A2.7 2.7 0 1 1 12 14.7a2.7 2.7 0 0 1 0-5.4z"/>'
|
||||
: '<path d="M2 12c1.6-2.8 5.8-7 10-7 2.1 0 4.1.8 5.9 2l1.6-1.6 1.4 1.4-18 18-1.4-1.4 2.2-2.2C3 19 1.3 14.9 1 13a1 1 0 0 1 .1-.6l.9-1.4zm10-5c-3 0-5.7 2-7.6 4.5.6.8 1.4 1.7 2.3 2.5l1.8-1.8A3.7 3.7 0 0 1 12 8.3c.5 0 1 .1 1.4.3l1.6-1.6A8.3 8.3 0 0 0 12 7zm0 10c3 0 5.7-2 7.6-4.5a15 15 0 0 0-1.9-2.1l-2 2a3.7 3.7 0 0 1-4.9 4.9l-1.7 1.7c.9.5 1.9.8 2.9.8z"/>';
|
||||
}
|
||||
|
||||
if (toggleBtn && passInput) {
|
||||
let shown = false;
|
||||
setEye(false);
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
shown = !shown;
|
||||
passInput.type = shown ? 'text' : 'password';
|
||||
toggleBtn.setAttribute('aria-pressed', shown ? 'true' : 'false');
|
||||
toggleBtn.setAttribute('aria-label', shown ? 'Sembunyikan password' : 'Tampilkan password');
|
||||
setEye(shown);
|
||||
passInput.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ganti Password Admin - PawMedic</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
<div class="container py-4" style="max-width:560px;">
|
||||
<div class="card shadow-sm border-0 rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="mb-3">Ganti Password Baru</h3>
|
||||
<p class="text-muted mb-3">OTP valid. Silakan set password admin baru.</p>
|
||||
|
||||
@if(session('error'))
|
||||
<div class="alert alert-danger">{{ session('error') }}</div>
|
||||
@endif
|
||||
@if($errors->any())
|
||||
<div class="alert alert-danger">{{ $errors->first() }}</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('admin.forgot.reset.submit') }}">
|
||||
@csrf
|
||||
<label class="form-label">Password baru</label>
|
||||
<input type="password" class="form-control mb-3" name="new_password" minlength="6" required>
|
||||
<label class="form-label">Konfirmasi password baru</label>
|
||||
<input type="password" class="form-control mb-3" name="new_password_confirmation" minlength="6" required>
|
||||
<button type="submit" class="btn btn-success">Simpan Password Baru</button>
|
||||
</form>
|
||||
|
||||
<a href="{{ route('admin.login') }}" class="btn btn-link ps-0 mt-2">Kembali ke login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<h2>📊 Statistik Penyakit</h2>
|
||||
|
||||
<canvas id="chartPenyakit"></canvas>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('chartPenyakit');
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: {!! json_encode($chartLabels) !!},
|
||||
datasets: [{
|
||||
label: 'Jumlah Kasus',
|
||||
data: {!! json_encode($chartData) !!}
|
||||
}]
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Calon Data Training - PawMedic Admin</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://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body{margin:0;font-family:'Inter',sans-serif;background:#f4faf7;color:#1f2937}
|
||||
.container{max-width:1180px;margin:0 auto;padding:28px 16px}
|
||||
.head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:18px}
|
||||
.title-wrap{display:flex;align-items:center;gap:10px}
|
||||
.logo-icon{width:40px;height:40px;border-radius:10px;background:linear-gradient(135deg,#6fcf97,#4bb66f);display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px}
|
||||
.title{font-family:'Poppins',sans-serif;font-size:30px;margin:0;color:#114d3a}
|
||||
.subtitle{color:#64748b;margin-top:6px}
|
||||
.card{background:#fff;border:1px solid #d1fae5;border-radius:16px;padding:16px;box-shadow:0 8px 20px rgba(17,77,58,.08)}
|
||||
.notice{background:#e8f7ef;border:1px solid #b7ebcf;padding:10px;border-radius:10px;color:#114d3a;margin-bottom:10px}
|
||||
.btn{padding:10px 14px;border-radius:10px;border:1px solid #6fcf97;background:#6fcf97;color:#fff;font-weight:700;cursor:pointer;text-decoration:none}
|
||||
.btn.secondary{background:#fff;color:#114d3a}
|
||||
.btn.danger{background:#fff;border-color:#fca5a5;color:#b91c1c}
|
||||
.table-wrap{overflow:auto;border:1px solid #e2e8f0;border-radius:10px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:10px;border-bottom:1px solid #e2e8f0;vertical-align:middle}
|
||||
th{background:#e8f7ef;text-align:left}
|
||||
input{width:100%;padding:8px;border:1px solid #cbd5e1;border-radius:8px;font:inherit}
|
||||
.actions{display:flex;justify-content:space-between;align-items:center;margin-top:12px;gap:10px;flex-wrap:wrap}
|
||||
.tag{display:inline-block;padding:4px 8px;border-radius:999px;font-size:12px;font-weight:700}
|
||||
.tag.ready{background:#dcfce7;color:#166534}
|
||||
.tag.need{background:#fee2e2;color:#991b1b}
|
||||
.row-no{width:54px;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="title-wrap">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="title">Calon Data Training Penyakit</h1>
|
||||
</div>
|
||||
<div class="subtitle">Tahap awal hanya isi nama penyakit dan kategori.</div>
|
||||
</div>
|
||||
<a class="btn secondary" href="{{ route('admin.dashboard') }}">← Dashboard</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@if(session('success'))
|
||||
<div class="notice">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if(session('error'))
|
||||
<div class="notice" style="background:#fee2e2;border-color:#fca5a5;color:#991b1b">{{ session('error') }}</div>
|
||||
@endif
|
||||
<div class="notice">
|
||||
Isi data penyakit dulu, lalu klik <strong>Atur Gejala</strong>. Status <strong>Belum Siap</strong> jika sample terisi belum 10, dan <strong>Siap</strong> jika sudah 10/10.
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('admin.training.settings.save') }}">
|
||||
@csrf
|
||||
<div class="table-wrap">
|
||||
<table id="trainingTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="row-no">No</th>
|
||||
<th style="min-width:220px;">Penyakit</th>
|
||||
<th style="min-width:180px;">Kategori</th>
|
||||
<th style="min-width:130px;">Sample Terisi</th>
|
||||
<th style="min-width:120px;">Status</th>
|
||||
<th style="min-width:240px;">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows">
|
||||
@forelse($items as $it)
|
||||
<tr class="training-row">
|
||||
<td class="row-no row-counter"></td>
|
||||
<td style="display:none;"><input type="hidden" name="id[]" value="{{ $it['id'] ?? '' }}"></td>
|
||||
<td><input type="text" name="disease[]" value="{{ $it['disease'] ?? '' }}"></td>
|
||||
<td><input type="text" name="category[]" value="{{ $it['category'] ?? '' }}"></td>
|
||||
<td>{{ (int)($it['samples'] ?? 0) }}/10</td>
|
||||
<td>
|
||||
@if(($it['status'] ?? '') === 'ready-train')
|
||||
<span class="tag ready">Siap</span>
|
||||
@else
|
||||
<span class="tag need">Belum Siap</span>
|
||||
@endif
|
||||
</td>
|
||||
<td style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<a class="btn secondary" href="{{ route('admin.training.symptoms.edit', $it['id']) }}">Atur Gejala</a>
|
||||
<button type="button" class="btn danger btn-delete-row" onclick="askDeleteTrainingRow(this)">Hapus</button>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr class="training-row">
|
||||
<td class="row-no row-counter"></td>
|
||||
<td style="display:none;"><input type="hidden" name="id[]" value=""></td>
|
||||
<td><input type="text" name="disease[]" placeholder="Contoh: Feline Diabetes"></td>
|
||||
<td><input type="text" name="category[]" placeholder="Contoh: Metabolik"></td>
|
||||
<td>0/10</td>
|
||||
<td><span class="tag need">Belum Siap</span></td>
|
||||
<td><span class="btn secondary" style="opacity:.6;pointer-events:none;">Simpan dulu</span></td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn secondary" onclick="addRow()">+ Tambah Baris</button>
|
||||
<a href="{{ route('admin.training.export') }}" class="btn secondary">Export Data Kandidat</a>
|
||||
<button type="submit" class="btn">Simpan Tahap Awal</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function addRow() {
|
||||
const tbody = document.getElementById('rows');
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'training-row';
|
||||
tr.innerHTML = `
|
||||
<td class="row-no row-counter"></td>
|
||||
<td style="display:none;"><input type="hidden" name="id[]" value=""></td>
|
||||
<td><input type="text" name="disease[]" placeholder="Nama penyakit"></td>
|
||||
<td><input type="text" name="category[]" placeholder="Kategori"></td>
|
||||
<td>0/10</td>
|
||||
<td><span class="tag need">Belum Siap</span></td>
|
||||
<td><span class="btn secondary" style="opacity:.6;pointer-events:none;">Simpan dulu</span></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
reindexRows();
|
||||
}
|
||||
let pendingDeleteRow = null;
|
||||
function askDeleteTrainingRow(btn) {
|
||||
pendingDeleteRow = btn.closest('.training-row');
|
||||
const modalEl = document.getElementById('confirmDeleteTrainingModal');
|
||||
if (window.bootstrap && modalEl) {
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
modal.show();
|
||||
return;
|
||||
}
|
||||
deleteTrainingRow();
|
||||
}
|
||||
function deleteTrainingRow() {
|
||||
const row = pendingDeleteRow;
|
||||
const modalEl = document.getElementById('confirmDeleteTrainingModal');
|
||||
if (modalEl && window.bootstrap) {
|
||||
const instance = bootstrap.Modal.getInstance(modalEl);
|
||||
if (instance) instance.hide();
|
||||
}
|
||||
const rows = document.querySelectorAll('.training-row');
|
||||
if (!row || rows.length <= 1) {
|
||||
const minRowAlert = document.getElementById('minRowAlert');
|
||||
if (minRowAlert) minRowAlert.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
pendingDeleteRow = null;
|
||||
reindexRows();
|
||||
}
|
||||
function reindexRows() {
|
||||
const rows = document.querySelectorAll('#rows .training-row');
|
||||
rows.forEach((row, idx) => {
|
||||
const no = row.querySelector('.row-counter');
|
||||
if (no) no.textContent = idx + 1;
|
||||
});
|
||||
}
|
||||
reindexRows();
|
||||
</script>
|
||||
<div id="minRowAlert" class="alert alert-warning d-none" style="position:fixed;right:16px;bottom:16px;z-index:1080;">
|
||||
Minimal harus ada 1 baris data.
|
||||
</div>
|
||||
<div class="modal fade" id="confirmDeleteTrainingModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Konfirmasi Hapus</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">Yakin ingin menghapus baris data ini?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" style="width:auto;">Batal</button>
|
||||
<button type="button" class="btn btn-danger" style="width:auto;" onclick="deleteTrainingRow()">Ya, Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Atur Gejala Sample - PawMedic Admin</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">
|
||||
<style>
|
||||
body{margin:0;font-family:'Inter',sans-serif;background:#f4faf7;color:#1f2937}
|
||||
.container{max-width:98vw;margin:0 auto;padding:24px 14px}
|
||||
.head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}
|
||||
.title{font-family:'Poppins',sans-serif;font-size:28px;margin:0;color:#114d3a}
|
||||
.sub{color:#64748b;margin-top:4px}
|
||||
.card{background:#fff;border:1px solid #d1fae5;border-radius:16px;padding:14px;box-shadow:0 8px 20px rgba(17,77,58,.08)}
|
||||
.notice{background:#e8f7ef;border:1px solid #b7ebcf;padding:10px;border-radius:10px;color:#114d3a;margin-bottom:10px}
|
||||
.btn{padding:10px 14px;border-radius:10px;border:1px solid #6fcf97;background:#6fcf97;color:#fff;font-weight:700;cursor:pointer;text-decoration:none}
|
||||
.btn.secondary{background:#fff;color:#114d3a}
|
||||
.table-wrap{overflow:auto;border:1px solid #e2e8f0;border-radius:10px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:8px;border-bottom:1px solid #e2e8f0;vertical-align:middle}
|
||||
th{background:#e8f7ef;text-align:center;font-size:12px}
|
||||
td{text-align:center}
|
||||
.sample-col{min-width:86px;font-weight:700;color:#14532d}
|
||||
.gejala-col{min-width:115px}
|
||||
.actions{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">Atur Gejala: {{ $item['disease'] ?? '-' }}</h1>
|
||||
<div class="sub">Kategori: {{ $item['category'] ?? '-' }} | Isi 10 baris sample</div>
|
||||
</div>
|
||||
<a href="{{ route('admin.training.settings') }}" class="btn secondary">← Kembali</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="notice">Centang gejala yang bernilai <strong>Ya</strong>. Tidak dicentang berarti <strong>Tidak</strong>.</div>
|
||||
<form method="POST" action="{{ route('admin.training.symptoms.save', $item['id']) }}">
|
||||
@csrf
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sample-col">Sample</th>
|
||||
@foreach($featureCols as $col)
|
||||
<th class="gejala-col">{{ $col }}</th>
|
||||
@endforeach
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for($r = 0; $r < 10; $r++)
|
||||
<tr>
|
||||
<td class="sample-col">{{ $r + 1 }}</td>
|
||||
@foreach($featureCols as $col)
|
||||
@php
|
||||
$isYes = strcasecmp((string)($item['symptom_samples'][$r][$col] ?? 'Tidak'), 'Ya') === 0;
|
||||
@endphp
|
||||
<td>
|
||||
<input type="checkbox" name="sample_rows[{{ $r }}][{{ $col }}]" value="1" {{ $isYes ? 'checked' : '' }}>
|
||||
</td>
|
||||
@endforeach
|
||||
</tr>
|
||||
@endfor
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="{{ route('admin.training.settings') }}" class="btn secondary">Kembali</a>
|
||||
<button type="submit" class="btn">Simpan Data</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,665 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Input Biodata - 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 rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
|
||||
<style>
|
||||
/* ===== GLOBAL ===== */
|
||||
:root{
|
||||
--ff-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--primary: #6fcf97;
|
||||
--primary-dark: #4bb66f;
|
||||
--primary-light: #e8f7ef;
|
||||
--text-dark: #114d3a;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg, #f0fdf4 0%, #eaf7f0 100%);
|
||||
color:#333;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.6;
|
||||
min-height:100vh;
|
||||
padding:20px;
|
||||
}
|
||||
|
||||
.container{
|
||||
max-width:800px;
|
||||
margin:0 auto;
|
||||
padding:40px 0;
|
||||
}
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
.header{
|
||||
text-align:center;
|
||||
margin-bottom:40px;
|
||||
animation:fadeDown 0.6s ease;
|
||||
}
|
||||
|
||||
.logo-link{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
text-decoration:none;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:20px;
|
||||
transition:transform 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-link:hover{
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
|
||||
.logo-icon{
|
||||
width:44px;
|
||||
height:44px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:12px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:22px;
|
||||
}
|
||||
|
||||
.logo-text{
|
||||
font-size:22px;
|
||||
font-weight:700;
|
||||
font-family:var(--ff-heading);
|
||||
}
|
||||
|
||||
.header h1{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:clamp(1.8rem, 4vw, 2.4rem);
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin:20px 0 12px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
|
||||
.header p{
|
||||
color:var(--text-muted);
|
||||
font-size:16px;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
/* ===== CARD FORM ===== */
|
||||
.form-card{
|
||||
background:white;
|
||||
border-radius:24px;
|
||||
padding:48px;
|
||||
box-shadow:0 20px 60px rgba(17,77,58,0.1);
|
||||
animation:fadeUp 0.8s ease;
|
||||
border:1px solid rgba(111,207,151,0.1);
|
||||
}
|
||||
|
||||
/* ===== FORM ===== */
|
||||
.form-group{
|
||||
margin-bottom:28px;
|
||||
}
|
||||
|
||||
.form-group label{
|
||||
display:block;
|
||||
font-weight:600;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:10px;
|
||||
font-size:15px;
|
||||
font-family:var(--ff-heading);
|
||||
}
|
||||
|
||||
.form-group label .required{
|
||||
color:#ef4444;
|
||||
margin-left:4px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select{
|
||||
width:100%;
|
||||
padding:14px 18px;
|
||||
border:2px solid var(--border);
|
||||
border-radius:12px;
|
||||
font-size:15px;
|
||||
font-family:var(--ff-body);
|
||||
transition:all 0.3s ease;
|
||||
background:#fafafa;
|
||||
color:#333;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus{
|
||||
outline:none;
|
||||
border-color:var(--primary);
|
||||
background:white;
|
||||
box-shadow:0 0 0 4px rgba(111,207,151,0.1);
|
||||
}
|
||||
|
||||
.form-group textarea{
|
||||
resize:vertical;
|
||||
min-height:100px;
|
||||
font-family:var(--ff-body);
|
||||
}
|
||||
|
||||
.form-group small{
|
||||
display:block;
|
||||
margin-top:6px;
|
||||
color:var(--text-muted);
|
||||
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{
|
||||
display:grid;
|
||||
grid-template-columns:1fr 1fr;
|
||||
gap:20px;
|
||||
}
|
||||
|
||||
/* ===== BUTTON ===== */
|
||||
.btn-group{
|
||||
display:flex;
|
||||
gap:16px;
|
||||
margin-top:40px;
|
||||
padding-top:32px;
|
||||
border-top:1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn{
|
||||
flex:1;
|
||||
padding:16px 32px;
|
||||
border:none;
|
||||
border-radius:14px;
|
||||
font-weight:600;
|
||||
font-size:16px;
|
||||
cursor:pointer;
|
||||
transition:all 0.3s ease;
|
||||
text-decoration:none;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:8px;
|
||||
font-family:var(--ff-body);
|
||||
letter-spacing:0.3px;
|
||||
}
|
||||
|
||||
.btn-primary{
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
color:white;
|
||||
box-shadow:0 4px 16px rgba(111,207,151,0.3);
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.btn-primary::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:-100%;
|
||||
width:100%;
|
||||
height:100%;
|
||||
background:linear-gradient(90deg, transparent, rgba(255,255,255,0.25), transparent);
|
||||
transition:left 0.6s;
|
||||
}
|
||||
|
||||
.btn-primary:hover::before{
|
||||
left:100%;
|
||||
}
|
||||
|
||||
.btn-primary:hover{
|
||||
transform:translateY(-3px) scale(1.02);
|
||||
box-shadow:0 12px 32px rgba(17,77,58,0.25);
|
||||
}
|
||||
|
||||
.btn-secondary{
|
||||
background:white;
|
||||
color:var(--primary-dark);
|
||||
border:2px solid var(--primary);
|
||||
box-shadow:0 2px 8px rgba(17,77,58,0.1);
|
||||
}
|
||||
|
||||
.btn-secondary:hover{
|
||||
background:var(--primary-light);
|
||||
transform:translateY(-2px);
|
||||
box-shadow:0 8px 20px rgba(17,77,58,0.15);
|
||||
}
|
||||
|
||||
/* ===== ANIMATIONS ===== */
|
||||
@keyframes fadeUp{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(30px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeDown{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(-20px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media(max-width:768px){
|
||||
body{
|
||||
padding:16px;
|
||||
}
|
||||
|
||||
.container{
|
||||
padding:20px 0;
|
||||
}
|
||||
|
||||
.form-card{
|
||||
padding:32px 24px;
|
||||
border-radius:20px;
|
||||
}
|
||||
|
||||
.form-row{
|
||||
grid-template-columns:1fr;
|
||||
gap:0;
|
||||
}
|
||||
|
||||
.btn-group{
|
||||
flex-direction:column;
|
||||
}
|
||||
|
||||
.btn{
|
||||
width:100%;
|
||||
}
|
||||
|
||||
.header h1{
|
||||
font-size:1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:480px){
|
||||
.form-card{
|
||||
padding:24px 20px;
|
||||
}
|
||||
|
||||
.header h1{
|
||||
font-size:1.6rem;
|
||||
}
|
||||
|
||||
.form-group{
|
||||
margin-bottom:24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:576px) and (orientation:portrait){
|
||||
.header h1{font-size:1.35rem;line-height:1.35;}
|
||||
.header p{font-size:14px;}
|
||||
.logo-icon{width:38px;height:38px;}
|
||||
.form-card{padding:18px 14px;border-radius:16px;}
|
||||
.form-group label{font-size:14px;}
|
||||
.form-group input,.form-group select,.form-group textarea{font-size:14px;padding:10px 12px;}
|
||||
.btn{font-size:14px;padding:10px 12px;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- HEADER -->
|
||||
<div class="header">
|
||||
<a href="/" class="logo-link">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">PawMedic</div>
|
||||
</a>
|
||||
@php
|
||||
$breadcrumbItems = [
|
||||
['label' => 'Beranda', 'url' => '/'],
|
||||
['label' => 'Biodata', 'url' => '#']
|
||||
];
|
||||
@endphp
|
||||
@include('components.breadcrumb', ['items' => $breadcrumbItems])
|
||||
<h1>Input Biodata Kucing</h1>
|
||||
<p>Lengkapi informasi kucing Anda untuk memulai diagnosis</p>
|
||||
</div>
|
||||
|
||||
<!-- FORM CARD -->
|
||||
<div class="form-card">
|
||||
<form id="biodataForm" action="{{ route('biodata.simpan') }}" method="POST">
|
||||
@csrf
|
||||
|
||||
<!-- Nama Pemilik -->
|
||||
<div class="form-group">
|
||||
<label for="nama_pemilik">
|
||||
Nama Pemilik <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nama_pemilik"
|
||||
name="nama_pemilik"
|
||||
placeholder="Masukkan nama Anda"
|
||||
required
|
||||
autocomplete="name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Nama Kucing -->
|
||||
<div class="form-group">
|
||||
<label for="nama_kucing">
|
||||
Nama Kucing <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nama_kucing"
|
||||
name="nama_kucing"
|
||||
placeholder="Masukkan nama kucing"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Umur Kucing -->
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="umur_kucing">
|
||||
Umur Kucing <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="umur_kucing"
|
||||
name="umur_kucing"
|
||||
placeholder="Dalam bulan"
|
||||
min="0"
|
||||
max="240"
|
||||
required
|
||||
>
|
||||
<small>Contoh: 12 (untuk 1 tahun)</small>
|
||||
</div>
|
||||
|
||||
<!-- Jenis Kelamin -->
|
||||
<div class="form-group">
|
||||
<label for="jenis_kelamin">
|
||||
Jenis Kelamin <span class="required">*</span>
|
||||
</label>
|
||||
<select id="jenis_kelamin" name="jenis_kelamin" required>
|
||||
<option value="">Pilih jenis kelamin</option>
|
||||
<option value="jantan">Jantan</option>
|
||||
<option value="betina">Betina</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ras Kucing -->
|
||||
<div class="form-group">
|
||||
<label for="ras_kucing">
|
||||
Ras Kucing
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="ras_kucing"
|
||||
name="ras_kucing"
|
||||
placeholder="Contoh: Persia, Angora, atau Campuran"
|
||||
>
|
||||
<small>Opsional - Kosongkan jika tidak tahu</small>
|
||||
</div>
|
||||
|
||||
<!-- Berat Badan -->
|
||||
<div class="form-group">
|
||||
<label for="berat_badan">
|
||||
Berat Badan (kg) <span class="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="berat_badan"
|
||||
name="berat_badan"
|
||||
placeholder="Contoh: 3.5"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
max="15"
|
||||
required
|
||||
>
|
||||
<small>Berat badan kucing dalam kilogram</small>
|
||||
</div>
|
||||
|
||||
<!-- Alamat -->
|
||||
<div class="form-group">
|
||||
<label for="alamat">
|
||||
Alamat Kecamatan <span class="required">*</span>
|
||||
</label>
|
||||
<div class="combo-wrap" id="alamatCombo">
|
||||
<div class="combo-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
id="alamat"
|
||||
name="alamat"
|
||||
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>
|
||||
|
||||
<!-- Nomor Telepon -->
|
||||
<div class="form-group">
|
||||
<label for="no_telepon">
|
||||
Nomor Telepon
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="no_telepon"
|
||||
name="no_telepon"
|
||||
placeholder="Contoh: 081234567890"
|
||||
pattern="[0-9]{10,13}"
|
||||
>
|
||||
<small>Opsional - Untuk keperluan dokumentasi</small>
|
||||
</div>
|
||||
|
||||
<!-- Button Group -->
|
||||
<div class="btn-group">
|
||||
<a href="/" class="btn btn-secondary">
|
||||
Kembali
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span>Lanjutkan Diagnosis</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
];
|
||||
|
||||
const biodataForm = document.getElementById('biodataForm');
|
||||
const alamatInput = document.getElementById('alamat');
|
||||
const alamatCombo = document.getElementById('alamatCombo');
|
||||
const alamatOptions = document.getElementById('alamatOptions');
|
||||
const alamatToggle = document.getElementById('alamatToggle');
|
||||
|
||||
function normalizeText(value) {
|
||||
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;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
nama_pemilik: document.getElementById('nama_pemilik').value.trim(),
|
||||
nama_kucing: document.getElementById('nama_kucing').value.trim(),
|
||||
umur_kucing: document.getElementById('umur_kucing').value,
|
||||
jenis_kelamin: document.getElementById('jenis_kelamin').value,
|
||||
ras_kucing: document.getElementById('ras_kucing').value.trim(),
|
||||
berat_badan: document.getElementById('berat_badan').value,
|
||||
alamat: alamatInput.value.trim(),
|
||||
no_telepon: document.getElementById('no_telepon').value.trim()
|
||||
};
|
||||
|
||||
sessionStorage.setItem('biodata_kucing', JSON.stringify(formData));
|
||||
});
|
||||
</script>
|
||||
|
||||
@include('components.toast')
|
||||
@include('components.scroll-top')
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<!-- Breadcrumb Navigation -->
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb">
|
||||
<ol class="breadcrumb-list">
|
||||
@foreach($items as $index => $item)
|
||||
@if($index < count($items) - 1)
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ $item['url'] }}">{{ $item['label'] }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-separator">›</li>
|
||||
@else
|
||||
<li class="breadcrumb-item active">{{ $item['label'] }}</li>
|
||||
@endif
|
||||
@endforeach
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.breadcrumb{
|
||||
margin-bottom:24px;
|
||||
animation:fadeDown 0.4s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-list{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
list-style:none;
|
||||
padding:0;
|
||||
margin:0;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
.breadcrumb-item{
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.breadcrumb-item a{
|
||||
color:var(--text-muted, #64748b);
|
||||
text-decoration:none;
|
||||
transition:color 0.2s ease;
|
||||
font-weight:500;
|
||||
}
|
||||
|
||||
.breadcrumb-item a:hover{
|
||||
color:var(--primary-dark, #4bb66f);
|
||||
}
|
||||
|
||||
.breadcrumb-item.active{
|
||||
color:var(--text-dark, #114d3a);
|
||||
font-weight:600;
|
||||
}
|
||||
|
||||
.breadcrumb-separator{
|
||||
color:var(--text-muted, #64748b);
|
||||
font-size:16px;
|
||||
user-select:none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<!-- Scroll to Top Button -->
|
||||
<button id="scrollTopBtn" class="scroll-top-btn" aria-label="Scroll to top">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 15V5M10 5L5 10M10 5L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.scroll-top-btn{
|
||||
position:fixed;
|
||||
bottom:30px;
|
||||
right:30px;
|
||||
width:50px;
|
||||
height:50px;
|
||||
background:linear-gradient(135deg, var(--primary, #6fcf97) 0%, var(--primary-dark, #4bb66f) 100%);
|
||||
color:white;
|
||||
border:none;
|
||||
border-radius:50%;
|
||||
cursor:pointer;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
box-shadow:0 4px 16px rgba(111,207,151,0.4);
|
||||
transition:all 0.3s ease;
|
||||
z-index:1000;
|
||||
opacity:0;
|
||||
visibility:hidden;
|
||||
transform:translateY(20px);
|
||||
}
|
||||
|
||||
.scroll-top-btn.visible{
|
||||
opacity:1;
|
||||
visibility:visible;
|
||||
transform:translateY(0);
|
||||
}
|
||||
|
||||
.scroll-top-btn:hover{
|
||||
transform:translateY(-4px) scale(1.1);
|
||||
box-shadow:0 8px 24px rgba(111,207,151,0.5);
|
||||
}
|
||||
|
||||
.scroll-top-btn:active{
|
||||
transform:translateY(-2px) scale(1.05);
|
||||
}
|
||||
|
||||
@media(max-width:768px){
|
||||
.scroll-top-btn{
|
||||
bottom:20px;
|
||||
right:20px;
|
||||
width:44px;
|
||||
height:44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const scrollTopBtn = document.getElementById('scrollTopBtn');
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.pageYOffset > 300) {
|
||||
scrollTopBtn.classList.add('visible');
|
||||
} else {
|
||||
scrollTopBtn.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
scrollTopBtn.addEventListener('click', () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
<!-- Toast Notification System -->
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
|
||||
<style>
|
||||
.toast-container{
|
||||
position:fixed;
|
||||
top:20px;
|
||||
right:20px;
|
||||
z-index:10000;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:12px;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.toast{
|
||||
background:white;
|
||||
padding:16px 20px;
|
||||
border-radius:12px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,0.15);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
min-width:300px;
|
||||
max-width:400px;
|
||||
pointer-events:auto;
|
||||
animation:toastSlideIn 0.3s ease;
|
||||
border-left:4px solid;
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.toast::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
left:0;
|
||||
top:0;
|
||||
bottom:0;
|
||||
width:4px;
|
||||
background:currentColor;
|
||||
}
|
||||
|
||||
.toast.success{
|
||||
border-left-color:#10b981;
|
||||
color:#065f46;
|
||||
}
|
||||
|
||||
.toast.error{
|
||||
border-left-color:#ef4444;
|
||||
color:#991b1b;
|
||||
}
|
||||
|
||||
.toast.info{
|
||||
border-left-color:#3b82f6;
|
||||
color:#1e40af;
|
||||
}
|
||||
|
||||
.toast.warning{
|
||||
border-left-color:#f59e0b;
|
||||
color:#92400e;
|
||||
}
|
||||
|
||||
.toast-icon{
|
||||
font-size:20px;
|
||||
flex-shrink:0;
|
||||
}
|
||||
|
||||
.toast-content{
|
||||
flex:1;
|
||||
}
|
||||
|
||||
.toast-title{
|
||||
font-weight:600;
|
||||
font-size:15px;
|
||||
margin-bottom:4px;
|
||||
}
|
||||
|
||||
.toast-message{
|
||||
font-size:14px;
|
||||
opacity:0.8;
|
||||
}
|
||||
|
||||
.toast-close{
|
||||
background:none;
|
||||
border:none;
|
||||
font-size:18px;
|
||||
cursor:pointer;
|
||||
opacity:0.5;
|
||||
transition:opacity 0.2s;
|
||||
padding:0;
|
||||
width:24px;
|
||||
height:24px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
.toast-close:hover{
|
||||
opacity:1;
|
||||
}
|
||||
|
||||
@keyframes toastSlideIn{
|
||||
from{
|
||||
transform:translateX(400px);
|
||||
opacity:0;
|
||||
}
|
||||
to{
|
||||
transform:translateX(0);
|
||||
opacity:1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut{
|
||||
from{
|
||||
transform:translateX(0);
|
||||
opacity:1;
|
||||
}
|
||||
to{
|
||||
transform:translateX(400px);
|
||||
opacity:0;
|
||||
}
|
||||
}
|
||||
|
||||
.toast.hiding{
|
||||
animation:toastSlideOut 0.3s ease forwards;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function showToast(message, type = 'info', title = null) {
|
||||
const container = document.getElementById('toastContainer');
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
|
||||
const icons = {
|
||||
success: '✓',
|
||||
error: '✕',
|
||||
warning: '!',
|
||||
info: 'i'
|
||||
};
|
||||
|
||||
toast.innerHTML = `
|
||||
<div class="toast-icon">${icons[type] || icons.info}</div>
|
||||
<div class="toast-content">
|
||||
${title ? `<div class="toast-title">${title}</div>` : ''}
|
||||
<div class="toast-message">${message}</div>
|
||||
</div>
|
||||
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
// Auto remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.classList.add('hiding');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Make it globally available
|
||||
window.showToast = showToast;
|
||||
</script>
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FAQ - 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 rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--ff-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--primary: #6fcf97;
|
||||
--primary-dark: #4bb66f;
|
||||
--primary-light: #e8f7ef;
|
||||
--text-dark: #114d3a;
|
||||
--text-muted: #64748b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg, #f0fdf4 0%, #eaf7f0 50%, #f0f9ff 100%);
|
||||
background-attachment:fixed;
|
||||
color:#333;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.6;
|
||||
min-height:100vh;
|
||||
padding:20px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
body::before{
|
||||
content:'';
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(111,207,151,0.12) 0%, transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(111,207,151,0.08) 0%, transparent 60%);
|
||||
pointer-events:none;
|
||||
z-index:0;
|
||||
}
|
||||
|
||||
.container{
|
||||
max-width:900px;
|
||||
margin:0 auto;
|
||||
padding:40px 0;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
.header{
|
||||
text-align:center;
|
||||
margin-bottom:50px;
|
||||
animation:fadeDown 0.6s ease;
|
||||
}
|
||||
|
||||
.logo-link{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
text-decoration:none;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:20px;
|
||||
transition:transform 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-link:hover{
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
|
||||
.logo-icon{
|
||||
width:44px;
|
||||
height:44px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:12px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:22px;
|
||||
}
|
||||
|
||||
.logo-text{
|
||||
font-size:22px;
|
||||
font-weight:700;
|
||||
font-family:var(--ff-heading);
|
||||
}
|
||||
|
||||
.header h1{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:clamp(1.8rem, 4vw, 2.6rem);
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin:20px 0 12px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
|
||||
.header p{
|
||||
color:var(--text-muted);
|
||||
font-size:17px;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
.faq-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:24px;
|
||||
padding:40px;
|
||||
box-shadow:0 20px 60px rgba(17,77,58,0.12);
|
||||
border:1px solid rgba(111,207,151,0.15);
|
||||
margin-bottom:24px;
|
||||
animation:fadeUp 0.6s ease backwards;
|
||||
}
|
||||
|
||||
.faq-card:nth-child(1){animation-delay:0.1s;}
|
||||
.faq-card:nth-child(2){animation-delay:0.2s;}
|
||||
.faq-card:nth-child(3){animation-delay:0.3s;}
|
||||
.faq-card:nth-child(4){animation-delay:0.4s;}
|
||||
.faq-card:nth-child(5){animation-delay:0.5s;}
|
||||
.faq-card:nth-child(n+6){animation-delay:0.6s;}
|
||||
|
||||
.faq-question{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:18px;
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:12px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
cursor:pointer;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
.faq-question::before{
|
||||
content:'❓';
|
||||
font-size:24px;
|
||||
flex-shrink:0;
|
||||
}
|
||||
|
||||
.faq-answer{
|
||||
color:var(--text-muted);
|
||||
line-height:1.8;
|
||||
font-size:15px;
|
||||
max-height:0;
|
||||
overflow:hidden;
|
||||
transition:max-height 0.3s ease, padding 0.3s ease;
|
||||
padding:0;
|
||||
}
|
||||
|
||||
.faq-card.active .faq-answer{
|
||||
max-height:500px;
|
||||
padding-top:12px;
|
||||
}
|
||||
|
||||
.faq-card.active .faq-question::before{
|
||||
content:'✓';
|
||||
}
|
||||
|
||||
.back-btn{
|
||||
margin-bottom:30px;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
color:var(--text-muted);
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
transition:all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover{
|
||||
color:var(--primary-dark);
|
||||
transform:translateX(-4px);
|
||||
}
|
||||
|
||||
@keyframes fadeDown{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(-20px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(30px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px){
|
||||
.faq-card{
|
||||
padding:28px 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:576px) and (orientation:portrait){
|
||||
.container{padding:14px;}
|
||||
.header h1{font-size:1.35rem;}
|
||||
.header p{font-size:14px;}
|
||||
.logo-icon{width:38px;height:38px;}
|
||||
.faq-card{padding:16px 12px;border-radius:14px;}
|
||||
.faq-question{font-size:15px;}
|
||||
.faq-answer{font-size:14px;line-height:1.6;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/" class="back-btn">
|
||||
← Kembali ke Beranda
|
||||
</a>
|
||||
|
||||
<div class="header">
|
||||
<a href="/" class="logo-link">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">PawMedic</div>
|
||||
</a>
|
||||
<h1>Pertanyaan Umum</h1>
|
||||
<p>Temukan jawaban untuk pertanyaan yang sering diajukan</p>
|
||||
</div>
|
||||
|
||||
<div class="faq-list">
|
||||
@forelse(($faqs ?? []) as $faq)
|
||||
<div class="faq-card">
|
||||
<div class="faq-question">{{ $faq['question'] ?? '-' }}</div>
|
||||
<div class="faq-answer" style="white-space: pre-line;">{{ $faq['answer'] ?? '-' }}</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="faq-card">
|
||||
<div class="faq-question">Belum ada FAQ</div>
|
||||
<div class="faq-answer">Konten FAQ belum tersedia.</div>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('components.scroll-top')
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.faq-question').forEach(question => {
|
||||
question.addEventListener('click', function() {
|
||||
const card = this.parentElement;
|
||||
const isActive = card.classList.contains('active');
|
||||
|
||||
// Close all other cards
|
||||
document.querySelectorAll('.faq-card').forEach(c => {
|
||||
c.classList.remove('active');
|
||||
});
|
||||
|
||||
// Toggle current card
|
||||
if (!isActive) {
|
||||
card.classList.add('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,674 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hasil Diagnosis - 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 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') }}">
|
||||
@php
|
||||
$diagnosis = session('diagnosis')?? [];
|
||||
@endphp
|
||||
<style>
|
||||
:root{
|
||||
--ff-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--primary: #6fcf97;
|
||||
--primary-dark: #4bb66f;
|
||||
--primary-light: #e8f7ef;
|
||||
--text-dark: #114d3a;
|
||||
--text-muted: #64748b;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--info: #3b82f6;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg, #f0fdf4 0%, #eaf7f0 50%, #f0f9ff 100%);
|
||||
background-attachment:fixed;
|
||||
color:#333;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.6;
|
||||
min-height:100vh;
|
||||
padding:20px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
body::before{
|
||||
content:'';
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(111,207,151,0.12) 0%, transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(111,207,151,0.08) 0%, transparent 60%);
|
||||
pointer-events:none;
|
||||
z-index:0;
|
||||
}
|
||||
|
||||
.container{
|
||||
max-width:1000px;
|
||||
margin:0 auto;
|
||||
padding:40px 0;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
.header{
|
||||
text-align:center;
|
||||
margin-bottom:40px;
|
||||
animation:fadeDown 0.6s ease;
|
||||
}
|
||||
|
||||
.logo-link{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
text-decoration:none;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:20px;
|
||||
transition:transform 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-link:hover{
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
|
||||
.logo-icon{
|
||||
width:44px;
|
||||
height:44px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:12px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:22px;
|
||||
}
|
||||
|
||||
.logo-text{
|
||||
font-size:22px;
|
||||
font-weight:700;
|
||||
font-family:var(--ff-heading);
|
||||
}
|
||||
|
||||
.header h1{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:clamp(1.8rem, 4vw, 2.4rem);
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin:20px 0 12px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
|
||||
/* ===== RESULT CARD ===== */
|
||||
.result-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:32px;
|
||||
padding:48px;
|
||||
box-shadow:
|
||||
0 25px 80px rgba(17,77,58,0.15),
|
||||
0 0 0 1px rgba(111,207,151,0.1);
|
||||
border:1px solid rgba(111,207,151,0.2);
|
||||
margin-bottom:32px;
|
||||
animation:fadeUp 0.8s ease;
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.result-card::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
height:5px;
|
||||
background:linear-gradient(90deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
}
|
||||
|
||||
.result-header{
|
||||
text-align:center;
|
||||
margin-bottom:40px;
|
||||
}
|
||||
|
||||
.result-icon{
|
||||
width:100px;
|
||||
height:100px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:50%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:48px;
|
||||
margin:0 auto 24px;
|
||||
box-shadow:0 12px 40px rgba(111,207,151,0.3);
|
||||
animation:iconBounce 2s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes iconBounce{
|
||||
0%, 100%{transform:translateY(0) scale(1);}
|
||||
50%{transform:translateY(-10px) scale(1.05);}
|
||||
}
|
||||
|
||||
.result-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:28px;
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:12px;
|
||||
}
|
||||
|
||||
.result-subtitle{
|
||||
color:var(--text-muted);
|
||||
font-size:16px;
|
||||
}
|
||||
|
||||
/* ===== DIAGNOSIS RESULT ===== */
|
||||
.diagnosis-result{
|
||||
background:var(--primary-light);
|
||||
border-left:5px solid var(--primary);
|
||||
padding:24px;
|
||||
border-radius:16px;
|
||||
margin-bottom:32px;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
|
||||
.diagnosis-label{
|
||||
font-size:14px;
|
||||
font-weight:600;
|
||||
color:var(--text-muted);
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.5px;
|
||||
margin-bottom:8px;
|
||||
}
|
||||
|
||||
.diagnosis-name{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:24px;
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:4px;
|
||||
}
|
||||
|
||||
.diagnosis-category{
|
||||
font-size:15px;
|
||||
color:var(--primary-dark);
|
||||
font-weight:600;
|
||||
}
|
||||
|
||||
.disease-explanation{
|
||||
margin-top:14px;
|
||||
background:#fff;
|
||||
border:1px solid #d1fae5;
|
||||
border-radius:12px;
|
||||
padding:14px 16px;
|
||||
color:#0f5132;
|
||||
font-size:14px;
|
||||
line-height:1.7;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
|
||||
/* ===== GEJALA LIST ===== */
|
||||
.gejala-list-section{
|
||||
margin-bottom:32px;
|
||||
}
|
||||
|
||||
.section-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:20px;
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:20px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.gejala-list{
|
||||
display:flex;
|
||||
flex-wrap:wrap;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.gejala-badge{
|
||||
background:white;
|
||||
border:2px solid var(--primary-light);
|
||||
padding:10px 18px;
|
||||
border-radius:20px;
|
||||
font-size:14px;
|
||||
font-weight:600;
|
||||
color:var(--text-dark);
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
transition:all 0.3s ease;
|
||||
}
|
||||
|
||||
.gejala-badge::before{
|
||||
content:'✓';
|
||||
width:20px;
|
||||
height:20px;
|
||||
background:var(--primary);
|
||||
color:white;
|
||||
border-radius:50%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:12px;
|
||||
font-weight:bold;
|
||||
}
|
||||
|
||||
.gejala-badge:hover{
|
||||
border-color:var(--primary);
|
||||
transform:translateY(-2px);
|
||||
box-shadow:0 4px 12px rgba(111,207,151,0.2);
|
||||
}
|
||||
|
||||
/* ===== RECOMMENDATION ===== */
|
||||
.recommendation{
|
||||
background:linear-gradient(135deg, #fff7ed 0%, #fffbeb 100%);
|
||||
border-left:5px solid var(--warning);
|
||||
padding:24px;
|
||||
border-radius:16px;
|
||||
margin-bottom:32px;
|
||||
}
|
||||
|
||||
.recommendation-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:18px;
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:16px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.recommendation-list{
|
||||
list-style:none;
|
||||
padding:0;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
.recommendation-list li{
|
||||
padding:12px 0;
|
||||
padding-left:32px;
|
||||
position:relative;
|
||||
color:var(--text-dark);
|
||||
line-height:1.7;
|
||||
}
|
||||
|
||||
.recommendation-list li::before{
|
||||
content:'→';
|
||||
position:absolute;
|
||||
left:0;
|
||||
color:var(--warning);
|
||||
font-weight:bold;
|
||||
font-size:18px;
|
||||
}
|
||||
|
||||
/* ===== WARNING BOX ===== */
|
||||
.warning-box{
|
||||
background:linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
|
||||
border-left:5px solid var(--danger);
|
||||
padding:24px;
|
||||
border-radius:16px;
|
||||
margin-bottom:32px;
|
||||
}
|
||||
|
||||
.warning-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:18px;
|
||||
font-weight:700;
|
||||
color:var(--danger);
|
||||
margin-bottom:12px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
.warning-text{
|
||||
color:var(--text-dark);
|
||||
line-height:1.7;
|
||||
}
|
||||
|
||||
.history-section{
|
||||
margin-bottom:32px;
|
||||
}
|
||||
|
||||
.history-table{
|
||||
width:100%;
|
||||
border-collapse:collapse;
|
||||
background:#fff;
|
||||
border:1px solid #d1fae5;
|
||||
border-radius:12px;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.history-table th,
|
||||
.history-table td{
|
||||
padding:10px 12px;
|
||||
border-bottom:1px solid #e2e8f0;
|
||||
text-align:left;
|
||||
font-size:14px;
|
||||
}
|
||||
|
||||
.history-table th{
|
||||
background:#f0fdf4;
|
||||
color:#0f5132;
|
||||
}
|
||||
|
||||
/* ===== BUTTONS ===== */
|
||||
.action-buttons{
|
||||
display:flex;
|
||||
gap:16px;
|
||||
margin-top:40px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
.btn{
|
||||
flex:1;
|
||||
min-width:200px;
|
||||
padding:16px 32px;
|
||||
border:none;
|
||||
border-radius:16px;
|
||||
font-weight:600;
|
||||
font-size:16px;
|
||||
cursor:pointer;
|
||||
transition:all 0.3s ease;
|
||||
text-decoration:none;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:8px;
|
||||
font-family:var(--ff-body);
|
||||
}
|
||||
|
||||
.btn-primary{
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
color:white;
|
||||
box-shadow:0 4px 16px rgba(111,207,151,0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover{
|
||||
transform:translateY(-3px);
|
||||
box-shadow:0 8px 24px rgba(111,207,151,0.4);
|
||||
}
|
||||
|
||||
.btn-secondary{
|
||||
background:white;
|
||||
color:var(--primary-dark);
|
||||
border:2px solid var(--primary);
|
||||
box-shadow:0 2px 8px rgba(17,77,58,0.1);
|
||||
}
|
||||
|
||||
.btn-secondary:hover{
|
||||
background:var(--primary-light);
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
|
||||
@keyframes fadeDown{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(-20px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(30px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px){
|
||||
.result-card{
|
||||
padding:32px 24px;
|
||||
}
|
||||
|
||||
.action-buttons{
|
||||
flex-direction:column;
|
||||
}
|
||||
|
||||
.btn{
|
||||
width:100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:576px) and (orientation:portrait){
|
||||
body{padding:10px;}
|
||||
.container{padding:10px 0;}
|
||||
.header h1{font-size:1.35rem;}
|
||||
.logo-icon{width:38px;height:38px;}
|
||||
.result-card{padding:16px 12px;border-radius:16px;}
|
||||
.result-title{font-size:1.1rem;}
|
||||
.result-subtitle,.diagnosis-description,.diagnosis-list li{font-size:13px;}
|
||||
.diagnosis-result,.recommendation,.warning-box{padding:14px 12px;border-radius:12px;margin-bottom:16px;}
|
||||
.diagnosis-name{font-size:20px;line-height:1.35;}
|
||||
.diagnosis-category{font-size:14px;}
|
||||
.disease-explanation{font-size:13px;line-height:1.65;padding:10px 12px;}
|
||||
.section-title{font-size:18px;margin-bottom:12px;}
|
||||
.gejala-list{gap:8px;}
|
||||
.gejala-badge{font-size:12px;padding:8px 10px;border-radius:12px;}
|
||||
.gejala-badge::before{width:16px;height:16px;font-size:10px;}
|
||||
.action-buttons{gap:10px;margin-top:20px;}
|
||||
.btn{font-size:14px;padding:10px 12px;min-width:unset;}
|
||||
.history-table th,.history-table td{font-size:12px;padding:8px 6px;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<a href="/" class="logo-link">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">PawMedic</div>
|
||||
</a>
|
||||
@php
|
||||
$breadcrumbItems = [
|
||||
['label' => 'Beranda', 'url' => '/'],
|
||||
['label' => 'Biodata', 'url' => route('biodata')],
|
||||
['label' => 'Gejala', 'url' => route('gejala')],
|
||||
['label' => 'Hasil Diagnosis', 'url' => '#']
|
||||
];
|
||||
@endphp
|
||||
@include('components.breadcrumb', ['items' => $breadcrumbItems])
|
||||
<h1>Hasil Diagnosis</h1>
|
||||
</div>
|
||||
|
||||
<div class="result-card">
|
||||
<div class="result-header">
|
||||
<div class="result-icon"><i class="bi bi-clipboard2-pulse"></i></div>
|
||||
<div class="result-title">Diagnosis Selesai</div>
|
||||
<div class="result-subtitle">Berikut adalah hasil analisis gejala kucing Anda</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnosis Result -->
|
||||
<div class="diagnosis-result">
|
||||
<div class="diagnosis-label">Kemungkinan Penyakit</div>
|
||||
<div class="diagnosis-name">
|
||||
{{ $diagnosis['nama'] ?? 'Tidak diketahui' }}
|
||||
</div>
|
||||
|
||||
<div class="diagnosis-category">
|
||||
Jenis: {{ $diagnosis['kategori'] ?? '-' }}
|
||||
</div>
|
||||
@if(!empty($diseaseDescription))
|
||||
<div class="disease-explanation">
|
||||
<strong>Penjelasan penyakit:</strong><br>
|
||||
{{ $diseaseDescription }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
<!-- Gejala yang Dipilih -->
|
||||
<div class="gejala-list-section">
|
||||
<div class="section-title">
|
||||
<span><i class="bi bi-search"></i> Gejala yang Dipilih</span>
|
||||
</div>
|
||||
<div class="gejala-list">
|
||||
@foreach(session('gejala', []) as $g)
|
||||
<div class="gejala-badge">{{ $g }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendation -->
|
||||
<div class="recommendation">
|
||||
<div class="recommendation-title">
|
||||
<span><i class="bi bi-lightbulb"></i> Rekomendasi Perawatan</span>
|
||||
</div>
|
||||
<ul class="recommendation-list">
|
||||
@foreach($diagnosis['pertolongan'] ?? [] as $item)
|
||||
<li>{{ $item }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="recommendation">
|
||||
<div class="recommendation-title">
|
||||
<span><i class="bi bi-shield-check"></i> Pencegahan</span>
|
||||
</div>
|
||||
<ul class="recommendation-list">
|
||||
@foreach($diagnosis['pencegahan'] ?? [] as $item)
|
||||
<li>{{ $item }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="warning-box">
|
||||
<div class="warning-title">
|
||||
<span><i class="bi bi-exclamation-triangle"></i> Peringatan Penting</span>
|
||||
</div>
|
||||
<div class="warning-text">
|
||||
Hasil diagnosis ini hanya sebagai panduan awal. Untuk diagnosis yang akurat dan penanganan yang tepat,
|
||||
sangat disarankan untuk berkonsultasi langsung dengan dokter hewan profesional.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons">
|
||||
<a href="{{ route('gejala') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Diagnosis Lagi
|
||||
</a>
|
||||
<a href="{{ route('hasil-diagnosis.pdf') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-download"></i> Download Hasil
|
||||
</a>
|
||||
<a href="/" class="btn btn-primary">
|
||||
<i class="bi bi-house-door"></i> Kembali ke Beranda
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(isset($diagnosisHistory) && $diagnosisHistory->count() > 0)
|
||||
<div class="result-card history-section">
|
||||
<div class="section-title">
|
||||
<span><i class="bi bi-clock-history"></i> Riwayat Diagnosis (Nomor yang sama)</span>
|
||||
</div>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tanggal</th>
|
||||
<th>Nama Kucing</th>
|
||||
<th>Hasil Diagnosis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($diagnosisHistory as $row)
|
||||
<tr>
|
||||
<td>
|
||||
<span class="rt-time" data-ts="{{ \Carbon\Carbon::parse($row->created_at)->toIso8601String() }}">
|
||||
{{ \Carbon\Carbon::parse($row->created_at)->format('d M Y H:i') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ $row->nama_kucing ?? '-' }}</td>
|
||||
<td>{{ $row->hasil_diagnosis ?? '-' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('components.scroll-top')
|
||||
|
||||
<script>
|
||||
function pad2(n){ return String(n).padStart(2,'0'); }
|
||||
function formatAbsolute(d){
|
||||
const months = ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agu','Sep','Okt','Nov','Des'];
|
||||
return `${pad2(d.getDate())} ${months[d.getMonth()]} ${d.getFullYear()} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
function formatRelative(d){
|
||||
const now = new Date();
|
||||
const diffMs = now - d;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
if (diffSec < 5) return 'baru saja';
|
||||
if (diffSec < 60) return `${diffSec} detik lalu`;
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
if (diffMin < 60) return `${diffMin} menit lalu`;
|
||||
const diffHour = Math.floor(diffMin / 60);
|
||||
if (diffHour < 24) return `${diffHour} jam lalu`;
|
||||
const diffDay = Math.floor(diffHour / 24);
|
||||
if (diffDay < 7) return `${diffDay} hari lalu`;
|
||||
return formatAbsolute(d);
|
||||
}
|
||||
function updateRealtimeTimes(){
|
||||
document.querySelectorAll('.rt-time').forEach((el) => {
|
||||
const ts = el.getAttribute('data-ts');
|
||||
if (!ts) return;
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return;
|
||||
el.textContent = formatRelative(d);
|
||||
el.title = formatAbsolute(d);
|
||||
});
|
||||
}
|
||||
updateRealtimeTimes();
|
||||
setInterval(updateRealtimeTimes, 15000);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media print{
|
||||
body::before,
|
||||
.action-buttons,
|
||||
.back-btn{
|
||||
display:none;
|
||||
}
|
||||
|
||||
.result-card{
|
||||
box-shadow:none;
|
||||
border:1px solid #ddd;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 172 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,456 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memproses Diagnosis - 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://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--ff-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--primary: #6fcf97;
|
||||
--primary-dark: #4bb66f;
|
||||
--primary-light: #e8f7ef;
|
||||
--text-dark: #114d3a;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg, #f0fdf4 0%, #eaf7f0 50%, #f0f9ff 100%);
|
||||
background-attachment:fixed;
|
||||
color:#333;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.6;
|
||||
min-height:100vh;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
padding:20px;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.container{
|
||||
text-align:center;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
.logo{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
margin-bottom:40px;
|
||||
animation:fadeDown 0.6s ease;
|
||||
}
|
||||
|
||||
.logo-icon{
|
||||
width:60px;
|
||||
height:60px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:16px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:28px;
|
||||
color:#fff;
|
||||
box-shadow:0 8px 24px rgba(111,207,151,0.3);
|
||||
animation:logoFloat 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes logoFloat{
|
||||
0%, 100%{transform:translateY(0px) rotate(0deg);}
|
||||
50%{transform:translateY(-10px) rotate(5deg);}
|
||||
}
|
||||
|
||||
.logo-text{
|
||||
font-size:28px;
|
||||
font-weight:700;
|
||||
font-family:var(--ff-heading);
|
||||
color:var(--text-dark);
|
||||
}
|
||||
|
||||
.loading-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:32px;
|
||||
padding:60px 50px;
|
||||
box-shadow:
|
||||
0 25px 80px rgba(17,77,58,0.15),
|
||||
0 0 0 1px rgba(111,207,151,0.1);
|
||||
border:1px solid rgba(111,207,151,0.2);
|
||||
max-width:500px;
|
||||
margin:0 auto;
|
||||
animation:fadeUp 0.8s ease;
|
||||
}
|
||||
|
||||
.loading-card::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
inset:0;
|
||||
background:linear-gradient(120deg, transparent 25%, rgba(255,255,255,0.38) 50%, transparent 75%);
|
||||
transform:translateX(-120%);
|
||||
animation:cardShine 2.2s ease-in-out infinite;
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
@keyframes cardShine{
|
||||
0%{transform:translateX(-120%);}
|
||||
100%{transform:translateX(120%);}
|
||||
}
|
||||
|
||||
.bootstrap-loader{
|
||||
width:80px;
|
||||
height:80px;
|
||||
margin:0 auto 22px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
}
|
||||
|
||||
.bootstrap-loader .spinner-border{
|
||||
width:3.5rem;
|
||||
height:3.5rem;
|
||||
border:0.35em solid #d1fae5;
|
||||
border-right-color:#4bb66f;
|
||||
border-top-color:#6fcf97;
|
||||
border-radius:50%;
|
||||
animation:spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.progress-wrap{
|
||||
margin-top:18px;
|
||||
margin-bottom:10px;
|
||||
}
|
||||
|
||||
.progress-track{
|
||||
width:100%;
|
||||
height:8px;
|
||||
background:#e2f6ea;
|
||||
border-radius:999px;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.progress-bar{
|
||||
width:0%;
|
||||
height:100%;
|
||||
background:linear-gradient(90deg,#6fcf97,#4bb66f);
|
||||
border-radius:999px;
|
||||
transition:width 0.45s ease;
|
||||
}
|
||||
|
||||
h1{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:32px;
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:16px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
|
||||
p{
|
||||
color:#64748b;
|
||||
font-size:17px;
|
||||
margin-bottom:40px;
|
||||
}
|
||||
|
||||
.loader{
|
||||
width:80px;
|
||||
height:80px;
|
||||
margin:0 auto 30px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
.loader-circle{
|
||||
width:100%;
|
||||
height:100%;
|
||||
border:6px solid var(--primary-light);
|
||||
border-top-color:var(--primary);
|
||||
border-right-color:var(--primary-dark);
|
||||
border-radius:50%;
|
||||
animation:spin 1s linear infinite;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
.loader-circle::before{
|
||||
content:'';
|
||||
position:absolute;
|
||||
top:50%;
|
||||
left:50%;
|
||||
width:50%;
|
||||
height:50%;
|
||||
border:4px solid transparent;
|
||||
border-top-color:var(--primary-dark);
|
||||
border-radius:50%;
|
||||
transform:translate(-50%, -50%);
|
||||
animation:spin 0.5s linear infinite reverse;
|
||||
}
|
||||
|
||||
@keyframes spin{
|
||||
to{transform:rotate(360deg);}
|
||||
}
|
||||
|
||||
.loading-dots{
|
||||
display:flex;
|
||||
gap:8px;
|
||||
justify-content:center;
|
||||
margin-top:20px;
|
||||
}
|
||||
|
||||
.dot{
|
||||
width:12px;
|
||||
height:12px;
|
||||
background:var(--primary);
|
||||
border-radius:50%;
|
||||
animation:dotBounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot:nth-child(1){animation-delay:0s;}
|
||||
.dot:nth-child(2){animation-delay:0.2s;}
|
||||
.dot:nth-child(3){animation-delay:0.4s;}
|
||||
|
||||
@keyframes dotBounce{
|
||||
0%, 80%, 100%{transform:translateY(0) scale(1); opacity:0.7;}
|
||||
40%{transform:translateY(-20px) scale(1.2); opacity:1;}
|
||||
}
|
||||
|
||||
.status-text{
|
||||
font-size:15px;
|
||||
color:var(--text-dark);
|
||||
font-weight:600;
|
||||
margin-top:20px;
|
||||
animation:pulse 2s ease infinite;
|
||||
}
|
||||
|
||||
.progress-label{
|
||||
margin-top:8px;
|
||||
font-size:13px;
|
||||
color:#4b5563;
|
||||
font-weight:600;
|
||||
}
|
||||
|
||||
@keyframes pulse{
|
||||
0%, 100%{opacity:1;}
|
||||
50%{opacity:0.6;}
|
||||
}
|
||||
|
||||
@keyframes fadeDown{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(-20px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(30px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.particles{
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
width:100%;
|
||||
height:100%;
|
||||
pointer-events:none;
|
||||
z-index:0;
|
||||
}
|
||||
|
||||
.particle{
|
||||
position:absolute;
|
||||
width:4px;
|
||||
height:4px;
|
||||
background:var(--primary);
|
||||
border-radius:50%;
|
||||
opacity:0.3;
|
||||
animation:float 15s infinite;
|
||||
}
|
||||
|
||||
.particle:nth-child(1){left:10%; animation-delay:0s; animation-duration:12s;}
|
||||
.particle:nth-child(2){left:20%; animation-delay:2s; animation-duration:15s;}
|
||||
.particle:nth-child(3){left:30%; animation-delay:4s; animation-duration:18s;}
|
||||
.particle:nth-child(4){left:40%; animation-delay:1s; animation-duration:14s;}
|
||||
.particle:nth-child(5){left:50%; animation-delay:3s; animation-duration:16s;}
|
||||
.particle:nth-child(6){left:60%; animation-delay:5s; animation-duration:13s;}
|
||||
.particle:nth-child(7){left:70%; animation-delay:2.5s; animation-duration:17s;}
|
||||
.particle:nth-child(8){left:80%; animation-delay:4.5s; animation-duration:15s;}
|
||||
.particle:nth-child(9){left:90%; animation-delay:1.5s; animation-duration:19s;}
|
||||
|
||||
@keyframes float{
|
||||
0%{
|
||||
transform:translateY(100vh) translateX(0);
|
||||
opacity:0;
|
||||
}
|
||||
10%{
|
||||
opacity:0.3;
|
||||
}
|
||||
90%{
|
||||
opacity:0.3;
|
||||
}
|
||||
100%{
|
||||
transform:translateY(-100px) translateX(50px);
|
||||
opacity:0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:576px) and (orientation:portrait){
|
||||
.container{padding:14px 10px;}
|
||||
.logo{margin-bottom:14px;}
|
||||
.logo-icon{width:38px;height:38px;}
|
||||
.logo-text{font-size:18px;}
|
||||
.loading-card{padding:18px 14px;border-radius:14px;}
|
||||
h1{font-size:1.35rem;margin-bottom:10px;}
|
||||
p{font-size:14px;margin-bottom:16px;}
|
||||
.bootstrap-loader .spinner-border{width:2.6rem;height:2.6rem;}
|
||||
.status-text,.progress-label{font-size:13px;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="particles">
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
<div class="particle"></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">PawMedic</div>
|
||||
</div>
|
||||
|
||||
<div class="loading-card">
|
||||
<h1>Memproses Diagnosis</h1>
|
||||
<p>Sedang menganalisis gejala yang Anda pilih...</p>
|
||||
|
||||
<div class="bootstrap-loader">
|
||||
<div class="spinner-border" role="status" aria-label="Loading"></div>
|
||||
</div>
|
||||
|
||||
<div class="loading-dots">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-track">
|
||||
<div class="progress-bar" id="progressBar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-label" id="progressLabel">0%</div>
|
||||
<div class="status-text" id="statusText">Menganalisis data gejala...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const statusMessages = [
|
||||
'Menganalisis data gejala...',
|
||||
'Memproses dengan sistem pakar...',
|
||||
'Menghitung probabilitas penyakit...',
|
||||
'Menyusun rekomendasi perawatan...',
|
||||
'Hampir selesai...'
|
||||
];
|
||||
|
||||
let currentStatus = 0;
|
||||
const statusText = document.getElementById('statusText');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressLabel = document.getElementById('progressLabel');
|
||||
let progress = 0;
|
||||
|
||||
// status text
|
||||
setInterval(() => {
|
||||
currentStatus = (currentStatus + 1) % statusMessages.length;
|
||||
statusText.textContent = statusMessages[currentStatus];
|
||||
}, 2000);
|
||||
|
||||
// progress animation
|
||||
const progressTimer = setInterval(() => {
|
||||
if (progress < 92) {
|
||||
progress += Math.random() * 4;
|
||||
} else if (progress < 97) {
|
||||
progress += Math.random() * 1.2;
|
||||
}
|
||||
|
||||
progress = Math.min(progress, 97);
|
||||
const rounded = Math.floor(progress);
|
||||
progressBar.style.width = rounded + '%';
|
||||
progressLabel.textContent = rounded + '%';
|
||||
}, 220);
|
||||
|
||||
// 🔥 AMBIL DATA DARI LOCAL STORAGE
|
||||
const gejala = JSON.parse(localStorage.getItem('gejala'));
|
||||
|
||||
// VALIDASI
|
||||
if (!gejala || gejala.length === 0) {
|
||||
window.location.href = '/cek-penyakit';
|
||||
}
|
||||
|
||||
// KIRIM KE LARAVEL
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/diagnosis/proses';
|
||||
|
||||
// CSRF
|
||||
let csrf = document.createElement('input');
|
||||
csrf.type = 'hidden';
|
||||
csrf.name = '_token';
|
||||
csrf.value = '{{ csrf_token() }}';
|
||||
form.appendChild(csrf);
|
||||
|
||||
// DATA GEJALA
|
||||
gejala.forEach(g => {
|
||||
let input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'gejala[]';
|
||||
input.value = g;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
|
||||
// SUBMIT SETELAH LOADING
|
||||
setTimeout(() => {
|
||||
progressBar.style.width = '100%';
|
||||
progressLabel.textContent = '100%';
|
||||
clearInterval(progressTimer);
|
||||
form.submit();
|
||||
}, 4600);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hasil Diagnosis PawMedic</title>
|
||||
<style>
|
||||
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #1f2937; line-height: 1.5; }
|
||||
.header { margin-bottom: 16px; border-bottom: 2px solid #16a34a; padding-bottom: 8px; }
|
||||
.title { font-size: 22px; font-weight: 700; color: #065f46; margin: 0; }
|
||||
.muted { color: #4b5563; font-size: 11px; margin-top: 4px; }
|
||||
.box { border: 1px solid #d1d5db; border-radius: 6px; padding: 10px; margin-bottom: 10px; }
|
||||
.box h3 { margin: 0 0 8px 0; font-size: 14px; color: #065f46; }
|
||||
ul { margin: 0; padding-left: 18px; }
|
||||
li { margin: 3px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1 class="title">Hasil Diagnosis PawMedic</h1>
|
||||
<div class="muted">Dibuat pada: {{ $generatedAt }}</div>
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h3>Ringkasan Diagnosis</h3>
|
||||
<div><strong>Penyakit:</strong> {{ $diagnosis['nama'] ?? '-' }}</div>
|
||||
<div><strong>Jenis:</strong> {{ $diagnosis['kategori'] ?? '-' }}</div>
|
||||
@if(!empty($diseaseDescription))
|
||||
<div style="margin-top:6px;"><strong>Penjelasan:</strong> {{ $diseaseDescription }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h3>Gejala Dipilih</h3>
|
||||
@if(!empty($gejala))
|
||||
<ul>
|
||||
@foreach($gejala as $item)
|
||||
<li>{{ $item }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<div>-</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h3>Rekomendasi Perawatan</h3>
|
||||
@if(!empty($diagnosis['pertolongan']) && is_array($diagnosis['pertolongan']))
|
||||
<ul>
|
||||
@foreach($diagnosis['pertolongan'] as $item)
|
||||
<li>{{ $item }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<div>-</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="box">
|
||||
<h3>Pencegahan</h3>
|
||||
@if(!empty($diagnosis['pencegahan']) && is_array($diagnosis['pencegahan']))
|
||||
<ul>
|
||||
@foreach($diagnosis['pencegahan'] as $item)
|
||||
<li>{{ $item }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@else
|
||||
<div>-</div>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,761 @@
|
|||
<!doctype html>
|
||||
<html lang="id">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Ulasan Pengguna - 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://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="icon" type="image/svg+xml" href="{{ asset('favicon.svg') }}">
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--ff-heading: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--ff-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial;
|
||||
--primary: #6fcf97;
|
||||
--primary-dark: #4bb66f;
|
||||
--primary-light: #e8f7ef;
|
||||
--text-dark: #114d3a;
|
||||
--text-muted: #64748b;
|
||||
--warning: #f59e0b;
|
||||
--border: #e2e8f0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body{
|
||||
margin:0;
|
||||
font-family:var(--ff-body);
|
||||
background:linear-gradient(135deg, #f0fdf4 0%, #eaf7f0 50%, #f0f9ff 100%);
|
||||
background-attachment:fixed;
|
||||
color:#333;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
line-height:1.6;
|
||||
min-height:100vh;
|
||||
padding:20px;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
body::before{
|
||||
content:'';
|
||||
position:fixed;
|
||||
top:0;
|
||||
left:0;
|
||||
right:0;
|
||||
bottom:0;
|
||||
background-image:
|
||||
radial-gradient(circle at 20% 50%, rgba(111,207,151,0.12) 0%, transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, rgba(111,207,151,0.08) 0%, transparent 60%);
|
||||
pointer-events:none;
|
||||
z-index:0;
|
||||
}
|
||||
|
||||
.container{
|
||||
max-width:1200px;
|
||||
margin:0 auto;
|
||||
padding:40px 0;
|
||||
position:relative;
|
||||
z-index:1;
|
||||
}
|
||||
|
||||
/* ===== HEADER ===== */
|
||||
.header{
|
||||
text-align:center;
|
||||
margin-bottom:50px;
|
||||
animation:fadeDown 0.6s ease;
|
||||
}
|
||||
|
||||
.logo-link{
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:10px;
|
||||
text-decoration:none;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:20px;
|
||||
transition:transform 0.3s ease;
|
||||
}
|
||||
|
||||
.logo-link:hover{
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
|
||||
.logo-icon{
|
||||
width:44px;
|
||||
height:44px;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius:12px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:22px;
|
||||
}
|
||||
|
||||
.logo-text{
|
||||
font-size:22px;
|
||||
font-weight:700;
|
||||
font-family:var(--ff-heading);
|
||||
}
|
||||
|
||||
.header h1{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:clamp(1.8rem, 4vw, 2.6rem);
|
||||
font-weight:800;
|
||||
color:var(--text-dark);
|
||||
margin:20px 0 12px;
|
||||
letter-spacing:-0.02em;
|
||||
}
|
||||
|
||||
.header p{
|
||||
color:var(--text-muted);
|
||||
font-size:17px;
|
||||
margin:0;
|
||||
}
|
||||
|
||||
/* ===== STATS CARD ===== */
|
||||
.stats-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:24px;
|
||||
padding:32px;
|
||||
box-shadow:0 20px 60px rgba(17,77,58,0.12);
|
||||
border:1px solid rgba(111,207,151,0.15);
|
||||
margin-bottom:40px;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap:24px;
|
||||
animation:fadeUp 0.8s ease;
|
||||
}
|
||||
|
||||
.stat-item{
|
||||
text-align:center;
|
||||
}
|
||||
|
||||
.stat-value{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:36px;
|
||||
font-weight:800;
|
||||
color:var(--primary-dark);
|
||||
margin-bottom:8px;
|
||||
}
|
||||
|
||||
.stat-label{
|
||||
font-size:14px;
|
||||
color:var(--text-muted);
|
||||
font-weight:600;
|
||||
text-transform:uppercase;
|
||||
letter-spacing:0.5px;
|
||||
}
|
||||
|
||||
/* ===== FORM CARD ===== */
|
||||
.form-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:28px;
|
||||
padding:40px;
|
||||
box-shadow:0 20px 60px rgba(17,77,58,0.12);
|
||||
border:1px solid rgba(111,207,151,0.15);
|
||||
margin-bottom:40px;
|
||||
animation:fadeUp 0.8s ease 0.2s backwards;
|
||||
}
|
||||
|
||||
.form-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:24px;
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:24px;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.form-group{
|
||||
margin-bottom:24px;
|
||||
}
|
||||
|
||||
.form-group label{
|
||||
display:block;
|
||||
font-weight:600;
|
||||
color:var(--text-dark);
|
||||
margin-bottom:10px;
|
||||
font-size:15px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea{
|
||||
width:100%;
|
||||
padding:14px 18px;
|
||||
border:2px solid var(--border);
|
||||
border-radius:12px;
|
||||
font-size:15px;
|
||||
font-family:var(--ff-body);
|
||||
transition:all 0.3s ease;
|
||||
background:#fafafa;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus{
|
||||
outline:none;
|
||||
border-color:var(--primary);
|
||||
background:white;
|
||||
box-shadow:0 0 0 4px rgba(111,207,151,0.1);
|
||||
}
|
||||
|
||||
.form-group textarea{
|
||||
resize:vertical;
|
||||
min-height:120px;
|
||||
}
|
||||
|
||||
.rating-input{
|
||||
display:flex;
|
||||
gap:8px;
|
||||
align-items:center;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
.star{
|
||||
font-size:34px;
|
||||
color:#d1d5db;
|
||||
cursor:pointer;
|
||||
transition:all 0.2s ease, text-shadow 0.2s ease;
|
||||
user-select:none;
|
||||
padding:2px;
|
||||
}
|
||||
|
||||
.star:hover,
|
||||
.star.active{
|
||||
color:var(--warning);
|
||||
transform:scale(1.1);
|
||||
text-shadow:0 4px 10px rgba(245,158,11,.35);
|
||||
}
|
||||
|
||||
/* ===== REVIEWS GRID ===== */
|
||||
.reviews-section{
|
||||
margin-bottom:40px;
|
||||
}
|
||||
|
||||
.section-header{
|
||||
display:flex;
|
||||
justify-content:space-between;
|
||||
align-items:center;
|
||||
margin-bottom:24px;
|
||||
flex-wrap:wrap;
|
||||
gap:16px;
|
||||
}
|
||||
|
||||
.section-title{
|
||||
font-family:var(--ff-heading);
|
||||
font-size:24px;
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
}
|
||||
|
||||
.filter-buttons{
|
||||
display:flex;
|
||||
gap:8px;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
.filter-btn{
|
||||
padding:8px 16px;
|
||||
border:2px solid var(--border);
|
||||
background:white;
|
||||
border-radius:20px;
|
||||
font-size:14px;
|
||||
font-weight:600;
|
||||
color:var(--text-muted);
|
||||
cursor:pointer;
|
||||
transition:all 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active{
|
||||
border-color:var(--primary);
|
||||
background:var(--primary-light);
|
||||
color:var(--primary-dark);
|
||||
}
|
||||
|
||||
.reviews-grid{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap:24px;
|
||||
}
|
||||
|
||||
.review-card{
|
||||
background:rgba(255,255,255,0.95);
|
||||
backdrop-filter:blur(20px);
|
||||
border-radius:20px;
|
||||
padding:24px;
|
||||
box-shadow:0 8px 24px rgba(17,77,58,0.08);
|
||||
border:1px solid rgba(111,207,151,0.1);
|
||||
transition:all 0.3s ease;
|
||||
animation:fadeUp 0.6s ease backwards;
|
||||
}
|
||||
|
||||
.review-card:nth-child(1){animation-delay:0.1s;}
|
||||
.review-card:nth-child(2){animation-delay:0.2s;}
|
||||
.review-card:nth-child(3){animation-delay:0.3s;}
|
||||
.review-card:nth-child(4){animation-delay:0.4s;}
|
||||
.review-card:nth-child(5){animation-delay:0.5s;}
|
||||
.review-card:nth-child(n+6){animation-delay:0.6s;}
|
||||
|
||||
.review-card:hover{
|
||||
transform:translateY(-4px);
|
||||
box-shadow:0 12px 32px rgba(17,77,58,0.15);
|
||||
border-color:var(--primary);
|
||||
}
|
||||
|
||||
.review-header{
|
||||
display:flex;
|
||||
align-items:flex-start;
|
||||
gap:16px;
|
||||
margin-bottom:16px;
|
||||
}
|
||||
|
||||
.avatar{
|
||||
width:56px;
|
||||
height:56px;
|
||||
border-radius:50%;
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
font-size:24px;
|
||||
flex-shrink:0;
|
||||
box-shadow:0 4px 12px rgba(111,207,151,0.3);
|
||||
}
|
||||
|
||||
.review-info{
|
||||
flex:1;
|
||||
}
|
||||
|
||||
.review-name{
|
||||
font-weight:700;
|
||||
color:var(--text-dark);
|
||||
font-size:16px;
|
||||
margin-bottom:4px;
|
||||
}
|
||||
|
||||
.review-cat{
|
||||
font-size:14px;
|
||||
color:var(--text-muted);
|
||||
font-style:italic;
|
||||
}
|
||||
|
||||
.review-rating{
|
||||
color:var(--warning);
|
||||
font-size:16px;
|
||||
margin-top:8px;
|
||||
}
|
||||
|
||||
.review-text{
|
||||
color:var(--text-dark);
|
||||
line-height:1.7;
|
||||
font-size:15px;
|
||||
margin:0;
|
||||
position:relative;
|
||||
padding-left:20px;
|
||||
}
|
||||
|
||||
.review-text::before{
|
||||
content:'"';
|
||||
position:absolute;
|
||||
left:0;
|
||||
top:-8px;
|
||||
font-size:48px;
|
||||
color:var(--primary-light);
|
||||
font-weight:700;
|
||||
line-height:1;
|
||||
}
|
||||
|
||||
.review-date{
|
||||
font-size:13px;
|
||||
color:var(--text-muted);
|
||||
margin-top:16px;
|
||||
padding-top:16px;
|
||||
border-top:1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ===== BUTTONS ===== */
|
||||
.btn{
|
||||
padding:14px 28px;
|
||||
border:none;
|
||||
border-radius:14px;
|
||||
font-weight:600;
|
||||
font-size:16px;
|
||||
cursor:pointer;
|
||||
transition:all 0.3s ease;
|
||||
text-decoration:none;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
gap:8px;
|
||||
font-family:var(--ff-body);
|
||||
}
|
||||
|
||||
.btn-primary{
|
||||
background:linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
color:white;
|
||||
box-shadow:0 4px 16px rgba(111,207,151,0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover{
|
||||
transform:translateY(-3px);
|
||||
box-shadow:0 8px 24px rgba(111,207,151,0.4);
|
||||
}
|
||||
|
||||
.btn-secondary{
|
||||
background:white;
|
||||
color:var(--primary-dark);
|
||||
border:2px solid var(--primary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover{
|
||||
background:var(--primary-light);
|
||||
transform:translateY(-2px);
|
||||
}
|
||||
|
||||
.back-btn{
|
||||
margin-bottom:30px;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
color:var(--text-muted);
|
||||
text-decoration:none;
|
||||
font-weight:600;
|
||||
transition:all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover{
|
||||
color:var(--primary-dark);
|
||||
transform:translateX(-4px);
|
||||
}
|
||||
|
||||
@keyframes fadeDown{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(-20px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUp{
|
||||
from{
|
||||
opacity:0;
|
||||
transform:translateY(30px);
|
||||
}
|
||||
to{
|
||||
opacity:1;
|
||||
transform:translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media(max-width:768px){
|
||||
.stats-card{
|
||||
grid-template-columns:repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.reviews-grid{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
|
||||
.form-card{
|
||||
padding:28px 24px;
|
||||
}
|
||||
|
||||
.section-header{
|
||||
flex-direction:column;
|
||||
align-items:flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width:576px) and (orientation:portrait){
|
||||
.container{padding:14px;}
|
||||
.header h1{font-size:1.35rem;}
|
||||
.header p{font-size:14px;}
|
||||
.logo-icon{width:38px;height:38px;}
|
||||
.stats-card{grid-template-columns:1fr 1fr;gap:10px;}
|
||||
.stat-value{font-size:1.05rem;}
|
||||
.form-card{padding:18px 14px;border-radius:16px;}
|
||||
.btn{font-size:14px;padding:10px 12px;}
|
||||
.review-card{padding:14px;}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="/" class="back-btn">
|
||||
← Kembali ke Beranda
|
||||
</a>
|
||||
|
||||
<div class="header">
|
||||
<a href="/" class="logo-link">
|
||||
<div class="logo-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<circle cx="6" cy="8" r="2.2"></circle>
|
||||
<circle cx="10.8" cy="5.6" r="2.1"></circle>
|
||||
<circle cx="15.8" cy="8" r="2.2"></circle>
|
||||
<path d="M12 10.6c-3.4 0-5.9 2.4-5.9 4.9 0 2.2 1.8 3.9 4 3.9 1.4 0 1.9-.7 2-.7s.6.7 2 .7c2.2 0 4-1.7 4-3.9 0-2.6-2.6-4.9-6.1-4.9z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="logo-text">PawMedic</div>
|
||||
</a>
|
||||
<h1>Ulasan Pengguna</h1>
|
||||
<p>Bagikan pengalaman Anda menggunakan PawMedic</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ $total }}</div>
|
||||
<div class="stat-label">Total Ulasan</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ number_format($avg, 1) }}</div>
|
||||
<div class="stat-label">Rating Rata-rata</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ $fivePercent }}%</div>
|
||||
<div class="stat-label">5 Bintang</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Submit Ulasan -->
|
||||
<div class="form-card">
|
||||
<div class="form-title">
|
||||
<span><i class="bi bi-pencil-square"></i></span>
|
||||
<span>Tulis Ulasan Anda</span>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('ulasan.store') }}">
|
||||
@csrf
|
||||
<div class="form-group">
|
||||
<label>Nama Anda</label>
|
||||
<input type="text" name="nama" placeholder="Masukkan nama Anda" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Nama Kucing</label>
|
||||
<input type="text" name="nama_kucing" placeholder="Nama kucing Anda">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rating</label>
|
||||
<div id="rating-stars" style="font-size: 28px; cursor: pointer;">
|
||||
<span data-value="1">☆</span>
|
||||
<span data-value="2">☆</span>
|
||||
<span data-value="3">☆</span>
|
||||
<span data-value="4">☆</span>
|
||||
<span data-value="5">☆</span>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="rating" id="rating-value">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ulasan</label>
|
||||
<textarea name="komentar" placeholder="Bagikan pengalaman Anda menggunakan PawMedic..." required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span>Kirim Ulasan</span>
|
||||
<span>→</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Reviews List -->
|
||||
<div class="reviews-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">Ulasan Pengguna</div>
|
||||
<div class="filter-buttons">
|
||||
<button class="filter-btn active" data-filter-rating="all">Semua Bintang</button>
|
||||
<button class="filter-btn" data-filter-rating="5">5 Bintang</button>
|
||||
<button class="filter-btn" data-filter-rating="4">4 Bintang</button>
|
||||
<button class="filter-btn" data-filter-rating="3">3 Bintang</button>
|
||||
<button class="filter-btn" data-filter-rating="2">2 Bintang</button>
|
||||
<button class="filter-btn" data-filter-rating="1">1 Bintang</button>
|
||||
@auth
|
||||
@if(Auth::user()->email === 'admin@pawmedic.app')
|
||||
<button class="filter-btn" data-filter-visibility="all">Semua Status</button>
|
||||
<button class="filter-btn" data-filter-visibility="visible">Tampil</button>
|
||||
<button class="filter-btn" data-filter-visibility="hidden">Hidden</button>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
<div class="reviews-grid">
|
||||
|
||||
@foreach($ulasan as $review)
|
||||
<div class="review-card" data-rating="{{ $review->rating }}" data-hidden="{{ ($review->is_hidden ?? false) ? '1' : '0' }}">
|
||||
<div class="review-header">
|
||||
<div class="avatar">{{ substr($review->nama,0,1) }}</div>
|
||||
<div class="review-info">
|
||||
<div class="review-name">{{ $review->nama }}</div>
|
||||
<div class="review-cat">
|
||||
{{ $review->hasil_diagnosis ?? 'Pengguna' }}
|
||||
</div>
|
||||
<div class="review-rating">
|
||||
{{ str_repeat('★', $review->rating) }}
|
||||
@if(($review->is_hidden ?? false))
|
||||
<span style="margin-left:8px;font-size:12px;color:#b45309;background:#fef3c7;padding:2px 8px;border-radius:999px;">Hidden</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ KOMENTAR UNTUK SEMUA -->
|
||||
<p class="review-text">{{ $review->komentar }}</p>
|
||||
|
||||
<!-- ✅ HANYA ADMIN -->
|
||||
@auth
|
||||
@if(Auth::user()->email === 'admin@pawmedic.app')
|
||||
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<form action="{{ route('ulasan.toggleHide', $review->id) }}" method="POST" style="display:inline;">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
style="background:#f59e0b; color:white; border:none; padding:6px 12px; border-radius:6px;">
|
||||
@if(($review->is_hidden ?? false))
|
||||
<i class="bi bi-eye"></i> Tampilkan
|
||||
@else
|
||||
<i class="bi bi-eye-slash"></i> Hide
|
||||
@endif
|
||||
</button>
|
||||
</form>
|
||||
<form action="{{ route('ulasan.delete', $review->id) }}"
|
||||
method="POST"
|
||||
class="delete-review-form"
|
||||
data-name="{{ $review->nama }}"
|
||||
style="display:inline;">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit"
|
||||
style="background:#ef4444; color:white; border:none; padding:6px 12px; border-radius:6px;">
|
||||
<i class="bi bi-trash"></i> Hapus
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
@endauth
|
||||
|
||||
<div class="review-date">
|
||||
{{ $review->created_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endforeach
|
||||
<!-- Reviews will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('components.toast')
|
||||
@include('components.scroll-top')
|
||||
<div class="modal fade" id="deleteReviewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="border-radius:14px;">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Konfirmasi Hapus Ulasan</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">Yakin hapus ulasan ini?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" style="width:auto;">Batal</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteReview" style="width:auto;">Ya, Hapus</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const buttons = document.querySelectorAll('.filter-btn');
|
||||
const cards = document.querySelectorAll('.review-card');
|
||||
let activeRatingFilter = 'all';
|
||||
let activeVisibilityFilter = 'all';
|
||||
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const ratingFilter = btn.getAttribute('data-filter-rating');
|
||||
const visibilityFilter = btn.getAttribute('data-filter-visibility');
|
||||
if (ratingFilter !== null) {
|
||||
activeRatingFilter = ratingFilter;
|
||||
document.querySelectorAll('[data-filter-rating]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
}
|
||||
if (visibilityFilter !== null) {
|
||||
activeVisibilityFilter = visibilityFilter;
|
||||
document.querySelectorAll('[data-filter-visibility]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
}
|
||||
applyReviewFilters();
|
||||
});
|
||||
});
|
||||
function applyReviewFilters() {
|
||||
cards.forEach(card => {
|
||||
const rating = card.getAttribute('data-rating');
|
||||
const isHidden = card.getAttribute('data-hidden') === '1';
|
||||
const passRating = activeRatingFilter === 'all' || rating === activeRatingFilter;
|
||||
let passVisibility = true;
|
||||
if (activeVisibilityFilter === 'visible') passVisibility = !isHidden;
|
||||
if (activeVisibilityFilter === 'hidden') passVisibility = isHidden;
|
||||
card.style.display = (passRating && passVisibility) ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
applyReviewFilters();
|
||||
</script>
|
||||
<script>
|
||||
let pendingDeleteForm = null;
|
||||
const deleteModalEl = document.getElementById('deleteReviewModal');
|
||||
const deleteModal = deleteModalEl && window.bootstrap ? new bootstrap.Modal(deleteModalEl) : null;
|
||||
document.querySelectorAll('.delete-review-form').forEach((form) => {
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
pendingDeleteForm = form;
|
||||
if (deleteModal) {
|
||||
deleteModal.show();
|
||||
} else if (confirm('Yakin hapus ulasan ini?')) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
document.getElementById('confirmDeleteReview')?.addEventListener('click', () => {
|
||||
if (pendingDeleteForm) pendingDeleteForm.submit();
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
const stars = document.querySelectorAll('#rating-stars span');
|
||||
const ratingInput = document.getElementById('rating-value');
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
|
||||
// ✅ CLICK (INI YANG KAMU KURANG)
|
||||
star.addEventListener('click', () => {
|
||||
const value = star.getAttribute('data-value');
|
||||
ratingInput.value = value;
|
||||
|
||||
stars.forEach((s, i) => {
|
||||
s.textContent = i < value ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
// hover (punya kamu)
|
||||
star.addEventListener('mouseover', () => {
|
||||
stars.forEach((s, i) => {
|
||||
s.textContent = i <= index ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
// keluar hover
|
||||
star.addEventListener('mouseout', () => {
|
||||
let value = ratingInput.value;
|
||||
stars.forEach((s, i) => {
|
||||
s.textContent = i < value ? '★' : '☆';
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\DiagnosisController;
|
||||
use App\Http\Controllers\AdminController;
|
||||
use App\Http\Controllers\GejalaController;
|
||||
use App\Http\Controllers\UlasanController;
|
||||
use App\Http\Controllers\LandingController;
|
||||
|
||||
Route::get('/admin/sort-diagnosis', [AdminController::class, 'sortDiagnosis']);
|
||||
Route::get('/admin/export-diagnosis', [AdminController::class, 'exportDiagnosisExcel'])
|
||||
->name('admin.export.diagnosis')
|
||||
->middleware('auth');
|
||||
Route::get('/admin/disease-settings', [AdminController::class, 'diseaseSettings'])
|
||||
->name('admin.disease.settings')
|
||||
->middleware('auth');
|
||||
Route::post('/admin/disease-settings', [AdminController::class, 'saveDiseaseSettings'])
|
||||
->name('admin.disease.settings.save')
|
||||
->middleware('auth');
|
||||
Route::get('/admin/faq-settings', [AdminController::class, 'faqSettings'])
|
||||
->name('admin.faq.settings')
|
||||
->middleware('auth');
|
||||
Route::post('/admin/faq-settings', [AdminController::class, 'saveFaqSettings'])
|
||||
->name('admin.faq.settings.save')
|
||||
->middleware('auth');
|
||||
Route::get('/admin/training-settings', [AdminController::class, 'trainingDataSettings'])
|
||||
->name('admin.training.settings')
|
||||
->middleware('auth');
|
||||
Route::post('/admin/training-settings', [AdminController::class, 'saveTrainingDataSettings'])
|
||||
->name('admin.training.settings.save')
|
||||
->middleware('auth');
|
||||
Route::get('/admin/training-settings/{id}/symptoms', [AdminController::class, 'editTrainingSymptoms'])
|
||||
->name('admin.training.symptoms.edit')
|
||||
->middleware('auth');
|
||||
Route::post('/admin/training-settings/{id}/symptoms', [AdminController::class, 'saveTrainingSymptoms'])
|
||||
->name('admin.training.symptoms.save')
|
||||
->middleware('auth');
|
||||
Route::get('/admin/training-template', [AdminController::class, 'downloadTrainingTemplate'])
|
||||
->name('admin.training.template')
|
||||
->middleware('auth');
|
||||
Route::get('/admin/training-export', [AdminController::class, 'downloadTrainingData'])
|
||||
->name('admin.training.export')
|
||||
->middleware('auth');
|
||||
|
||||
Route::delete('/ulasan/{id}', [UlasanController::class, 'destroy'])->name('ulasan.delete');
|
||||
|
||||
Route::get('/ulasan', [UlasanController::class, 'index'])->name('ulasan');
|
||||
Route::post('/ulasan', [UlasanController::class, 'store'])->name('ulasan.store');
|
||||
|
||||
Route::get('/gejala', [GejalaController::class, 'index'])->name('gejala');
|
||||
|
||||
Route::delete('/admin/ulasan/{id}', [UlasanController::class, 'destroy'])
|
||||
->name('ulasan.delete');
|
||||
Route::post('/admin/ulasan/{id}/toggle-hide', [UlasanController::class, 'toggleHide'])
|
||||
->name('ulasan.toggleHide')
|
||||
->middleware('auth');
|
||||
|
||||
Route::get('/', [LandingController::class, 'index'])->name('landing');
|
||||
|
||||
Route::get('/biodata', function () {
|
||||
return view('biodata');
|
||||
})->name('biodata');
|
||||
|
||||
Route::get('/loading', function () {
|
||||
return view('loading');
|
||||
})->name('loading');
|
||||
|
||||
Route::post('/diagnosis/proses', [DiagnosisController::class, 'prosesDiagnosis'])->name('diagnosis.proses');
|
||||
|
||||
Route::get('/hasil-diagnosis', [DiagnosisController::class, 'hasil'])->name('hasil-diagnosis');
|
||||
Route::get('/hasil-diagnosis/pdf', [DiagnosisController::class, 'downloadPdf'])->name('hasil-diagnosis.pdf');
|
||||
|
||||
Route::get('/faq', [AdminController::class, 'faqPage'])->name('faq');
|
||||
|
||||
// Admin Routes
|
||||
Route::get('/admin/login', [AdminController::class, 'login'])->name('admin.login');
|
||||
Route::post('/admin/login', [AdminController::class, 'authenticate'])->name('admin.authenticate');
|
||||
Route::get('/admin/lupa-password', [AdminController::class, 'forgotPasswordPage'])->name('admin.forgot.password');
|
||||
Route::post('/admin/lupa-password/send-otp', [AdminController::class, 'sendForgotOtp'])->name('admin.forgot.sendOtp');
|
||||
Route::post('/admin/lupa-password/verify-otp', [AdminController::class, 'verifyForgotOtp'])->name('admin.forgot.verifyOtp');
|
||||
Route::get('/admin/lupa-password/ganti-password', [AdminController::class, 'resetPasswordPage'])->name('admin.forgot.reset.form');
|
||||
Route::post('/admin/lupa-password/ganti-password', [AdminController::class, 'resetPasswordSubmit'])->name('admin.forgot.reset.submit');
|
||||
Route::post('/admin/recover/send-otp', [AdminController::class, 'sendRecoveryOtp'])->name('admin.recover.sendOtp');
|
||||
Route::post('/admin/recover-password', [AdminController::class, 'recoverPassword'])->name('admin.recover.password');
|
||||
Route::post('/admin/logout', [AdminController::class, 'logout'])->name('admin.logout');
|
||||
Route::get('/admin/dashboard', [AdminController::class, 'dashboard'])->name('admin.dashboard')->middleware('auth');
|
||||
Route::post('/biodata/simpan', [DiagnosisController::class, 'simpanBiodata'])->name('biodata.simpan');
|
||||
Route::get('/admin/statistik', [AdminController::class, 'statistik'])->name('admin.statistik');
|
||||
|
||||
Route::get('/loading-diagnosis', function () {
|
||||
return view('loading');
|
||||
})->name('diagnosis.loading');
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
*
|
||||
!private/
|
||||
!public/
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
*
|
||||
!.gitignore
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue