Compare commits
12 Commits
0921901aed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| edd2f626be | |||
| 507fc6ccdf | |||
| d16f454083 | |||
| 17187459be | |||
| 74a09ee9b2 | |||
| c89489b125 | |||
| bcb407592a | |||
| 6133073005 | |||
| e83502af40 | |||
| 9301d81259 | |||
| 12b2d8dc98 | |||
| e9dccd8fba |
@@ -1,3 +1,4 @@
|
||||
<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">
|
||||
@@ -57,3 +58,7 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
|
||||
# ilycats
|
||||
management cattery
|
||||
e9dccd8fba790927e3c01171d6a622eb88c409f8
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Helpers\WhatsappHelper;
|
||||
use App\Models\{Cat, Food, Matting, Medicine, Role, Setting, Supply, User};
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SendOperationalNotifications extends Command
|
||||
{
|
||||
protected $signature = 'ilycats:notify {--dry-run : Tampilkan kandidat tanpa mengirim WhatsApp}';
|
||||
protected $description = 'Mengirim notifikasi WhatsApp HPL, kedaluwarsa inventori, dan berat badan yang belum diperbarui.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ((Setting::where('key', 'notif_wa_status')->value('value') ?? 'inactive') !== 'active') {
|
||||
$this->info('Notifikasi WhatsApp sedang nonaktif.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->sendHplNotifications();
|
||||
$this->sendInventoryExpiryNotifications();
|
||||
$this->sendWeightStaleNotifications();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function recipients(string $settingKey)
|
||||
{
|
||||
$roleId = Setting::where('key', $settingKey)->value('value');
|
||||
$query = User::query()
|
||||
->where('is_active', true)
|
||||
->whereNotNull('phone')
|
||||
->where('phone', '!=', '');
|
||||
|
||||
if ($roleId) {
|
||||
$role = Role::find($roleId);
|
||||
$query->where(function ($q) use ($roleId, $role) {
|
||||
$q->where('role_id', $roleId);
|
||||
// Tetap dukung akun admin lama yang belum mempunyai role_id.
|
||||
if ($role?->slug === 'admin') {
|
||||
$q->orWhere('role', 'admin');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
$query->where('role', 'admin');
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function sendHplNotifications(): void
|
||||
{
|
||||
if ((Setting::where('key', 'notif_hpl_wa')->value('value') ?? 'active') !== 'active') return;
|
||||
$today = now()->startOfDay();
|
||||
$recipients = $this->recipients('notif_hpl_role_id');
|
||||
|
||||
Matting::with(['dam','sire'])->whereIn('status', ['proses','hamil'])->whereNotNull('tgl_kawin_awal')->get()->each(function ($m) use ($today, $recipients) {
|
||||
$hpl = Carbon::parse($m->tgl_kawin_awal)->startOfDay()->addDays(63);
|
||||
$days = (int) $today->diffInDays($hpl, false);
|
||||
if ($days < 0 || $days > 7) return;
|
||||
$label = $days === 0 ? 'HPL hari ini' : "H-{$days}";
|
||||
$message = "🐾 ILYCats - Pengingat HPL\n\nDam: " . ($m->dam?->nama_kucing ?? '-') . "\nSire: " . ($m->sire?->nama_kucing ?? '-') . "\nHPL: " . $hpl->format('d-m-Y') . "\nStatus: {$label}\n\nMohon pantau kondisi induk dan siapkan kebutuhan persalinan.";
|
||||
foreach ($recipients as $user) {
|
||||
$this->sendOnce("hpl:{$m->id}:{$today->toDateString()}:{$user->id}", 'hpl', $user, $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function sendInventoryExpiryNotifications(): void
|
||||
{
|
||||
if ((Setting::where('key', 'notif_inventory_expiry_wa')->value('value') ?? 'active') !== 'active') return;
|
||||
$today = now()->startOfDay();
|
||||
$limit = $today->copy()->addDays(14);
|
||||
$recipients = $this->recipients('notif_inventory_role_id');
|
||||
$types = [
|
||||
'obat' => [Medicine::class, 'nama_obat'],
|
||||
'makanan' => [Food::class, 'nama_makanan'],
|
||||
'perlengkapan' => [Supply::class, 'nama_perlengkapan'],
|
||||
];
|
||||
|
||||
foreach ($types as $type => [$model, $nameField]) {
|
||||
$model::whereNotNull('expired_at')->whereDate('expired_at', '>=', $today)->whereDate('expired_at', '<=', $limit)->get()->each(function ($item) use ($type, $nameField, $today, $recipients) {
|
||||
$expiry = Carbon::parse($item->expired_at)->startOfDay();
|
||||
$days = (int) $today->diffInDays($expiry, false);
|
||||
$message = "⚠️ ILYCats - Kedaluwarsa Inventori\n\nJenis: " . ucfirst($type) . "\nItem: {$item->{$nameField}}\nKedaluwarsa: " . $expiry->format('d-m-Y') . "\nSisa: {$days} hari\nStok: {$item->qty} {$item->unit}\n\nMohon cek stok dan rencanakan penggantian.";
|
||||
foreach ($recipients as $user) {
|
||||
$this->sendOnce("expiry:{$type}:{$item->id}:{$expiry->toDateString()}:{$user->id}", 'inventory_expiry', $user, $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function sendWeightStaleNotifications(): void
|
||||
{
|
||||
if ((Setting::where('key', 'notif_weight_stale_wa')->value('value') ?? 'active') !== 'active') return;
|
||||
$recipients = $this->recipients('notif_weight_role_id');
|
||||
$threshold = now()->subDays(7)->startOfDay();
|
||||
|
||||
Cat::activeInCattery()->withMax('weightHistories as last_weight_at', 'tanggal_timbang')->get()->each(function ($cat) use ($threshold, $recipients) {
|
||||
$last = $cat->last_weight_at ? Carbon::parse($cat->last_weight_at)->startOfDay() : null;
|
||||
if ($last && $last->gte($threshold)) return;
|
||||
$days = $last ? $last->diffInDays(now()->startOfDay()) : null;
|
||||
$lastKey = $last?->toDateString() ?? 'none';
|
||||
$message = "⚖️ ILYCats - Berat Belum Diperbarui\n\nKucing: {$cat->nama_kucing}\nBerat terakhir: " . ($cat->berat_badan ? $cat->berat_badan . ' kg' : '-') . "\nTerakhir ditimbang: " . ($last ? $last->format('d-m-Y') . " ({$days} hari lalu)" : 'belum ada pencatatan') . "\n\nMohon lakukan penimbangan terbaru.";
|
||||
foreach ($recipients as $user) {
|
||||
$this->sendOnce("weight:{$cat->id}:{$lastKey}:{$user->id}", 'weight_stale', $user, $message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function sendOnce(string $key, string $type, User $user, string $message): void
|
||||
{
|
||||
if (DB::table('notification_dispatches')->where('notification_key', $key)->exists()) return;
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("[DRY] {$user->name}: {$key}");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
WhatsappHelper::send($user->phone, $message);
|
||||
DB::table('notification_dispatches')->insert([
|
||||
'notification_key' => $key,
|
||||
'type' => $type,
|
||||
'user_id' => $user->id,
|
||||
'channel' => 'whatsapp',
|
||||
'sent_at' => now(),
|
||||
'message' => $message,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("ILYCats WA notification failed [{$key}]: {$e->getMessage()}");
|
||||
$this->error("Gagal {$key}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Cat;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
|
||||
class CatWeightTemplateExport implements FromCollection, WithHeadings, WithMapping, WithStyles, ShouldAutoSize, WithColumnFormatting
|
||||
{
|
||||
/**
|
||||
* Ambil data kucing yang aktif
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
// Tetap mengambil semua kucing yang tidak dihapus
|
||||
return Cat::whereNull('deleted_at')->get(['id', 'nama_kucing']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header Tabel
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
"Kode Kucing",
|
||||
"Nama Kucing",
|
||||
"Berat Badan (Kg)",
|
||||
"Tanggal & Waktu" // Ubah header agar staf tahu ini mendukung jam
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping baris data
|
||||
*/
|
||||
public function map($cat): array
|
||||
{
|
||||
return [
|
||||
$cat->id,
|
||||
$cat->nama_kucing,
|
||||
'', // Kosongkan untuk diisi staf
|
||||
|
||||
// Konversi tanggal PHP murni ke Serial Number Date milik Excel
|
||||
// Ini mencegah Excel mengubah tanggal menjadi format teks yang berantakan
|
||||
Date::dateTimeToExcel(now())
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Kolom secara spesifik
|
||||
*/
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return [
|
||||
// Memaksa kolom D (Tanggal & Waktu) menggunakan format kalender bawaan Excel
|
||||
// Formatnya akan menjadi YYYY-MM-DD HH:MM:SS saat staf membuka filenya
|
||||
'D' => NumberFormat::FORMAT_DATE_DATETIME,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Styling Header agar lebih cantik
|
||||
*/
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4F81BD']
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
|
||||
($item->sire?->nama_kucing ?? '-') . ' x ' . ($item->dam?->nama_kucing ?? '-'),
|
||||
($item->dam?->pedigree ?? '-'),
|
||||
'S: ' . ($item->ems_bapak ?? '-') . ' / D: ' . ($item->ems_ibu ?? '-'),
|
||||
'S: ' . ($item->no_pedigree_bapak ?? 'Non-Ped') . ' / D: ' . ($item->no_pedigree_ibu ?? 'Non-Ped'),
|
||||
'S: ' . ($item->no_pedigree_bapak ?? 'Non-pedigree') . ' / D: ' . ($item->no_pedigree_ibu ?? 'Non-pedigree'),
|
||||
'S: ' . ($item->no_microchip_bapak ?? '-') . ' / D: ' . ($item->no_microchip_ibu ?? '-'),
|
||||
$tglKawin,
|
||||
$eddDate,
|
||||
@@ -66,7 +66,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
|
||||
return [
|
||||
AfterSheet::class => function(AfterSheet $event) {
|
||||
$sheet = $event->sheet;
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC BREEDING REPORT');
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - LAPORAN PEMBIAKAN');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Treatment;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class TreatmentExport implements FromQuery, WithHeadings, WithMapping, WithStyles, ShouldAutoSize
|
||||
{
|
||||
protected $request;
|
||||
|
||||
public function __construct($request)
|
||||
{
|
||||
// Kita bisa menerima objek Request atau Query Builder hasil clone dari Controller
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query data dengan filter yang sinkron dengan tampilan UI
|
||||
*/
|
||||
public function query()
|
||||
{
|
||||
$query = Treatment::query()->with(['cat', 'user', 'details.medicine']);
|
||||
|
||||
// Filter 1: Pencarian Nama Kucing
|
||||
if ($this->request->filled('search')) {
|
||||
$query->whereHas('cat', function($q) {
|
||||
$q->where('nama_kucing', 'like', '%' . $this->request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Filter 2: Nama PIC (Staf)
|
||||
if ($this->request->filled('pic_id')) {
|
||||
$query->where('user_id', $this->request->pic_id);
|
||||
}
|
||||
|
||||
// Filter 3: Rentang Tanggal
|
||||
if ($this->request->filled('start_date') && $this->request->filled('end_date')) {
|
||||
$query->whereBetween('tanggal', [$this->request->start_date, $this->request->end_date]);
|
||||
}
|
||||
|
||||
// Filter 4: Kondisi (Healthy / Under Treatment)
|
||||
if ($this->request->filled('status')) {
|
||||
$query->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', $this->request->status);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->latest('tanggal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Header Tabel Excel
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
"TANGGAL",
|
||||
"NAMA KUCING",
|
||||
"STATUS KESEHATAN",
|
||||
"KELUHAN",
|
||||
"DIAGNOSA & TINDAKAN",
|
||||
"DAFTAR OBAT (QTY)",
|
||||
"DOSIS & KET",
|
||||
"PIC / STAF"
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformasi Data per Baris
|
||||
*/
|
||||
public function map($t): array
|
||||
{
|
||||
// Menggabungkan daftar obat menjadi satu string jika ada multi-medicine
|
||||
$medicineList = $t->details->map(function($d) {
|
||||
$namaObat = $d->medicine->nama_obat ?? 'Obat Terhapus';
|
||||
return "{$namaObat} ({$d->qty_used})";
|
||||
})->implode(", ");
|
||||
|
||||
// Jika data detail kosong, gunakan data fallback (legacy support)
|
||||
if ($medicineList === "") {
|
||||
$medicineList = $t->nama_obat ? "{$t->nama_obat} ({$t->qty_used})" : "-";
|
||||
}
|
||||
|
||||
// Mengambil deskripsi dosis/keterangan dari detail pertama atau header
|
||||
$dosisKet = $t->details->map(function($d) {
|
||||
return "{$d->dosis} [{$d->keterangan}]";
|
||||
})->implode(" | ");
|
||||
|
||||
return [
|
||||
$t->tanggal->format('d/m/Y'),
|
||||
$t->cat->nama_kucing ?? 'N/A',
|
||||
($t->cat->treatment_status == 1) ? 'UNDER TREATMENT' : 'HEALTHY',
|
||||
$t->keluhan,
|
||||
$t->diagnosa,
|
||||
$medicineList,
|
||||
$dosisKet ?: $t->dosis,
|
||||
$t->user->name ?? 'System'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Memberikan Styling pada file Excel
|
||||
*/
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
// Style Header (Baris 1)
|
||||
1 => [
|
||||
'font' => [
|
||||
'bold' => true,
|
||||
'color' => ['rgb' => 'FFFFFF'],
|
||||
'size' => 11
|
||||
],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4F46E5'] // Indigo-600
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
|
||||
],
|
||||
],
|
||||
|
||||
// Memberikan border ke seluruh data yang ada
|
||||
'A1:H' . ($sheet->getHighestRow()) => [
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN,
|
||||
'color' => ['rgb' => 'E2E8F0'], // Slate-200
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class UnifiedReportExport implements FromCollection, WithHeadings, WithMapping,
|
||||
$sheet = $event->sheet;
|
||||
|
||||
// Menulis Metadata
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC REPORT');
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - LAPORAN TERPADU');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
|
||||
@@ -2,19 +2,75 @@
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class WhatsappHelper
|
||||
{
|
||||
public static function send($target, $message)
|
||||
public static function send(string $target, string $message): bool
|
||||
{
|
||||
$token = "YOUR_API_TOKEN";
|
||||
$curl = curl_init();
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => 'https://api.fonnte.com/send',
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => ['target' => $target, 'message' => $message],
|
||||
CURLOPT_HTTPHEADER => ["Authorization: $token"],
|
||||
$target = self::normalizePhone($target);
|
||||
$provider = strtolower((string) config('services.whatsapp.provider', 'fonnte'));
|
||||
|
||||
if ($provider === 'waha') {
|
||||
$baseUrl = rtrim((string) config('services.whatsapp.waha_url'), '/');
|
||||
if ($baseUrl === '') {
|
||||
throw new RuntimeException('WAHA_URL belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
if ($key = config('services.whatsapp.waha_api_key')) {
|
||||
$headers['X-Api-Key'] = $key;
|
||||
}
|
||||
|
||||
$response = Http::timeout(8)->withHeaders($headers)->post($baseUrl . '/api/sendText', [
|
||||
'session' => config('services.whatsapp.waha_session', 'default'),
|
||||
'chatId' => $target . '@c.us',
|
||||
'text' => $message,
|
||||
]);
|
||||
curl_exec($curl);
|
||||
curl_close($curl);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new RuntimeException('WAHA gagal mengirim pesan: HTTP ' . $response->status());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
$token = (string) config('services.whatsapp.fonnte_token');
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('FONNTE_TOKEN belum dikonfigurasi.');
|
||||
}
|
||||
|
||||
$response = Http::asForm()->timeout(8)->withHeaders([
|
||||
'Authorization' => $token,
|
||||
])->post('https://api.fonnte.com/send', [
|
||||
'target' => $target,
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
if (!$response->successful()) {
|
||||
throw new RuntimeException('Fonnte gagal mengirim pesan: HTTP ' . $response->status());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function configured(): bool
|
||||
{
|
||||
$provider = strtolower((string) config('services.whatsapp.provider', 'fonnte'));
|
||||
return $provider === 'waha'
|
||||
? (string) config('services.whatsapp.waha_url') !== ''
|
||||
: (string) config('services.whatsapp.fonnte_token') !== '';
|
||||
}
|
||||
|
||||
public static function normalizePhone(string $phone): string
|
||||
{
|
||||
$phone = preg_replace('/\D+/', '', $phone) ?: '';
|
||||
if (str_starts_with($phone, '0')) {
|
||||
$phone = '62' . substr($phone, 1);
|
||||
}
|
||||
if (!str_starts_with($phone, '62')) {
|
||||
$phone = '62' . ltrim($phone, '0');
|
||||
}
|
||||
return $phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email', 'password' => 'required|string', 'device_name' => 'nullable|string|max:100']);
|
||||
$user = User::where('email', strtolower($data['email']))->first();
|
||||
if (!$user || !Hash::check($data['password'], $user->password)) {
|
||||
return response()->json(['message' => 'Email atau kata sandi tidak sesuai.'], 422);
|
||||
}
|
||||
$plain = Str::random(80);
|
||||
ApiToken::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $data['device_name'] ?? 'Android',
|
||||
'token_hash' => hash('sha256', $plain),
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
return response()->json(['token' => $plain, 'token_type' => 'Bearer', 'user' => $this->userPayload($user)]);
|
||||
}
|
||||
public function me(Request $request) { return response()->json(['user' => $this->userPayload($request->user())]); }
|
||||
public function logout(Request $request) { $request->attributes->get('api_token')?->delete(); return response()->json(['message' => 'Sesi berhasil diakhiri.']); }
|
||||
private function userPayload(User $user): array { return ['id'=>$user->id,'name'=>$user->name,'email'=>$user->email,'role'=>$user->roleModel?->name ?? $user->getRawOriginal('role')]; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Matting;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BreedingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$items=Matting::with(['sire:id,nama_kucing,foto','dam:id,nama_kucing,foto','kittens.cat:id,nama_kucing,foto'])
|
||||
->when($request->status,fn($q,$v)=>$q->where('status',$v))->latest('tgl_kawin_awal')->paginate(15);
|
||||
$items->getCollection()->transform(fn($m)=>$this->payload($m));
|
||||
return response()->json($items);
|
||||
}
|
||||
public function show(Matting $matting) { Gate::authorize('check-access', ['matting', 'read']); $matting->load(['sire','dam','kittens.cat.registrations.association']); return response()->json(['data'=>$this->payload($matting,true)]); }
|
||||
private function payload(Matting $m,bool $detail=false): array
|
||||
{
|
||||
$edd=$m->tgl_kawin_awal ? Carbon::parse($m->tgl_kawin_awal)->addDays(63) : null;
|
||||
return ['id'=>$m->id,'sire'=>['id'=>$m->sire_id,'nama'=>$m->sire?->nama_kucing],'dam'=>['id'=>$m->dam_id,'nama'=>$m->dam?->nama_kucing],'tanggal_kawin_awal'=>$m->tgl_kawin_awal?->format('Y-m-d'),'tanggal_kawin_akhir'=>$m->tgl_kawin_akhir?->format('Y-m-d'),'perkiraan_lahir'=>$edd?->format('Y-m-d'),'status'=>$m->status,'jumlah_anak_hidup'=>$m->jumlah_anak_hidup,'anakan'=>$m->kittens->map(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'foto'=>$k->cat?->foto_url])->values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class CatController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cats = Cat::with(['registrations.association'])
|
||||
->when($request->search, fn($q,$s)=>$q->where(fn($x)=>$x->where('nama_kucing','like',"%$s%")->orWhere('microchip','like',"%$s%")->orWhere('kode_ems','like',"%$s%")))
|
||||
->when($request->gender, fn($q,$v)=>$q->where('jenis_kelamin',$v))
|
||||
->orderBy('nama_kucing')->paginate(min((int)$request->get('per_page',20),50));
|
||||
$cats->getCollection()->transform(fn($c)=>$this->payload($c));
|
||||
return response()->json($cats);
|
||||
}
|
||||
public function show(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cat->load(['sire:id,nama_kucing','dam:id,nama_kucing','registrations.association','registrations.documents','weightHistories'=>fn($q)=>$q->latest('tanggal_timbang')->take(30)]);
|
||||
return response()->json(['data'=>$this->payload($cat,true)]);
|
||||
}
|
||||
private function payload(Cat $c, bool $detail=false): array
|
||||
{
|
||||
$base=['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'ras'=>$c->ras,'jenis_kelamin'=>$c->jenis_kelamin,'tanggal_lahir'=>$c->tanggal_lahir?->format('Y-m-d'),'usia'=>$c->age,'kode_ems'=>$c->kode_ems,'microchip'=>$c->microchip,'status'=>$c->status,'berat_terakhir'=>(float)($c->berat_badan ?? $c->berat_terakhir ?? 0),'lokasi'=>$c->lokasi_kandang,'registrasi'=>$c->registrations->map(fn($r)=>['id'=>$r->id,'asosiasi'=>$r->association?->code,'nama_asosiasi'=>$r->association?->name,'nomor_registrasi'=>$r->registration_number,'nomor_pedigree'=>$r->pedigree_number,'nomor_sertifikat'=>$r->certificate_number,'title'=>$r->title,'utama'=>$r->is_primary])->values()];
|
||||
if($detail){$base['induk_jantan']=$c->sire?->nama_kucing;$base['induk_betina']=$c->dam?->nama_kucing;$base['riwayat_berat']=$c->weightHistories->map(fn($w)=>['id'=>$w->id,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang])->values();}
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use App\Models\Kitten;
|
||||
use App\Models\Matting;
|
||||
use App\Models\WeightHistory;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$now = now();
|
||||
$upcoming = Matting::with(['sire:id,nama_kucing','dam:id,nama_kucing'])
|
||||
->where('status', 'proses')->whereNotNull('tgl_kawin_awal')->latest('tgl_kawin_awal')->take(20)->get()
|
||||
->map(function ($m) use ($now) {
|
||||
$edd = Carbon::parse($m->tgl_kawin_awal)->addDays(63);
|
||||
return ['id'=>$m->id,'sire'=>$m->sire?->nama_kucing,'dam'=>$m->dam?->nama_kucing,'tanggal_kawin'=>$m->tgl_kawin_awal?->format('Y-m-d'),'perkiraan_lahir'=>$edd->format('Y-m-d'),'sisa_hari'=>$now->startOfDay()->diffInDays($edd->copy()->startOfDay(), false)];
|
||||
})->filter(fn($v) => $v['sisa_hari'] >= -3 && $v['sisa_hari'] <= 30)->values();
|
||||
|
||||
return response()->json([
|
||||
'ringkasan' => [
|
||||
'total_kucing' => Cat::count(),
|
||||
'jantan' => Cat::where('jenis_kelamin','Jantan')->count(),
|
||||
'betina' => Cat::where('jenis_kelamin','Betina')->count(),
|
||||
'breeding_aktif' => Matting::where('status','proses')->count(),
|
||||
'total_anakan' => Kitten::count(),
|
||||
],
|
||||
'perkiraan_kelahiran' => $upcoming,
|
||||
'berat_terbaru' => WeightHistory::with('cat:id,nama_kucing,foto')->latest('tanggal_timbang')->take(8)->get()->map(fn($w)=>['cat_id'=>$w->cat_id,'nama'=>$w->cat?->nama_kucing,'foto'=>$w->cat?->foto_url,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang]),
|
||||
'kucing_terbaru' => Cat::latest()->take(6)->get(['id','nama_kucing','foto','jenis_kelamin','kode_ems','status'])->map(fn($c)=>['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'jenis_kelamin'=>$c->jenis_kelamin,'kode_ems'=>$c->kode_ems,'status'=>$c->status]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Kitten;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class KittenController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$items=Kitten::with(['cat:id,nama_kucing,foto,induk_jantan_id,induk_betina_id','matting.sire:id,nama_kucing','matting.dam:id,nama_kucing'])
|
||||
->when($request->search,fn($q,$s)=>$q->where('nama','like',"%$s%"))->latest('tgl_lahir')->paginate(20);
|
||||
$items->getCollection()->transform(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'foto'=>$k->cat?->foto_url,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'sire'=>$k->matting?->sire?->nama_kucing,'dam'=>$k->matting?->dam?->nama_kucing]);
|
||||
return response()->json($items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class WeightController extends Controller
|
||||
{
|
||||
public function index(Cat $cat) { Gate::authorize('check-access', ['cats', 'read']); return response()->json(['data'=>$cat->weightHistories()->orderByDesc('tanggal_timbang')->paginate(30)]); }
|
||||
public function store(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$data=$request->validate(['berat'=>'required|numeric|min:0.05|max:30','tanggal'=>'nullable|date|before_or_equal:today']);
|
||||
$history=$cat->weightHistories()->create(['berat'=>$data['berat'],'tanggal_timbang'=>$data['tanggal'] ?? today()]);
|
||||
DB::table('cats')->where('id',$cat->id)->update(['berat_badan'=>$data['berat'],'berat_terakhir'=>$data['berat'],'updated_at'=>now()]);
|
||||
return response()->json(['message'=>'Berat badan berhasil disimpan.','data'=>$history],201);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\{Matting, Cat, Kitten};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\{Request, RedirectResponse, JsonResponse};
|
||||
use Illuminate\Support\Facades\{DB, Storage};
|
||||
use Illuminate\Support\Facades\{DB, Storage, Gate};
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MattingController extends Controller
|
||||
@@ -15,6 +15,7 @@ class MattingController extends Controller
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$mattings = Matting::with(['sire', 'dam'])
|
||||
->withCount('kittens')
|
||||
->latest()
|
||||
@@ -25,6 +26,7 @@ class MattingController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'create']);
|
||||
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']);
|
||||
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']);
|
||||
return view('pages.matting.create', compact('sires', 'dams'));
|
||||
@@ -32,6 +34,7 @@ class MattingController extends Controller
|
||||
|
||||
public function getCatData($id): JsonResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$cat = Cat::find($id);
|
||||
if (!$cat) return response()->json(['error' => 'Kucing tidak ditemukan'], 404);
|
||||
|
||||
@@ -48,6 +51,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'create']);
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request) {
|
||||
@@ -79,6 +83,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function edit(Matting $matting): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
// Load relasi agar data anak muncul di form edit
|
||||
$matting->load('kittens.cat');
|
||||
|
||||
@@ -93,6 +98,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $matting) {
|
||||
@@ -151,20 +157,40 @@ class MattingController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
public function updateStatus(Request $request, Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
$validated = $request->validate([
|
||||
'status' => 'required|in:proses,hamil,berhasil,gagal',
|
||||
'actual_birth_date' => 'nullable|date',
|
||||
'breeding_notes' => 'nullable|string|max:2000',
|
||||
]);
|
||||
|
||||
if ($validated['status'] === 'hamil' && !$matting->pregnancy_confirmed_at) {
|
||||
$validated['pregnancy_confirmed_at'] = now()->toDateString();
|
||||
}
|
||||
if ($validated['status'] === 'berhasil' && empty($validated['actual_birth_date'])) {
|
||||
$validated['actual_birth_date'] = $matting->kittens()->min('tgl_lahir') ?: now()->toDateString();
|
||||
}
|
||||
|
||||
$matting->update($validated);
|
||||
$this->logAction('MATTING_STATUS_UPDATED', $matting, 'Memperbarui tahap pembiakan menjadi ' . $validated['status']);
|
||||
return back()->with('success', 'Tahap pembiakan berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* SINKRONISASI DATA INDUK (EMS Fix)
|
||||
*/
|
||||
private function syncParentData(Request $request)
|
||||
{
|
||||
Cat::where('id', $request->sire_id)->update([
|
||||
'pedigree' => $request->no_pedigree_bapak,
|
||||
'microchip' => $request->no_microchip_bapak,
|
||||
'tanggal_lahir' => $request->tgl_lahir_sire,
|
||||
'kode_ems' => $request->ems_bapak // Sinkron EMS Bapak
|
||||
]);
|
||||
|
||||
Cat::where('id', $request->dam_id)->update([
|
||||
'pedigree' => $request->no_pedigree_ibu,
|
||||
'microchip' => $request->no_microchip_ibu,
|
||||
'tanggal_lahir' => $request->tgl_lahir_dam,
|
||||
'kode_ems' => $request->ems_ibu // Sinkron EMS Ibu
|
||||
@@ -218,6 +244,7 @@ class MattingController extends Controller
|
||||
|
||||
public function destroy(Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'delete']);
|
||||
return DB::transaction(function () use ($matting) {
|
||||
try {
|
||||
$pairName = ($matting->sire->nama_kucing ?? 'Sire') . ' x ' . ($matting->dam->nama_kucing ?? 'Dam');
|
||||
@@ -262,7 +289,10 @@ class MattingController extends Controller
|
||||
'sire_id' => 'required|exists:cats,id',
|
||||
'dam_id' => 'required|exists:cats,id',
|
||||
'tgl_kawin_awal' => 'required|date',
|
||||
'status' => 'required|in:proses,berhasil,gagal',
|
||||
'status' => 'required|in:proses,hamil,berhasil,gagal',
|
||||
'pregnancy_confirmed_at' => 'nullable|date',
|
||||
'actual_birth_date' => 'nullable|date',
|
||||
'breeding_notes' => 'nullable|string|max:2000',
|
||||
'ems_bapak' => 'nullable|string',
|
||||
'ems_ibu' => 'nullable|string',
|
||||
'no_pedigree_bapak' => 'nullable|string',
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\{Cat, User, Matting};
|
||||
use App\Models\{Cat, User, Matting,Medicine};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\{Storage, DB, Gate}; // Tambahkan Gate
|
||||
use Illuminate\Support\Facades\{Storage, DB, Gate,Cache}; // Tambahkan Gate
|
||||
use Illuminate\Support\Carbon;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Imports\CatWeightImport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Exports\CatWeightTemplateExport;
|
||||
use Maatwebsite\Excel\Validators\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class CatController extends Controller
|
||||
{
|
||||
@@ -23,12 +30,24 @@ class CatController extends Controller
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses: Read
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$query = Cat::query()->with(['pic', 'sire', 'dam']);
|
||||
$now = now();
|
||||
$listScope = $request->get('list_scope', 'active');
|
||||
if (!in_array($listScope, ['active', 'adopted', 'external_parent', 'all'], true)) {
|
||||
$listScope = 'active';
|
||||
}
|
||||
|
||||
$query = Cat::query()->with(['pic:id,name', 'sire', 'dam', 'registrations.association']);
|
||||
|
||||
if ($listScope === 'active') {
|
||||
$query->activeInCattery();
|
||||
} elseif ($listScope === 'adopted') {
|
||||
$query->adopted();
|
||||
} elseif ($listScope === 'external_parent') {
|
||||
$query->externalParents();
|
||||
}
|
||||
|
||||
// 1. Pencarian Global
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
@@ -36,30 +55,68 @@ class CatController extends Controller
|
||||
->orWhere('ras', 'like', "%{$search}%")
|
||||
->orWhere('microchip', 'like', "%{$search}%")
|
||||
->orWhere('pedigree', 'like', "%{$search}%")
|
||||
->orWhere('kode_ems', 'like', "%{$search}%");
|
||||
->orWhere('kode_ems', 'like', "%{$search}%")
|
||||
->orWhere('lokasi_kandang', 'like', "%{$search}%")
|
||||
->orWhereHas('pic', fn($sub) => $sub->where('name', 'like', "%{$search}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Filter Spesifik
|
||||
if ($request->filled('status')) $query->where('status', $request->status);
|
||||
if ($request->filled('filter_overdue')) {
|
||||
$filter = $request->get('filter_overdue');
|
||||
$query->where(function($q) use ($filter, $now) {
|
||||
if ($filter === 'vaccine') {
|
||||
$q->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now);
|
||||
} elseif (in_array($filter, ['flea', 'obat_kutu'], true)) {
|
||||
$q->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now);
|
||||
} elseif (in_array($filter, ['deworming', 'obat_cacing'], true)) {
|
||||
$q->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now);
|
||||
} elseif ($filter === 'grooming') {
|
||||
$q->whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->get('filter_health') === 'treatment') {
|
||||
$query->where('treatment_status', 1);
|
||||
}
|
||||
|
||||
if ($request->filled('status')) {
|
||||
$status = $request->status;
|
||||
if (in_array(strtolower($status), ['adult', 'dewasa'], true)) {
|
||||
$query->where('tanggal_lahir', '<=', $now->copy()->subMonths(12));
|
||||
} elseif ($status === 'Junior') {
|
||||
$query->whereBetween('tanggal_lahir', [$now->copy()->subMonths(12)->addDay(), $now->copy()->subMonths(7)]);
|
||||
} elseif ($status === 'Kitten') {
|
||||
$query->where('tanggal_lahir', '>', $now->copy()->subMonths(7));
|
||||
} else {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('ras')) $query->where('ras', $request->ras);
|
||||
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
|
||||
|
||||
$cats = $query->latest()->paginate(15)->withQueryString();
|
||||
$allowedSorts = ['nama_kucing', 'lokasi_kandang', 'berat_badan', 'created_at', 'pic_name'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts, true) ? $request->get('sort') : 'created_at';
|
||||
$direction = strtolower($request->get('direction')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
// 3. Statistik Dashboard (Tetap Utuh)
|
||||
$totalCats = Cat::count();
|
||||
$safeWords = ['sehat', 'normal', 'aktif', 'sembuh', '-', 'tidak ada', 'clean'];
|
||||
$sickCats = Cat::whereNotNull('riwayat_sakit_terakhir')
|
||||
->where(function($q) use ($safeWords) {
|
||||
foreach ($safeWords as $word) {
|
||||
$q->where('riwayat_sakit_terakhir', 'NOT LIKE', "%{$word}%");
|
||||
if ($sort === 'pic_name') {
|
||||
$query->leftJoin('users', 'cats.pic_id', '=', 'users.id')->select('cats.*')->orderBy('users.name', $direction);
|
||||
} else {
|
||||
$query->orderBy("cats.$sort", $direction);
|
||||
}
|
||||
})->count();
|
||||
|
||||
$breeds = Cat::distinct()->pluck('ras');
|
||||
$cats = $query->paginate(15)->withQueryString();
|
||||
|
||||
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
|
||||
$activeCats = Cat::activeInCattery()->count();
|
||||
$adoptedCats = Cat::adopted()->count();
|
||||
$externalParentCats = Cat::externalParents()->count();
|
||||
$sickCats = Cat::activeInCattery()->where('treatment_status', 1)->count();
|
||||
$breeds = Cat::activeInCattery()->distinct()->whereNotNull('ras')->pluck('ras');
|
||||
|
||||
return view('pages.cats.index', compact(
|
||||
'cats', 'activeCats', 'adoptedCats', 'externalParentCats', 'sickCats', 'breeds', 'listScope'
|
||||
));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
@@ -78,43 +135,96 @@ class CatController extends Controller
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'create']);
|
||||
|
||||
// 1. Validasi
|
||||
$validated = $this->validateCat($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated) {
|
||||
// 2. Handling Upload Foto (Fix: Encoding Intervention v3)
|
||||
if ($request->hasFile('foto')) {
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if (isset($validated['berat_badan'])) {
|
||||
$validated['berat_terakhir'] = $validated['berat_badan'];
|
||||
// 3. Handling Berat Badan (Sederhanakan)
|
||||
// Cukup isi berat_badan, Model Cat akan otomatis mengisi berat_terakhir (via static::saving)
|
||||
if ($request->filled('berat_badan')) {
|
||||
$validated['berat_badan'] = $request->berat_badan;
|
||||
}
|
||||
|
||||
// 4. Handling Status Otomatis
|
||||
if (empty($validated['status'])) {
|
||||
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
|
||||
}
|
||||
|
||||
// Memastikan registry disimpan sebagai JSON jika model tidak melakukan casting
|
||||
if (isset($validated['registry'])) {
|
||||
$validated['registry'] = $validated['registry'];
|
||||
}
|
||||
// 5. Simpan Data
|
||||
// Pastikan kolom-kolom induk juga masuk ke validated jika ada di request
|
||||
$validated['induk_jantan_id'] = $request->induk_jantan_id;
|
||||
$validated['induk_betina_id'] = $request->induk_betina_id;
|
||||
$validated['pic_id'] = $request->pic_id ?? auth()->id();
|
||||
$validated['cattery_status'] = $request->boolean('is_external_parent')
|
||||
? 'external_parent'
|
||||
: ($request->boolean('is_adopted') ? 'adopted' : 'active');
|
||||
$validated['adopted_at'] = $validated['cattery_status'] === 'adopted'
|
||||
? ($request->input('adopted_at') ?: now()->toDateString())
|
||||
: null;
|
||||
$validated['adoption_notes'] = $request->input('adoption_notes');
|
||||
|
||||
$cat = Cat::create($validated);
|
||||
$this->logAction('CREATED', $cat, "Mendaftarkan pasien baru: {$cat->nama_kucing}", 'Cat');
|
||||
|
||||
// 6. Logging (Pastikan Trait LogsActivity & Tabel Log sudah punya kolom 'model')
|
||||
try {
|
||||
$this->logAction('CREATED', $cat, "Mendaftarkan kucing baru: {$cat->nama_kucing}", 'Cat');
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::warning("Log gagal dicatat: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix: Perbaikan cara simpan gambar pada Intervention Image v3
|
||||
*/
|
||||
private function handleImageUpload($file): string
|
||||
{
|
||||
$filename = 'cat_' . uniqid() . '.png';
|
||||
$path = 'cats/' . $filename;
|
||||
|
||||
// Intervention v3: Gunakan encode() atau toPng() lalu simpan sebagai string
|
||||
$img = Image::read($file)->cover(500, 500, 'center');
|
||||
|
||||
// Simpan ke disk public
|
||||
Storage::disk('public')->put($path, $img->toPng()->toString());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function show(Cat $cat): View
|
||||
{
|
||||
// 1. Otorisasi (TETAP ADA)
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
// 2. Eager Loading (DITAMBAHKAN medicalRecords & performer)
|
||||
// Kita memuat medicalRecords agar tidak terjadi query berulang di dalam Loop Blade (N+1 Problem)
|
||||
$cat->load([
|
||||
'treatments' => fn($q) => $q->latest(),
|
||||
'sire', 'dam', 'pic',
|
||||
'weightHistories' => fn($q) => $q->latest()->limit(12)
|
||||
'medicalRecords' => fn($q) => $q->latest(), // Baru: Riwayat Vaksin/Obat Cacing
|
||||
'medicalRecords.performer', // Baru: Siapa yang melakukan tindakan
|
||||
'sire',
|
||||
'dam',
|
||||
'pic',
|
||||
'weightHistories' => fn($q) => $q->latest()->limit(12),
|
||||
'registrations.association',
|
||||
'registrations.documents'
|
||||
]);
|
||||
return view('pages.cats.show', compact('cat'));
|
||||
|
||||
// 3. Ambil Data Inventori (Baru: Untuk Datalist di Modal Input)
|
||||
// Menampilkan semua obat yang stoknya masih tersedia
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)
|
||||
->orderBy('nama_obat', 'asc')
|
||||
->get(['nama_obat', 'qty', 'unit']);
|
||||
|
||||
// 4. Return View dengan Compact (DITAMBAHKAN availableMedicines)
|
||||
return view('pages.cats.show', compact('cat', 'availableMedicines'));
|
||||
}
|
||||
|
||||
public function edit(Cat $cat): View
|
||||
@@ -131,27 +241,50 @@ class CatController extends Controller
|
||||
|
||||
public function update(Request $request, Cat $cat): RedirectResponse
|
||||
{
|
||||
// 1. Otorisasi Akses
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// 2. Validasi (Mengirimkan $cat->id agar Rule:unique mengabaikan record ini sendiri)
|
||||
$validated = $this->validateCat($request, $cat->id);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
|
||||
// 3. Handling Foto
|
||||
if ($request->hasFile('foto')) {
|
||||
if ($cat->foto) Storage::disk('public')->delete($cat->foto);
|
||||
// Hapus foto lama jika ada
|
||||
if ($cat->foto && Storage::disk('public')->exists($cat->foto)) {
|
||||
Storage::disk('public')->delete($cat->foto);
|
||||
}
|
||||
// Upload foto baru menggunakan helper yang sudah diperbaiki
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if ($request->filled('berat_badan')) {
|
||||
$validated['berat_terakhir'] = $request->berat_badan;
|
||||
if ($cat->berat_badan != $request->berat_badan) {
|
||||
$cat->weightHistories()->create(['berat' => $request->berat_badan]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 4. PENYEMPURNAAN REGISTRY (FIX UPDATE)
|
||||
* Jika di form semua checkbox tidak dicentang, $request->registry akan kosong.
|
||||
* Kita paksa nilainya menjadi ['NON-PED'] agar data lama di DB terhapus/terganti.
|
||||
*/
|
||||
$validated['registry'] = $request->input('registry', ['NON-PED']);
|
||||
$validated['cattery_status'] = $request->boolean('is_external_parent')
|
||||
? 'external_parent'
|
||||
: ($request->boolean('is_adopted') ? 'adopted' : 'active');
|
||||
$validated['adopted_at'] = $validated['cattery_status'] === 'adopted'
|
||||
? ($request->input('adopted_at') ?: ($cat->adopted_at?->format('Y-m-d') ?? now()->toDateString()))
|
||||
: null;
|
||||
$validated['adoption_notes'] = $request->input('adoption_notes');
|
||||
|
||||
// 5. Update Data Utama
|
||||
// Logika berat_badan sudah otomatis dihandle oleh Model (static::saving/updated)
|
||||
$cat->update($validated);
|
||||
$this->logAction('UPDATED', $cat, "Update data medis/profil: {$cat->nama_kucing}", 'Cat');
|
||||
|
||||
return redirect()->route('cats.index')->with('success', 'Data berhasil diperbarui.');
|
||||
// 6. Activity Log (Dibalut Try-Catch agar proses update tidak gagal hanya karena log)
|
||||
try {
|
||||
$this->logAction('UPDATED', $cat, "Memperbarui profil medis & identitas: {$cat->nama_kucing}", 'Cat');
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::warning("Activity Log gagal dicatat: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "Data {$cat->nama_kucing} berhasil diperbarui.");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,71 +322,226 @@ class CatController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF ID Card / Medical Pass
|
||||
* PENYEMPURNAAN: Perbaikan Barcode (Base64 SVG)
|
||||
*/
|
||||
public function downloadPdf(Cat $cat)
|
||||
//cetak download health ledger pdf
|
||||
|
||||
public function downloadReport(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load(['pic', 'sire', 'dam']);
|
||||
|
||||
// FIX: Konversi SVG ke Base64 agar tampil di domPDF tanpa Imagick
|
||||
$qrCodeRaw = QrCode::format('svg')
|
||||
->size(150)
|
||||
->margin(1)
|
||||
->generate(route('cats.show', $cat->id));
|
||||
|
||||
$qrCode = base64_encode($qrCodeRaw);
|
||||
|
||||
$pdf = Pdf::loadView('pages.cats.pdf-card', [
|
||||
'cat' => $cat,
|
||||
'qrCode' => $qrCode, // Dikirim sebagai Base64
|
||||
'printedAt' => Carbon::now()->translatedFormat('d F Y H:i')
|
||||
$cat->load([
|
||||
'medicalRecords' => fn($q) => $q->orderBy('date_given', 'desc'),
|
||||
'medicalRecords.performer',
|
||||
'treatments' => fn($q) => $q->orderBy('tanggal', 'desc'), // <--- Pastikan ini ada
|
||||
'sire',
|
||||
'dam',
|
||||
'weightHistories' => fn($q) => $q->orderBy('created_at', 'desc')
|
||||
]);
|
||||
|
||||
return $pdf->setPaper([0, 0, 300, 500], 'portrait')
|
||||
->stream('MEDICAL_PASS_' . strtoupper($cat->nama_kucing) . '.pdf');
|
||||
$groupedHistory = $cat->medicalRecords->groupBy('type');
|
||||
|
||||
$pdf = Pdf::loadView('pages.reports.medical_history', compact('cat', 'groupedHistory'))
|
||||
->setPaper('a4', 'portrait');
|
||||
|
||||
$filename = "Medical_Report_" . Str::slug($cat->nama_kucing) . "_" . date('Ymd') . ".pdf";
|
||||
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
|
||||
public function bulkWeightPage(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
return view('pages.cats.bulk-weight');
|
||||
}
|
||||
|
||||
public function downloadWeightTemplate(): BinaryFileResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Menambahkan Jam & Menit agar nama file lebih spesifik
|
||||
$fileName = "Template_Bulk_BB_" . date('Ymd_Hi') . ".xlsx";
|
||||
|
||||
return Excel::download(new CatWeightTemplateExport, $fileName);
|
||||
}
|
||||
|
||||
public function uploadWeight(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
// Tambahkan rule 'file' untuk memastikan inputan benar-benar file
|
||||
'file_excel' => 'required|file|mimes:xlsx,xls,csv|max:5120',
|
||||
], [
|
||||
'file_excel.required' => 'File Excel wajib diunggah.',
|
||||
'file_excel.mimes' => 'Format file harus berupa Excel (.xlsx, .xls) atau CSV.',
|
||||
'file_excel.max' => 'Ukuran file maksimal adalah 5MB.',
|
||||
]);
|
||||
|
||||
try {
|
||||
// 1. Masukkan class import ke dalam variabel
|
||||
$importHelper = new CatWeightImport();
|
||||
|
||||
// 2. Jalankan proses import menggunakan variabel tersebut
|
||||
Excel::import($importHelper, $request->file('file_excel'));
|
||||
|
||||
// 3. Ambil jumlah data yang berhasil diproses
|
||||
$totalBerhasil = $importHelper->getImportedCount();
|
||||
|
||||
// 4. Sisipkan jumlahnya ke dalam pesan sukses
|
||||
return redirect()->route('cats.index')
|
||||
->with('success', "Data berat badan massal berhasil diproses! Sebanyak {$totalBerhasil} record berhasil disinkronisasi ke dalam database.");
|
||||
|
||||
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
|
||||
return back()->with('error', 'Tipe file tidak dikenali. Pastikan file tidak rusak (corrupt).');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// MENANGKAP ERROR SISTEM / DATABASE
|
||||
return back()->with('error', 'Gagal memproses file Excel. Pastikan format kolom sesuai template. Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan/Update Sertifikat Pedigree
|
||||
*/
|
||||
public function uploadPedigree(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$request->validate(['sertifikat_ped_file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240']);
|
||||
|
||||
$registration = $cat->registrations()->where('is_primary', true)->first() ?: $cat->registrations()->first();
|
||||
if (!$registration) {
|
||||
return redirect()->route('cats.pedigree', $cat)->with('error', 'Tambahkan registrasi asosiasi terlebih dahulu sebelum mengunggah sertifikat.');
|
||||
}
|
||||
|
||||
$path = $request->file('sertifikat_ped_file')->store('cat-registrations/' . $cat->id, 'public');
|
||||
$registration->documents()->create([
|
||||
'label' => 'Sertifikat Pedigree',
|
||||
'document_type' => 'pedigree',
|
||||
'file_path' => $path,
|
||||
]);
|
||||
|
||||
return redirect()->route('cats.pedigree', $cat)->with('success', 'Sertifikat pedigree berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* HELPERS (TETAP UTUH) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
|
||||
private function validateCat(Request $request, $id = null)
|
||||
{
|
||||
return $request->validate([
|
||||
// Identitas Dasar
|
||||
'nama_kucing' => 'required|string|max:255',
|
||||
'ras' => 'required|string',
|
||||
'ras' => 'required|string|max:100',
|
||||
'jenis_kelamin' => 'required|in:Jantan,Betina',
|
||||
'tanggal_lahir' => 'nullable|date',
|
||||
'tanggal_lahir' => 'nullable|date|before_or_equal:today',
|
||||
|
||||
// Media
|
||||
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
|
||||
|
||||
// Registry & Legalitas (Ditambahkan NON-PED & Unik)
|
||||
'registry' => 'nullable|array',
|
||||
'registry.*' => 'string',
|
||||
'pedigree' => 'nullable|string|max:255',
|
||||
'microchip' => 'nullable|string|max:255',
|
||||
'kode_ems' => 'nullable|string|max:255',
|
||||
'status' => 'nullable|string',
|
||||
'induk_jantan_id' => 'nullable|exists:cats,id',
|
||||
'induk_betina_id' => 'nullable|exists:cats,id',
|
||||
'lokasi_kandang' => 'nullable|string',
|
||||
'registry.*' => ['string', \Illuminate\Validation\Rule::in(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'])],
|
||||
'pedigree' => [
|
||||
'nullable', 'string', 'max:255',
|
||||
Rule::unique('cats', 'pedigree')->ignore($id)
|
||||
],
|
||||
'microchip' => [
|
||||
'nullable', 'string', 'max:255',
|
||||
Rule::unique('cats', 'microchip')->ignore($id)
|
||||
],
|
||||
'kode_ems' => 'nullable|string|max:50',
|
||||
|
||||
// Status & Lokasi
|
||||
'status' => 'nullable|string|max:50',
|
||||
'adopted_at' => 'nullable|date',
|
||||
'adoption_notes'=> 'nullable|string|max:1000',
|
||||
'lokasi_kandang'=> 'nullable|string|max:255',
|
||||
'pic_id' => 'nullable|exists:users,id',
|
||||
'berat_badan' => 'nullable|numeric|min:0',
|
||||
|
||||
// Silsilah (Validasi Induk harus sesuai Gender)
|
||||
'induk_jantan_id' => [
|
||||
'nullable',
|
||||
Rule::exists('cats', 'id')->where(function ($query) {
|
||||
$query->where('jenis_kelamin', 'Jantan');
|
||||
}),
|
||||
],
|
||||
'induk_betina_id' => [
|
||||
'nullable',
|
||||
Rule::exists('cats', 'id')->where(function ($query) {
|
||||
$query->where('jenis_kelamin', 'Betina');
|
||||
}),
|
||||
],
|
||||
|
||||
// Medis & Monitoring
|
||||
'berat_badan' => 'nullable|numeric|min:0|max:20',
|
||||
'riwayat_sakit_terakhir' => 'nullable|string',
|
||||
'jadwal_vaksin' => 'nullable|date',
|
||||
'jadwal_obat_cacing' => 'nullable|date',
|
||||
'jadwal_obat_kutu' => 'nullable|date',
|
||||
'jadwal_grooming' => 'nullable|date',
|
||||
], [
|
||||
// Custom Message (Opsional agar user paham kenapa gagal)
|
||||
'induk_jantan_id.exists' => 'Induk jantan harus kucing dengan jenis kelamin Jantan.',
|
||||
'induk_betina_id.exists' => 'Induk betina harus kucing dengan jenis kelamin Betina.',
|
||||
'microchip.unique' => 'Nomor Microchip sudah terdaftar di sistem.',
|
||||
'tanggal_lahir.before_or_equal' => 'Tanggal lahir tidak boleh melebihi hari ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleImageUpload($file): string
|
||||
public function completeTreatment(Request $request, Cat $cat)
|
||||
{
|
||||
$filename = 'cat_' . uniqid() . '.png';
|
||||
$path = 'cats/' . $filename;
|
||||
$img = Image::read($file)->cover(500, 500, 'center')->toPng();
|
||||
Storage::disk('public')->put($path, (string) $img);
|
||||
return $path;
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
'treatment_id' => 'required|exists:treatments,id',
|
||||
'remark_pasca_sakit' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
// ========================================================
|
||||
// 1. EKSEKUSI PAKSA TABEL TREATMENTS (JALUR KERAS)
|
||||
// DB::table akan menembus langsung ke database MySQL Anda.
|
||||
// ========================================================
|
||||
DB::table('treatments')
|
||||
->where('id', $request->treatment_id)
|
||||
->update([
|
||||
'status_perawatan' => 2,
|
||||
'remark_pasca_sakit' => $request->remark_pasca_sakit,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Ambil nama keluhan untuk dimunculkan di notifikasi
|
||||
$namaKeluhan = DB::table('treatments')->where('id', $request->treatment_id)->value('keluhan');
|
||||
|
||||
// ========================================================
|
||||
// 2. CEK SISA PENYAKIT (Adakah penyakit lain di kucing ini yang masih = 1?)
|
||||
// ========================================================
|
||||
$sisaPenyakitAktif = DB::table('treatments')
|
||||
->where('cat_id', $cat->id)
|
||||
->where('status_perawatan', 1)
|
||||
->exists();
|
||||
|
||||
// ========================================================
|
||||
// 3. EKSEKUSI TABEL CATS BERDASARKAN HASIL CEK
|
||||
// ========================================================
|
||||
if (!$sisaPenyakitAktif) {
|
||||
// JIKA SUDAH TIDAK ADA PENYAKIT LAIN -> Kucing Full Sehat (2)
|
||||
$cat->treatment_status = 2;
|
||||
$cat->save();
|
||||
|
||||
return redirect()->back()->with('success', "Penyakit '{$namaKeluhan}' disembuhkan. Kucing sekarang berstatus SEHAT sepenuhnya!");
|
||||
}
|
||||
|
||||
// JIKA MASIH ADA PENYAKIT LAIN -> Pastikan kucing TETAP berstatus Treatment (1)
|
||||
$cat->treatment_status = 1;
|
||||
$cat->save();
|
||||
|
||||
return redirect()->back()->with('warning', "Penyakit '{$namaKeluhan}' disembuhkan, namun kucing MASIH DALAM PERAWATAN untuk keluhan lainnya.");
|
||||
}
|
||||
|
||||
public function pedigree(Cat $cat)
|
||||
{
|
||||
return view('pages.cats.pedigree', compact('cat'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cat;
|
||||
use App\Models\CatAssociation;
|
||||
use App\Models\CatRegistration;
|
||||
use App\Models\CatRegistrationDocument;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CatRegistrationController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function index(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cat->load(['registrations.association', 'registrations.documents']);
|
||||
$associations = CatAssociation::where('is_active', true)->orderBy('code')->get();
|
||||
return view('pages.cats.pedigree', compact('cat', 'associations'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$validated = $this->validateRegistration($request, $cat);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
$associationId = $this->resolveAssociation($request);
|
||||
if ($cat->registrations()->where('association_id', $associationId)->exists()) {
|
||||
return back()->withErrors(['association_id' => 'Asosiasi ini sudah terdaftar untuk kucing tersebut.']);
|
||||
}
|
||||
if ($request->boolean('is_primary')) {
|
||||
$cat->registrations()->update(['is_primary' => false]);
|
||||
}
|
||||
$registration = $cat->registrations()->create(array_merge($validated, [
|
||||
'association_id' => $associationId,
|
||||
'is_primary' => $request->boolean('is_primary') || !$cat->registrations()->exists(),
|
||||
]));
|
||||
$this->storeDocumentIfPresent($request, $registration);
|
||||
$this->logAction('CAT_REGISTRATION_CREATED', $cat, 'Menambahkan registrasi pedigree pada ' . $registration->association->code, 'Cat');
|
||||
return back()->with('success', 'Registrasi pedigree berhasil ditambahkan.');
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Request $request, Cat $cat, CatRegistration $registration)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id, 404);
|
||||
$validated = $this->validateRegistration($request, $cat, $registration);
|
||||
if ($request->boolean('is_primary')) {
|
||||
$cat->registrations()->whereKeyNot($registration->id)->update(['is_primary' => false]);
|
||||
}
|
||||
$registration->update(array_merge($validated, ['is_primary' => $request->boolean('is_primary')]));
|
||||
return back()->with('success', 'Registrasi pedigree berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Cat $cat, CatRegistration $registration)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id, 404);
|
||||
$wasPrimary = $registration->is_primary;
|
||||
$registration->delete();
|
||||
if ($wasPrimary && ($next = $cat->registrations()->oldest()->first())) {
|
||||
$next->update(['is_primary' => true]);
|
||||
}
|
||||
return back()->with('success', 'Registrasi pedigree berhasil dihapus.');
|
||||
}
|
||||
|
||||
public function storeDocument(Request $request, Cat $cat, CatRegistration $registration)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id, 404);
|
||||
$request->validate([
|
||||
'document' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'label' => 'nullable|string|max:120',
|
||||
'document_type' => ['nullable', Rule::in(['pedigree','registration','ownership','title','other'])],
|
||||
'issued_at' => 'nullable|date',
|
||||
]);
|
||||
$this->storeDocumentIfPresent($request, $registration, 'document');
|
||||
return back()->with('success', 'Dokumen berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
|
||||
public function showDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
abort_unless($registration->cat_id === $cat->id && $document->cat_registration_id === $registration->id, 404);
|
||||
abort_unless(Storage::disk('public')->exists($document->file_path), 404);
|
||||
|
||||
return response()->file(Storage::disk('public')->path($document->file_path), [
|
||||
'Content-Disposition' => 'inline; filename="' . basename($document->file_path) . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function downloadDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
abort_unless($registration->cat_id === $cat->id && $document->cat_registration_id === $registration->id, 404);
|
||||
abort_unless(Storage::disk('public')->exists($document->file_path), 404);
|
||||
|
||||
$extension = pathinfo($document->file_path, PATHINFO_EXTENSION);
|
||||
$filename = str($document->label ?: 'pedigree')->slug('_') . ($extension ? '.' . $extension : '');
|
||||
return Storage::disk('public')->download($document->file_path, $filename);
|
||||
}
|
||||
|
||||
public function destroyDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id && $document->cat_registration_id === $registration->id, 404);
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
$document->delete();
|
||||
return back()->with('success', 'Dokumen berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function validateRegistration(Request $request, Cat $cat, ?CatRegistration $registration = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'registration_number' => 'nullable|string|max:100',
|
||||
'pedigree_number' => 'nullable|string|max:100',
|
||||
'certificate_number' => 'nullable|string|max:100',
|
||||
'issue_date' => 'nullable|date',
|
||||
'title' => 'nullable|string|max:120',
|
||||
'ownership_status' => ['nullable', Rule::in(['milik_sendiri','co_own','transfer','lainnya'])],
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'association_id' => $registration ? 'nullable' : 'required',
|
||||
'association_code' => 'nullable|string|max:30',
|
||||
'association_name' => 'nullable|string|max:120',
|
||||
'certificate_file' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveAssociation(Request $request): int
|
||||
{
|
||||
if ($request->association_id !== 'custom') {
|
||||
return (int) CatAssociation::whereKey($request->association_id)->where('is_active', true)->firstOrFail()->id;
|
||||
}
|
||||
$request->validate(['association_code' => 'required|string|max:30', 'association_name' => 'required|string|max:120']);
|
||||
return CatAssociation::firstOrCreate(
|
||||
['code' => strtoupper(trim($request->association_code))],
|
||||
['name' => trim($request->association_name), 'is_active' => true]
|
||||
)->id;
|
||||
}
|
||||
|
||||
private function storeDocumentIfPresent(Request $request, CatRegistration $registration, string $field = 'certificate_file'): void
|
||||
{
|
||||
if (!$request->hasFile($field)) return;
|
||||
$path = $request->file($field)->store('cat-registrations/' . $registration->cat_id, 'public');
|
||||
$registration->documents()->create([
|
||||
'label' => $request->input('label') ?: 'Sertifikat Pedigree',
|
||||
'document_type' => $request->input('document_type') ?: 'pedigree',
|
||||
'file_path' => $path,
|
||||
'issued_at' => $request->input('issued_at') ?: $request->input('issue_date'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
@@ -18,11 +19,39 @@ class CategoryController extends Controller
|
||||
/**
|
||||
* Menampilkan daftar kategori.
|
||||
*/
|
||||
public function index(): View
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Mengambil kategori beserta hitungan item di dalamnya (optional untuk insight)
|
||||
$categories = Category::latest()->get();
|
||||
return view('pages.inventory.categories.index', compact('categories'));
|
||||
// 1. Proteksi Akses (Sesuai Matriks Role Management)
|
||||
Gate::authorize('check-access', ['categories', 'read']);
|
||||
|
||||
$query = Category::query();
|
||||
|
||||
// 2. SEARCH GLOBAL (Mencari di Nama & Keywords)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('keywords', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 3. LOGIKA SORTING DINAMIS
|
||||
$allowedSorts = ['name', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'name';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// 4. EKSEKUSI DENGAN PAGINASI (Standarisasi 15 item per halaman)
|
||||
$categories = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK RINGKAS
|
||||
$totalCategories = Category::count();
|
||||
$latestCategory = Category::latest()->first()?->name ?? '-';
|
||||
|
||||
return view('pages.inventory.categories.index', compact(
|
||||
'categories', 'totalCategories', 'latestCategory'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,9 +76,8 @@ class CategoryController extends Controller
|
||||
*/
|
||||
public function edit(Category $category): View
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
|
||||
Gate::authorize('check-access', ['categories', 'update']);
|
||||
|
||||
return view('pages.inventory.categories.edit', compact('category'));
|
||||
}
|
||||
|
||||
@@ -3,86 +3,467 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment};
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment, Kitten};
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 1. Statistik Utama
|
||||
$now = Carbon::now();
|
||||
$selectedMonth = $request->get('month', $now->format('m'));
|
||||
$selectedYear = $request->get('year', $now->format('Y'));
|
||||
$selectedGender = $request->get('gender');
|
||||
$selectedStatus = $request->get('status');
|
||||
|
||||
$stats = [
|
||||
'total_cats' => Cat::count(),
|
||||
'total_staff' => \App\Models\User::count(),
|
||||
'sick_cats' => Cat::whereNotNull('riwayat_sakit_terakhir')->count(),
|
||||
'total_mating' => Matting::where('status', 'proses')->count(),
|
||||
'total_cats' => Cat::activeInCattery()->count(),
|
||||
'total_kittens' => Kitten::count(),
|
||||
'sick_cats' => Cat::activeInCattery()->where('treatment_status', 1)->count(),
|
||||
'total_mating' => Matting::whereIn('status', ['proses', 'hamil'])->count(),
|
||||
];
|
||||
|
||||
// 2. Birth Watch: Persalinan 7 Hari ke Depan (Rumus: H+63)
|
||||
$upcomingBirths = Matting::with(['dam', 'sire', 'pic'])
|
||||
->where('status', 'proses')
|
||||
->whereNotNull('tanggal_kawin')
|
||||
->whereBetween('tanggal_kawin', [
|
||||
now()->subDays(70)->toDateString(),
|
||||
now()->subDays(56)->toDateString()
|
||||
])
|
||||
$upcomingBirths = Matting::with(['dam', 'sire'])
|
||||
->whereIn('status', ['proses', 'hamil'])
|
||||
->whereNotNull('tgl_kawin_awal')
|
||||
->get()
|
||||
->map(function ($mating) {
|
||||
$mating->edd = Carbon::parse($mating->tanggal_kawin)->addDays(63);
|
||||
$mating->days_to_go = (int) now()->diffInDays($mating->edd, false);
|
||||
->map(function ($mating) use ($now) {
|
||||
$mating->edd = Carbon::parse($mating->tgl_kawin_awal)->addDays(63);
|
||||
$mating->days_to_go = (int) $now->copy()->startOfDay()->diffInDays($mating->edd->copy()->startOfDay(), false);
|
||||
return $mating;
|
||||
})
|
||||
->filter(fn($m) => $m->days_to_go >= -2 && $m->days_to_go <= 7)
|
||||
->filter(fn ($mating) => $mating->days_to_go >= -2 && $mating->days_to_go <= 7)
|
||||
->sortBy('days_to_go');
|
||||
|
||||
// 3. Medical Alerts
|
||||
$medicalAlerts = [
|
||||
'vaccine' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<=', now()->addDays(3))->count(),
|
||||
'flea' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<=', now()->addDays(3))->count(),
|
||||
'deworming' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<=', now()->addDays(3))->count(),
|
||||
'grooming' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<=', now()->addDays(3))->count(),
|
||||
$overdueAlerts = [
|
||||
'vaccine' => [
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'vaccine']),
|
||||
],
|
||||
'flea' => [
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'flea']),
|
||||
],
|
||||
'deworming' => [
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'deworming']),
|
||||
],
|
||||
'grooming' => [
|
||||
'count' => Cat::activeInCattery()->whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'grooming']),
|
||||
],
|
||||
];
|
||||
|
||||
// 4. Inventory Critical (Stok < 5)
|
||||
$lowStock = [
|
||||
'medicines' => Medicine::where('qty', '<', 5)->get(['nama_obat as name', 'qty', 'unit']),
|
||||
'foods' => Food::where('qty', '<', 5)->get(['nama_makanan as name', 'qty', 'unit']),
|
||||
'supplies' => Supply::where('qty', '<', 5)->get(['nama_perlengkapan as name', 'qty', 'unit']),
|
||||
];
|
||||
$totalLowStock = collect($lowStock)->flatten(1)->count();
|
||||
|
||||
// 5. Chart Data: Sebaran Ras (Persian, British Shorthair, dll)
|
||||
$chartData = Cat::select('ras', DB::raw('count(*) as total'))->groupBy('ras')->get();
|
||||
|
||||
// 6. Weight Trends: Rata-rata Berat (6 Bulan Terakhir)
|
||||
$weightTrends = DB::table('weight_histories')
|
||||
/*
|
||||
* Berat terakhir untuk daftar/ranking pada bulan yang dipilih.
|
||||
* Kode ini dipertahankan agar fungsi dashboard lama tetap tersedia.
|
||||
*/
|
||||
$weightQuery = DB::table('weight_histories')
|
||||
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
|
||||
->select(
|
||||
DB::raw('AVG(berat) as avg_weight'),
|
||||
DB::raw("DATE_FORMAT(tanggal_timbang, '%M') as month"),
|
||||
DB::raw('MONTH(tanggal_timbang) as month_num')
|
||||
'cats.id as cat_id',
|
||||
'cats.nama_kucing',
|
||||
'cats.jenis_kelamin',
|
||||
'cats.status',
|
||||
'cats.tanggal_lahir',
|
||||
'weight_histories.berat',
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->where('tanggal_timbang', '>=', now()->subMonths(6))
|
||||
->groupBy('month', 'month_num')
|
||||
->orderBy('month_num')
|
||||
->get();
|
||||
->where(function ($q) { $q->whereNull('cats.cattery_status')->orWhere('cats.cattery_status', 'active'); })
|
||||
->whereIn('weight_histories.id', function ($query) use ($selectedMonth, $selectedYear) {
|
||||
$query->select(DB::raw('MAX(id)'))
|
||||
->from('weight_histories')
|
||||
->whereMonth('tanggal_timbang', $selectedMonth)
|
||||
->whereYear('tanggal_timbang', $selectedYear)
|
||||
->groupBy('cat_id');
|
||||
});
|
||||
|
||||
if ($weightTrends->isEmpty()) {
|
||||
$weightTrends = collect([(object)['month' => now()->format('F'), 'avg_weight' => 0]]);
|
||||
if ($selectedGender) {
|
||||
$weightQuery->where('cats.jenis_kelamin', $selectedGender);
|
||||
}
|
||||
|
||||
// 7. Recent Activity
|
||||
$recentTreatments = Treatment::with(['cat', 'pic'])->latest()->take(5)->get();
|
||||
if ($selectedStatus) {
|
||||
if (in_array(strtolower($selectedStatus), ['adult', 'dewasa'])) {
|
||||
$weightQuery->whereIn('cats.status', ['Adult', 'Dewasa']);
|
||||
} else {
|
||||
$weightQuery->where('cats.status', $selectedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
$individualWeightData = $weightQuery
|
||||
->orderBy('weight_histories.berat', 'desc')
|
||||
->get()
|
||||
->map(function ($cat) use ($now) {
|
||||
$cat->usia = $this->formatAge($cat->tanggal_lahir, $now);
|
||||
$cat->kategori_usia = $this->ageCategory($cat->tanggal_lahir, $cat->status, $now);
|
||||
return $cat;
|
||||
});
|
||||
|
||||
$weightSummary = [
|
||||
'overall' => $individualWeightData->avg('berat') ?? 0,
|
||||
'male' => $individualWeightData->where('jenis_kelamin', 'Jantan')->avg('berat') ?? 0,
|
||||
'female' => $individualWeightData->where('jenis_kelamin', 'Betina')->avg('berat') ?? 0,
|
||||
];
|
||||
|
||||
/*
|
||||
* Pemantauan berat badan.
|
||||
* Kategori umur dihitung dari tanggal lahir agar konsisten:
|
||||
* Kitten < 7 bulan, Junior 7-11 bulan, Dewasa >= 12 bulan.
|
||||
*/
|
||||
$allowedTrendMonths = [3, 6, 12, 24];
|
||||
$weightTrendMonths = (int) $request->get('weight_months', 6);
|
||||
if (!in_array($weightTrendMonths, $allowedTrendMonths, true)) {
|
||||
$weightTrendMonths = 6;
|
||||
}
|
||||
|
||||
// Filter tabel dapat menampilkan semua kategori, sedangkan grafik individual
|
||||
// selalu dibatasi pada satu kategori agar mudah dibaca.
|
||||
$allowedTableCategories = ['Semua', 'Kitten', 'Junior', 'Dewasa'];
|
||||
$weightTableCategory = $request->get('weight_category', 'Semua');
|
||||
if (!in_array($weightTableCategory, $allowedTableCategories, true)) {
|
||||
$weightTableCategory = 'Semua';
|
||||
}
|
||||
|
||||
$allowedTrendCategories = ['Kitten', 'Junior', 'Dewasa'];
|
||||
$defaultTrendCategory = $weightTableCategory !== 'Semua' ? $weightTableCategory : 'Kitten';
|
||||
$weightTrendCategory = $request->get('trend_category', $defaultTrendCategory);
|
||||
if (!in_array($weightTrendCategory, $allowedTrendCategories, true)) {
|
||||
$weightTrendCategory = 'Kitten';
|
||||
}
|
||||
|
||||
$trendStart = $now->copy()->subMonths($weightTrendMonths - 1)->startOfMonth();
|
||||
|
||||
$weightTrendRows = DB::table('weight_histories')
|
||||
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
|
||||
->select(
|
||||
'cats.id as cat_id',
|
||||
'cats.nama_kucing',
|
||||
'cats.jenis_kelamin',
|
||||
'cats.status',
|
||||
'cats.tanggal_lahir',
|
||||
'cats.foto',
|
||||
'weight_histories.berat',
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->whereNull('cats.deleted_at')
|
||||
->where(function ($q) { $q->whereNull('cats.cattery_status')->orWhere('cats.cattery_status', 'active'); })
|
||||
->whereDate('weight_histories.tanggal_timbang', '>=', $trendStart->toDateString())
|
||||
->orderBy('weight_histories.tanggal_timbang')
|
||||
->orderBy('cats.nama_kucing')
|
||||
->get()
|
||||
->map(function ($row) use ($now) {
|
||||
$row->kategori_usia = $this->ageCategory($row->tanggal_lahir, $row->status, $now);
|
||||
$date = Carbon::parse($row->tanggal_timbang);
|
||||
$row->tanggal_iso = $date->format('Y-m-d');
|
||||
$row->bulan_iso = $date->format('Y-m');
|
||||
return $row;
|
||||
});
|
||||
|
||||
// Sumbu grafik dibuat per bulan, bukan per tanggal timbang, supaya garis tidak terlalu padat.
|
||||
$categoryMonthKeys = collect();
|
||||
for ($cursor = $trendStart->copy(); $cursor->lte($now); $cursor->addMonth()) {
|
||||
$categoryMonthKeys->push($cursor->format('Y-m'));
|
||||
}
|
||||
|
||||
$categoryAverageTrend = [
|
||||
'labels' => $categoryMonthKeys,
|
||||
'series' => collect(['Kitten', 'Junior', 'Dewasa'])->map(function ($category) use ($categoryMonthKeys, $weightTrendRows) {
|
||||
$rows = $weightTrendRows->where('kategori_usia', $category);
|
||||
|
||||
return [
|
||||
'name' => $category,
|
||||
'data' => $categoryMonthKeys->map(function ($month) use ($rows) {
|
||||
// Ambil berat terakhir setiap kucing pada bulan tersebut, lalu hitung rata-ratanya.
|
||||
$latestPerCat = $rows
|
||||
->where('bulan_iso', $month)
|
||||
->groupBy('cat_id')
|
||||
->map(fn ($catRows) => $catRows->last());
|
||||
|
||||
$value = $latestPerCat->avg('berat');
|
||||
return $value === null ? null : round((float) $value, 2);
|
||||
})->values(),
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
|
||||
// Ringkasan berdasarkan berat terakhir setiap kucing pada rentang periode.
|
||||
$latestPerCatInTrend = $weightTrendRows
|
||||
->groupBy('cat_id')
|
||||
->map(fn ($rows) => $rows->last());
|
||||
|
||||
$weightCategorySummary = collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($latestPerCatInTrend) {
|
||||
$rows = $latestPerCatInTrend->where('kategori_usia', $category);
|
||||
return [$category => [
|
||||
'cats' => $rows->count(),
|
||||
'average' => round((float) ($rows->avg('berat') ?? 0), 2),
|
||||
'min' => $rows->isEmpty() ? 0 : round((float) $rows->min('berat'), 2),
|
||||
'max' => $rows->isEmpty() ? 0 : round((float) $rows->max('berat'), 2),
|
||||
]];
|
||||
});
|
||||
|
||||
// KPI bulan berjalan dibanding bulan sebelumnya.
|
||||
$currentMonthKey = $now->format('Y-m');
|
||||
$previousMonthKey = $now->copy()->subMonth()->format('Y-m');
|
||||
|
||||
$latestByCatMonth = $weightTrendRows
|
||||
->groupBy(fn ($row) => $row->cat_id . '|' . $row->bulan_iso)
|
||||
->map(fn ($rows) => $rows->last());
|
||||
|
||||
$currentMonthRows = $latestByCatMonth->where('bulan_iso', $currentMonthKey)->values();
|
||||
$previousMonthRows = $latestByCatMonth->where('bulan_iso', $previousMonthKey)->values();
|
||||
|
||||
$percentChange = static function (float $current, float $previous): float {
|
||||
if ($previous <= 0) {
|
||||
return $current > 0 ? 100.0 : 0.0;
|
||||
}
|
||||
return round((($current - $previous) / $previous) * 100, 1);
|
||||
};
|
||||
|
||||
$weightKpis = [
|
||||
'weighed' => [
|
||||
'value' => $currentMonthRows->pluck('cat_id')->unique()->count(),
|
||||
'change' => $percentChange(
|
||||
(float) $currentMonthRows->pluck('cat_id')->unique()->count(),
|
||||
(float) $previousMonthRows->pluck('cat_id')->unique()->count()
|
||||
),
|
||||
],
|
||||
'categories' => collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($currentMonthRows, $previousMonthRows, $latestPerCatInTrend, $percentChange) {
|
||||
$currentRows = $currentMonthRows->where('kategori_usia', $category);
|
||||
$previousRows = $previousMonthRows->where('kategori_usia', $category);
|
||||
|
||||
// Jika bulan berjalan belum memiliki data, tetap tampilkan posisi berat terakhir
|
||||
// tanpa mengarang persentase perubahan.
|
||||
$currentAverage = $currentRows->isNotEmpty()
|
||||
? (float) $currentRows->avg('berat')
|
||||
: (float) ($latestPerCatInTrend->where('kategori_usia', $category)->avg('berat') ?? 0);
|
||||
$previousAverage = (float) ($previousRows->avg('berat') ?? 0);
|
||||
|
||||
return [$category => [
|
||||
'average' => round($currentAverage, 2),
|
||||
'change' => $currentRows->isNotEmpty() && $previousRows->isNotEmpty()
|
||||
? $percentChange($currentAverage, $previousAverage)
|
||||
: 0.0,
|
||||
'has_comparison' => $currentRows->isNotEmpty() && $previousRows->isNotEmpty(),
|
||||
]];
|
||||
}),
|
||||
];
|
||||
|
||||
// Grafik tren kategori terpilih: maksimal 6 kucing dengan data terlengkap.
|
||||
$selectedCategoryGroups = $weightTrendRows
|
||||
->where('kategori_usia', $weightTrendCategory)
|
||||
->groupBy('cat_id')
|
||||
->sort(function ($a, $b) {
|
||||
$countComparison = $b->count() <=> $a->count();
|
||||
if ($countComparison !== 0) {
|
||||
return $countComparison;
|
||||
}
|
||||
|
||||
return strcmp((string) $b->last()->tanggal_iso, (string) $a->last()->tanggal_iso);
|
||||
})
|
||||
->take(6);
|
||||
|
||||
$weightTrendLabels = $categoryMonthKeys->values();
|
||||
$weightTrendSeries = $selectedCategoryGroups
|
||||
->map(function ($rows) use ($categoryMonthKeys) {
|
||||
$first = $rows->first();
|
||||
$monthlyValues = $rows->groupBy('bulan_iso')->map(fn ($monthRows) => round((float) $monthRows->last()->berat, 2));
|
||||
|
||||
return [
|
||||
'cat_id' => (int) $first->cat_id,
|
||||
'name' => $first->nama_kucing,
|
||||
'category' => $first->kategori_usia,
|
||||
'data' => $categoryMonthKeys->map(fn ($month) => $monthlyValues->get($month))->values(),
|
||||
'latest' => round((float) $rows->last()->berat, 2),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
// Tabel berat terbaru. Perubahan membandingkan dua catatan terakhir pada periode terpilih.
|
||||
$weightLatestTable = $weightTrendRows
|
||||
->groupBy('cat_id')
|
||||
->map(function ($rows) {
|
||||
$rows = $rows->sortBy('tanggal_timbang')->values();
|
||||
$latest = $rows->last();
|
||||
$previous = $rows->count() > 1 ? $rows->get($rows->count() - 2) : null;
|
||||
|
||||
return [
|
||||
'cat_id' => (int) $latest->cat_id,
|
||||
'name' => $latest->nama_kucing,
|
||||
'gender' => $latest->jenis_kelamin,
|
||||
'category' => $latest->kategori_usia,
|
||||
'photo' => $latest->foto,
|
||||
'latest' => round((float) $latest->berat, 2),
|
||||
'previous' => $previous ? round((float) $previous->berat, 2) : null,
|
||||
'change' => $previous ? round((float) $latest->berat - (float) $previous->berat, 2) : null,
|
||||
'date' => $latest->tanggal_iso,
|
||||
];
|
||||
})
|
||||
->when($weightTableCategory !== 'Semua', fn ($rows) => $rows->where('category', $weightTableCategory))
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
// Daftar kenaikan/penurunan berat berdasarkan kategori pada periode yang dipilih.
|
||||
$weightMovements = collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($weightLatestTable) {
|
||||
$rows = $weightLatestTable->where('category', $category)->whereNotNull('change');
|
||||
return [$category => [
|
||||
'gainers' => $rows->where('change', '>', 0)->sortByDesc('change')->take(5)->values(),
|
||||
'losers' => $rows->where('change', '<', 0)->sortBy('change')->take(5)->values(),
|
||||
]];
|
||||
});
|
||||
$weightPeriodLabel = $trendStart->translatedFormat('M Y') . ' – ' . $now->translatedFormat('M Y');
|
||||
|
||||
// Inventory
|
||||
$allExpiringItems = collect();
|
||||
$lowStock = collect();
|
||||
|
||||
$inventoryTypes = [
|
||||
'medicines' => ['model' => Medicine::class, 'name' => 'nama_obat'],
|
||||
'foods' => ['model' => Food::class, 'name' => 'nama_makanan'],
|
||||
'supplies' => ['model' => Supply::class, 'name' => 'nama_perlengkapan'],
|
||||
];
|
||||
|
||||
foreach ($inventoryTypes as $type => $config) {
|
||||
$modelClass = $config['model'];
|
||||
$nameColumn = $config['name'];
|
||||
|
||||
$modelClass::where('qty', '<', 5)
|
||||
->select("$nameColumn as name", 'qty', 'unit')
|
||||
->get()
|
||||
->each(function ($item) use ($lowStock, $type) {
|
||||
$item->url = route("inventory.$type.index", ['search' => $item->name]);
|
||||
$lowStock->push($item);
|
||||
});
|
||||
|
||||
if (Schema::hasColumn((new $modelClass)->getTable(), 'expired_at')) {
|
||||
$modelClass::whereNotNull('expired_at')
|
||||
->where('expired_at', '<=', $now->copy()->addDays(30))
|
||||
->get()
|
||||
->each(function ($item) use ($allExpiringItems, $now, $type, $nameColumn) {
|
||||
$expiredDate = Carbon::parse($item->expired_at);
|
||||
$allExpiringItems->push((object) [
|
||||
'name' => $item->$nameColumn,
|
||||
'qty' => $item->qty,
|
||||
'unit' => $item->unit,
|
||||
'expired_at' => $item->expired_at,
|
||||
'url' => route("inventory.$type.index", ['search' => $item->$nameColumn]),
|
||||
'formatted_date' => $expiredDate->format('d M Y'),
|
||||
'days_left' => (int) $now->copy()->startOfDay()->diffInDays($expiredDate->copy()->startOfDay(), false),
|
||||
'is_expired' => $expiredDate->isPast(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$totalLowStock = $lowStock->count();
|
||||
$sortedItems = $allExpiringItems->sortBy('days_left');
|
||||
$perPage = 5;
|
||||
$currentPage = $request->input('expiry_page', 1);
|
||||
$offset = ($currentPage * $perPage) - $perPage;
|
||||
|
||||
$expiringItems = new LengthAwarePaginator(
|
||||
$sortedItems->slice($offset, $perPage)->values(),
|
||||
$sortedItems->count(),
|
||||
$perPage,
|
||||
$currentPage,
|
||||
[
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
'pageName' => 'expiry_page',
|
||||
]
|
||||
);
|
||||
|
||||
$topDiseasesWeek = Treatment::select('diagnosa', DB::raw('count(*) as total'))
|
||||
->where('tanggal', '>=', $now->copy()->subWeek())
|
||||
->groupBy('diagnosa')
|
||||
->orderBy('total', 'desc')
|
||||
->get();
|
||||
|
||||
$topDiseasesMonth = Treatment::select('diagnosa', DB::raw('count(*) as total'))
|
||||
->where('tanggal', '>=', $now->copy()->subMonth())
|
||||
->groupBy('diagnosa')
|
||||
->orderBy('total', 'desc')
|
||||
->get();
|
||||
|
||||
$recentTreatments = Treatment::with(['cat'])->latest()->take(5)->get();
|
||||
|
||||
return view('pages.dashboard.index', compact(
|
||||
'stats',
|
||||
'upcomingBirths',
|
||||
'medicalAlerts',
|
||||
'overdueAlerts',
|
||||
'individualWeightData',
|
||||
'weightSummary',
|
||||
'weightTrendLabels',
|
||||
'weightTrendSeries',
|
||||
'categoryAverageTrend',
|
||||
'weightCategorySummary',
|
||||
'weightKpis',
|
||||
'weightLatestTable',
|
||||
'weightTableCategory',
|
||||
'weightTrendCategory',
|
||||
'weightTrendMonths',
|
||||
'weightMovements',
|
||||
'weightPeriodLabel',
|
||||
'lowStock',
|
||||
'totalLowStock',
|
||||
'chartData',
|
||||
'weightTrends',
|
||||
'expiringItems',
|
||||
'selectedMonth',
|
||||
'selectedYear',
|
||||
'selectedGender',
|
||||
'selectedStatus',
|
||||
'topDiseasesWeek',
|
||||
'topDiseasesMonth',
|
||||
'recentTreatments'
|
||||
));
|
||||
}
|
||||
|
||||
private function ageCategory($birthDate, ?string $storedStatus, Carbon $referenceDate): string
|
||||
{
|
||||
if ($birthDate) {
|
||||
$birth = Carbon::parse($birthDate);
|
||||
$months = $birth->diffInMonths($referenceDate);
|
||||
|
||||
if ($months < 7) {
|
||||
return 'Kitten';
|
||||
}
|
||||
|
||||
if ($months < 12) {
|
||||
return 'Junior';
|
||||
}
|
||||
|
||||
return 'Dewasa';
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) $storedStatus));
|
||||
return match ($status) {
|
||||
'kitten' => 'Kitten',
|
||||
'junior' => 'Junior',
|
||||
'adult', 'dewasa' => 'Dewasa',
|
||||
default => 'Dewasa',
|
||||
};
|
||||
}
|
||||
|
||||
private function formatAge($birthDate, Carbon $referenceDate): string
|
||||
{
|
||||
if (!$birthDate) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$diff = Carbon::parse($birthDate)->diff($referenceDate);
|
||||
$parts = [];
|
||||
|
||||
if ($diff->y > 0) {
|
||||
$parts[] = $diff->y . ' th';
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
$parts[] = $diff->m . ' bln';
|
||||
}
|
||||
if ($diff->y === 0 && $diff->d > 0) {
|
||||
$parts[] = $diff->d . ' hr';
|
||||
}
|
||||
|
||||
return $parts ? implode(' ', $parts) : '0 hr';
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class FoodController extends Controller
|
||||
{
|
||||
@@ -19,16 +20,67 @@ class FoodController extends Controller
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses
|
||||
Gate::authorize('check-access', ['foods', 'read']);
|
||||
|
||||
$now = now();
|
||||
$query = Food::with('category');
|
||||
|
||||
// 1. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan makanan yang sudah kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. SEARCH GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_makanan', 'like', '%' . $request->search . '%');
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_makanan', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$foods = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.foods.index', compact('foods'));
|
||||
}
|
||||
// 3. SORTING DINAMIS (Menambahkan kembali expired_at)
|
||||
$allowedSorts = ['nama_makanan', 'qty', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_makanan';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$foods = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 4. STATISTIK (Komprehensif)
|
||||
$totalStock = Food::sum('qty');
|
||||
$lowStockCount = Food::where('qty', '<=', 5)->count();
|
||||
|
||||
// Hitung item yang expired (Wajib ada kolom expired_at di database)
|
||||
$expiredCount = Food::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$totalItems = Food::count();
|
||||
|
||||
return view('pages.inventory.foods.index', compact(
|
||||
'foods',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount',
|
||||
'totalItems'
|
||||
));
|
||||
}
|
||||
/**
|
||||
* Tampilkan form tambah.
|
||||
*/
|
||||
|
||||
@@ -3,18 +3,25 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\{Matting, User, Cat};
|
||||
use App\Models\{Matting, User, Cat, Setting};
|
||||
use App\Exports\MattingExport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class MattingReportController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 1. Eager Loading Lengkap (Sire, Dam, PIC, dan Detail Anak)
|
||||
// PERBAIKAN: Proteksi akses menggunakan matriks permission
|
||||
if (Gate::allows('check-access', ['matting_reports', 'read'])) {
|
||||
// Lanjut proses
|
||||
}
|
||||
|
||||
// 1. Eager Loading Lengkap (Menjamin Sire/Dam/PIC/Kittens terload dengan efisien)
|
||||
$query = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->withCount('kittens');
|
||||
|
||||
// 2. Filter Nama Indukan (Sire atau Dam)
|
||||
if ($request->filled('parent_name')) {
|
||||
$name = $request->parent_name;
|
||||
@@ -33,13 +40,13 @@ $query = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->withCount('kitten
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Filter Registry (ICA, CFA, WCF, dll)
|
||||
// 4. PERBAIKAN: Filter Registry (Mendukung Multiple Choice ICA, CFA, dll)
|
||||
if ($request->filled('registry')) {
|
||||
$reg = $request->registry;
|
||||
$reg = is_array($request->registry) ? $request->registry : [$request->registry];
|
||||
$query->where(function($q) use ($reg) {
|
||||
$q->where('registry', $reg)
|
||||
->orWhereHas('sire', fn($sq) => $sq->where('registry', $reg))
|
||||
->orWhereHas('dam', fn($sq) => $sq->where('registry', $reg));
|
||||
$q->whereIn('registry', $reg) // Cek di table matting
|
||||
->orWhereHas('sire', fn($sq) => $sq->whereIn('registry', $reg)) // Cek di table cats (sire)
|
||||
->orWhereHas('dam', fn($sq) => $sq->whereIn('registry', $reg)); // Cek di table cats (dam)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +73,7 @@ $query = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->withCount('kitten
|
||||
// 8. Eksekusi dengan Pagination
|
||||
$mattings = $query->latest()->paginate(15)->withQueryString();
|
||||
|
||||
// 9. Data Pendukung UI (Agar Dropdown di Blade berfungsi)
|
||||
// 9. Data Pendukung UI (Mengambil data unik dari database agar dropdown akurat)
|
||||
$breeds = Cat::distinct()->whereNotNull('ras')->pluck('ras');
|
||||
$registries = ['ICA', 'CFA', 'WCF', 'FFF', 'LOOF'];
|
||||
$statuses = ['Breeding', 'Hamil', 'Teradopsi', 'RIP'];
|
||||
@@ -74,7 +81,6 @@ $mattings = $query->latest()->paginate(15)->withQueryString();
|
||||
return view('pages.reports.matting.index', compact(
|
||||
'mattings', 'breeds', 'registries', 'statuses'
|
||||
));
|
||||
|
||||
}
|
||||
|
||||
protected function processExport($data, $format)
|
||||
@@ -85,28 +91,28 @@ $mattings = $query->latest()->paginate(15)->withQueryString();
|
||||
return Excel::download(new MattingExport($data), "{$fileName}.xlsx");
|
||||
}
|
||||
|
||||
// Penyempurnaan PDF: Tambahkan opsi font dan akses file lokal (untuk logo)
|
||||
// Penyempurnaan PDF: Tambahkan opsi font dan akses file lokal
|
||||
$pdf = Pdf::loadView('pages.reports.matting.pdf', [
|
||||
'data' => $data,
|
||||
// Kirim data setting jika ingin menampilkan logo/branding dinamis di PDF
|
||||
'setting' => \App\Models\Setting::pluck('value', 'key')
|
||||
'setting' => Setting::pluck('value', 'key')
|
||||
]);
|
||||
|
||||
return $pdf->setPaper('a4', 'landscape')
|
||||
->setOption([
|
||||
'tempDir' => public_path(),
|
||||
'chroot' => public_path(),
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => true, // Penting agar gambar dari storage muncul di PDF
|
||||
])
|
||||
->setWarnings(false) // Menghindari PDF korup karena error kecil di blade
|
||||
->setWarnings(false)
|
||||
->download("{$fileName}.pdf");
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
// Eager loading mendalam untuk silsilah dan data anak
|
||||
// Eager loading mendalam: load sire/dam (untuk foto), pic (registrar), dan kittens
|
||||
$matting = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->findOrFail($id);
|
||||
|
||||
return view('pages.reports.matting.show', compact('matting'));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,15 +3,12 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MedicalDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Tren Penyakit Bulanan (Data 6 Bulan Terakhir)
|
||||
$monthlyTrends = Treatment::select(
|
||||
DB::raw('MONTHNAME(tanggal) as month'),
|
||||
DB::raw('COUNT(*) as total')
|
||||
@@ -21,20 +18,28 @@ class MedicalDashboardController extends Controller
|
||||
->orderBy(DB::raw('MONTH(tanggal)'))
|
||||
->get();
|
||||
|
||||
// 2. Daftar Obat Paling Sering Digunakan (Top 5)
|
||||
$topMedicines = Treatment::select('nama_obat', DB::raw('SUM(qty_used) as total_used'))
|
||||
->groupBy('nama_obat')
|
||||
// treatments memakai medicine_id. Nama obat diambil dari tabel medicines.
|
||||
$topObats = Treatment::query()
|
||||
->join('medicines', 'treatments.medicine_id', '=', 'medicines.id')
|
||||
->select(
|
||||
'medicines.id',
|
||||
'medicines.nama_obat',
|
||||
DB::raw('SUM(treatments.qty_used) as total_used')
|
||||
)
|
||||
->whereNotNull('treatments.medicine_id')
|
||||
->groupBy('medicines.id', 'medicines.nama_obat')
|
||||
->orderByDesc('total_used')
|
||||
->take(5)
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// 3. Keluhan/Penyakit yang Paling Sering Muncul
|
||||
$topComplaints = Treatment::select('keluhan', DB::raw('COUNT(*) as total'))
|
||||
->whereNotNull('keluhan')
|
||||
->where('keluhan', '!=', '')
|
||||
->groupBy('keluhan')
|
||||
->orderByDesc('total')
|
||||
->take(5)
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
return view('pages.dashboard.medical', compact('monthlyTrends', 'topMedicines', 'topComplaints'));
|
||||
return view('pages.dashboard.medical', compact('monthlyTrends', 'topObats', 'topComplaints'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cat;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\MedicalRecord;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Exception;
|
||||
|
||||
class MedicalRecordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Simpan rekam medis individu (dari halaman show cat)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Tambahan Validasi
|
||||
$request->validate([
|
||||
'cat_id' => 'required|exists:cats,id',
|
||||
'type' => 'required|string',
|
||||
'date_given' => 'required|date',
|
||||
'next_schedule' => 'nullable|date|after_or_equal:date_given',
|
||||
'medicines' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request) {
|
||||
$type = $request->type;
|
||||
$medicines = $request->input('medicines', []);
|
||||
|
||||
if ($type === 'Grooming') {
|
||||
$this->createRecord($request->cat_id, $type, 'Routine Grooming', null, 'SESSION', $request);
|
||||
} else {
|
||||
foreach ($medicines as $med) {
|
||||
if (empty($med['name'])) continue;
|
||||
|
||||
$doseValue = $this->parseFraction($med['dosage']);
|
||||
|
||||
// Pengecekan Stok Sebelum Potong
|
||||
$item = Medicine::where('nama_obat', $med['name'])->first();
|
||||
if ($item) {
|
||||
if ($item->qty < $doseValue) {
|
||||
throw new Exception("Stok obat '{$med['name']}' tidak mencukupi. Sisa: {$item->qty}");
|
||||
}
|
||||
$item->decrement('qty', $doseValue);
|
||||
}
|
||||
|
||||
$this->createRecord($request->cat_id, $type, $med['name'], $med['dosage'], $med['unit'], $request);
|
||||
}
|
||||
}
|
||||
|
||||
// Update pengingat di tabel cats
|
||||
$this->updateCatReminder($request->cat_id, $type, $request->next_schedule);
|
||||
|
||||
return back()->with('success', 'Medical Ledger berhasil dicatat dan jadwal otomatis diperbarui.');
|
||||
});
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal mencatat rekam medis: ' . $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form Bulk Treatment
|
||||
*/
|
||||
/**
|
||||
* Tampilkan form Bulk Treatment
|
||||
*/
|
||||
public function bulkCreate()
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Mengambil kucing yang Sehat (2) maupun yang sedang Treatment (1)
|
||||
$cats = Cat::whereIn('treatment_status', [1, 2])
|
||||
->orderBy('nama_kucing', 'asc')
|
||||
->get();
|
||||
|
||||
// Mengambil obat dengan stok yang valid (qty > 0)
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
|
||||
|
||||
return view('pages.cats.medical-bulk', compact('cats', 'availableMedicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan Bulk Treatment (Massal)
|
||||
*/
|
||||
public function bulkStore(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
'cat_ids' => 'required|array|min:1',
|
||||
'type' => 'required',
|
||||
'date_given' => 'required|date',
|
||||
'next_schedule' => 'nullable|date|after_or_equal:date_given',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request) {
|
||||
$catIds = $request->cat_ids;
|
||||
$medicines = $request->input('medicines', []);
|
||||
$totalCats = count($catIds);
|
||||
|
||||
foreach ($catIds as $catId) {
|
||||
if ($request->type === 'Grooming') {
|
||||
$this->createRecord($catId, $request->type, 'Routine Grooming', null, 'SESSION', $request);
|
||||
} else {
|
||||
foreach ($medicines as $med) {
|
||||
if (empty($med['name'])) continue;
|
||||
|
||||
$doseValue = $this->parseFraction($med['dosage']);
|
||||
|
||||
// Validasi stok untuk bulk (kebutuhan = dosis * jumlah kucing)
|
||||
$item = Medicine::where('nama_obat', $med['name'])->first();
|
||||
if ($item) {
|
||||
if ($item->qty < $doseValue) {
|
||||
throw new Exception("Stok '{$med['name']}' tidak cukup untuk pemrosesan massal.");
|
||||
}
|
||||
$item->decrement('qty', $doseValue);
|
||||
}
|
||||
|
||||
$this->createRecord($catId, $request->type, $med['name'], $med['dosage'], $med['unit'], $request);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateCatReminder($catId, $request->type, $request->next_schedule);
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "Bulk Treatment berhasil diterapkan pada $totalCats kucing.");
|
||||
});
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal memproses bulk treatment: ' . $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Simpan data ke tabel medical_records
|
||||
*/
|
||||
/**
|
||||
* Helper: Simpan data ke tabel medical_records
|
||||
*/
|
||||
private function createRecord($catId, $type, $name, $dosage, $unit, $request)
|
||||
{
|
||||
return MedicalRecord::create([
|
||||
'cat_id' => $catId,
|
||||
'user_id' => auth()->id(), // Ini adalah kolom yang benar sesuai migration kita
|
||||
'type' => $type,
|
||||
'treatment_name' => $name,
|
||||
'dosage' => $this->parseFraction($dosage),
|
||||
'unit' => $unit,
|
||||
'date_given' => $request->date_given,
|
||||
'next_schedule' => $request->next_schedule,
|
||||
'notes' => $request->notes
|
||||
// HAPUS baris 'performed_by' di sini
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Update kolom jadwal di tabel cats
|
||||
*/
|
||||
private function updateCatReminder($catId, $type, $nextDate)
|
||||
{
|
||||
if (!$nextDate) return;
|
||||
|
||||
$mapping = [
|
||||
'Vaccine' => 'jadwal_vaksin',
|
||||
'Deworming' => 'jadwal_obat_cacing',
|
||||
'Flea Treatment' => 'jadwal_obat_kutu',
|
||||
'Grooming' => 'jadwal_grooming',
|
||||
];
|
||||
|
||||
if (isset($mapping[$type])) {
|
||||
Cat::where('id', $catId)->update([$mapping[$type] => $nextDate]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Konversi pecahan (1/2) ke desimal (0.5)
|
||||
*/
|
||||
private function parseFraction($fraction)
|
||||
{
|
||||
if (is_null($fraction)) return 0;
|
||||
|
||||
$fraction = str_replace(' ', '', $fraction); // Hapus spasi jika ada
|
||||
|
||||
if (str_contains($fraction, '/')) {
|
||||
list($numerator, $denominator) = explode('/', $fraction);
|
||||
return $denominator > 0 ? (float)$numerator / (float)$denominator : 0;
|
||||
}
|
||||
return (float)$fraction;
|
||||
}
|
||||
|
||||
public function create(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
|
||||
|
||||
|
||||
return view('pages.cats.medical-ledger-create', compact('cat', 'availableMedicines'));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Imports\MedicineImport;
|
||||
use App\Exports\MedicineTemplateExport;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class MedicineController extends Controller
|
||||
{
|
||||
@@ -21,17 +22,73 @@ class MedicineController extends Controller
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses
|
||||
Gate::authorize('check-access', ['medicines', 'read']);
|
||||
|
||||
$now = now();
|
||||
$query = Medicine::query();
|
||||
|
||||
// 1. FILTER KHUSUS (DARI DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan yang sudah lewat tanggal kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang akan kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis (<= 5)
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. SEARCH GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_obat', 'like', '%' . $request->search . '%');
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_obat', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$medicines = $query->latest()->paginate(10);
|
||||
|
||||
return view('pages.inventory.medicines.index', compact('medicines'));
|
||||
// 3. FILTER KATEGORI (Opsional jika ada dropdown di UI)
|
||||
if ($request->filled('category')) {
|
||||
$query->where('kategori', $request->category);
|
||||
}
|
||||
|
||||
// 4. SORTING DINAMIS
|
||||
// Gunakan 'expired_at' sebagai default sorting jika dari Dashboard 'expired'
|
||||
$allowedSorts = ['nama_obat', 'kategori', 'qty', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_obat';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$medicines = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK BOX (Selalu hitung total keseluruhan, bukan hasil filter)
|
||||
$totalStock = Medicine::sum('qty');
|
||||
$lowStockCount = Medicine::where('qty', '<=', 5)->count();
|
||||
|
||||
// Menghitung barang yang sudah expired (merah)
|
||||
$expiredCount = Medicine::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$categories = Medicine::distinct()->whereNotNull('kategori')->pluck('kategori');
|
||||
|
||||
return view('pages.inventory.medicines.index', compact(
|
||||
'medicines',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount', // Tambahan statistik baru
|
||||
'categories'
|
||||
));
|
||||
}
|
||||
/**
|
||||
* Tampilkan form tambah obat.
|
||||
*/
|
||||
|
||||
@@ -8,14 +8,12 @@ use App\Exports\UnifiedReportExport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Gate; // WAJIB DIIMPORT untuk sistem permission
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// PERBAIKAN UTAMA: Proteksi menggunakan matriks permission slug 'reports'
|
||||
// Ini menggantikan pengecekan manual 'admin'
|
||||
Gate::authorize('check-access', ['reports', 'read']);
|
||||
|
||||
$module = $request->get('module', 'cats');
|
||||
@@ -78,6 +76,18 @@ class ReportController extends Controller
|
||||
{
|
||||
$status = $request->status;
|
||||
|
||||
// TAMBAHAN: Logika Filter untuk Modul Cats (Sehat/Sakit)
|
||||
// PERBAIKAN 1: Gunakan $request->filled('status') sesuai dengan <select name="status"> di Blade
|
||||
if ($module === 'cats' && $request->filled('status')) {
|
||||
|
||||
// PERBAIKAN 2: Tangkap semua kemungkinan value (Bahasa Indonesia, Inggris, maupun angka)
|
||||
match(strtolower($status)) {
|
||||
'sakit', 'treatment', '1' => $query->where('treatment_status', 1),
|
||||
'sehat', 'healthy', '2' => $query->where('treatment_status', 2),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
|
||||
// Modul Maintenance
|
||||
if (in_array($module, ['vaccine', 'deworming', 'flea', 'grooming']) && $request->filled('status')) {
|
||||
$dateCol = $this->getDateColumn($module);
|
||||
@@ -124,7 +134,6 @@ class ReportController extends Controller
|
||||
|
||||
protected function processExport($data, $module, $format)
|
||||
{
|
||||
// Proteksi Tambahan: Pastikan user punya izin Export (opsional, bisa disatukan dengan Read)
|
||||
$fileName = "Report_{$module}_" . now()->format('Ymd_His');
|
||||
if ($format === 'xlsx') {
|
||||
return Excel::download(new UnifiedReportExport($data, $module), "{$fileName}.xlsx");
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
@@ -13,6 +14,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'read']);
|
||||
$roles = Role::all();
|
||||
// Menambahkan prefix 'pages.' sesuai struktur folder Anda
|
||||
return view('pages.admin.roles.index', compact('roles'));
|
||||
@@ -20,12 +22,14 @@ class RoleController extends Controller
|
||||
|
||||
public function create()
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'create']);
|
||||
// Mengarahkan ke folder pages sesuai standarisasi Anda
|
||||
return view('pages.admin.roles.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'create']);
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'slug' => 'required|string|max:255|unique:roles,slug',
|
||||
@@ -53,6 +57,7 @@ class RoleController extends Controller
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'update']);
|
||||
$role = Role::with('permissions')->findOrFail($id);
|
||||
|
||||
/**
|
||||
@@ -94,6 +99,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'update']);
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
DB::transaction(function () use ($request, $role) {
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Role;
|
||||
use App\Helpers\WhatsappHelper;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
|
||||
class SettingController extends Controller
|
||||
@@ -23,11 +26,15 @@ class SettingController extends Controller
|
||||
'app_slogan',
|
||||
'app_version', // Tambahan baru
|
||||
'app_address',
|
||||
'wa_number'
|
||||
'wa_number',
|
||||
'notif_hpl_role_id',
|
||||
'notif_inventory_role_id',
|
||||
'notif_weight_role_id'
|
||||
];
|
||||
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['settings', 'read']);
|
||||
$settings = Setting::pluck('value', 'key')->all();
|
||||
|
||||
// Tambahkan default jika version belum ada
|
||||
@@ -44,11 +51,13 @@ class SettingController extends Controller
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
return view('pages.admin.settings', compact('settings', 'waStatus', 'emailStatus', 'logs'));
|
||||
$roles = Role::orderBy('name')->get(['id','name']);
|
||||
return view('pages.admin.settings', compact('settings', 'waStatus', 'emailStatus', 'logs', 'roles'));
|
||||
}
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['settings', 'update']);
|
||||
$request->validate([
|
||||
'app_logo' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
|
||||
'app_favicon' => 'nullable|image|mimes:png,jpg,ico|max:512',
|
||||
@@ -73,7 +82,21 @@ class SettingController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('UPDATED_SETTINGS', null, "Memperbarui identitas sistem ke versi " . $request->app_version);
|
||||
foreach (['notif_hpl_wa','notif_inventory_expiry_wa','notif_weight_stale_wa','notif_wa_status'] as $notificationKey) {
|
||||
Setting::updateOrCreate(
|
||||
['key' => $notificationKey],
|
||||
['value' => $request->boolean($notificationKey) ? 'active' : 'inactive', 'group' => 'notification']
|
||||
);
|
||||
}
|
||||
|
||||
foreach (['notif_hpl_role_id','notif_inventory_role_id','notif_weight_role_id'] as $roleKey) {
|
||||
if ($request->filled($roleKey)) {
|
||||
$request->validate([$roleKey => 'exists:roles,id']);
|
||||
Setting::updateOrCreate(['key' => $roleKey], ['value' => $request->input($roleKey), 'group' => 'notification']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logAction('UPDATED_SETTINGS', null, "Memperbarui identitas dan notifikasi sistem ke versi " . $request->app_version);
|
||||
|
||||
return back()->with('success', 'Konfigurasi ' . $request->app_name . ' berhasil diperbarui!');
|
||||
}
|
||||
@@ -101,21 +124,12 @@ class SettingController extends Controller
|
||||
|
||||
private function checkWhatsAppService()
|
||||
{
|
||||
// Gunakan cache atau timeout pendek agar page load tidak berat
|
||||
try {
|
||||
$response = Http::timeout(3)->withHeaders([
|
||||
'Authorization' => config('services.fonnte.token') ?? env('FONNTE_TOKEN'),
|
||||
])->post('https://api.fonnte.com/device');
|
||||
|
||||
$data = $response->json();
|
||||
$provider = strtoupper((string) config('services.whatsapp.provider', 'fonnte'));
|
||||
return [
|
||||
'active' => ($data['status'] ?? false) == true,
|
||||
'quota' => $data['quota'] ?? 0,
|
||||
'message' => ($data['status'] ?? false) ? 'API Terhubung' : ($data['reason'] ?? 'Perangkat tidak aktif')
|
||||
'active' => WhatsappHelper::configured(),
|
||||
'quota' => null,
|
||||
'message' => WhatsappHelper::configured() ? "{$provider} dikonfigurasi" : "{$provider} belum dikonfigurasi",
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['active' => false, 'quota' => 0, 'message' => 'Layanan WA Offline'];
|
||||
}
|
||||
}
|
||||
|
||||
private function checkEmailService()
|
||||
|
||||
@@ -9,6 +9,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SupplyController extends Controller
|
||||
{
|
||||
@@ -19,14 +21,70 @@ class SupplyController extends Controller
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// 1. Proteksi Akses (Sesuai Matriks Role Management)
|
||||
Gate::authorize('check-access', ['supplies', 'read']);
|
||||
|
||||
$now = now();
|
||||
$query = Supply::with('category');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_perlengkapan', 'like', '%' . $request->search . '%');
|
||||
// 2. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan perlengkapan yang sudah kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
}
|
||||
|
||||
$supplies = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.supplies.index', compact('supplies'));
|
||||
// 3. SEARCH GLOBAL (Mencari di Nama, Kategori, dan Unit)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_perlengkapan', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%")
|
||||
->orWhereHas('category', function($sub) use ($search) {
|
||||
$sub->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. LOGIKA SORTING DINAMIS (Menambahkan expired_at dalam daftar sort)
|
||||
$allowedSorts = ['nama_perlengkapan', 'qty', 'kategori', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_perlengkapan';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// Eksekusi Query dengan Pagination
|
||||
$supplies = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK RINGKAS (Komprehensif)
|
||||
$totalStock = Supply::sum('qty');
|
||||
$lowStockCount = Supply::where('qty', '<=', 5)->count();
|
||||
|
||||
// Hitung item yang expired (Wajib ada kolom expired_at di database)
|
||||
$expiredCount = Supply::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$totalItems = Supply::count();
|
||||
|
||||
return view('pages.inventory.supplies.index', compact(
|
||||
'supplies',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount', // Variabel statistik baru
|
||||
'totalItems'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,22 +163,39 @@ class SupplyController extends Controller
|
||||
*/
|
||||
public function destroy(Supply $supply): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
// 1. OTORISASI DINAMIS (Inilah perbaikan untuk akses Supervisor)
|
||||
// Menggunakan Gate 'check-access' agar sinkron dengan Role Management Anda
|
||||
Gate::authorize('check-access', ['supplies', 'delete']);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$supplyName = $supply->nama_perlengkapan;
|
||||
|
||||
if ($supply->foto_produk) {
|
||||
// 2. PEMBERSIHAN FILE (STORAGE)
|
||||
if ($supply->foto_produk && Storage::disk('public')->exists($supply->foto_produk)) {
|
||||
Storage::disk('public')->delete($supply->foto_produk);
|
||||
}
|
||||
|
||||
// PENTING: Log dilakukan SEBELUM delete agar ID tidak null
|
||||
$this->logAction('DELETED_SUPPLY', $supply, "Menghapus perlengkapan: {$supplyName}");
|
||||
// 3. AUDIT TRAIL
|
||||
// Dicatat sebelum delete agar konteks data $supply masih tersedia secara lengkap
|
||||
if (method_exists($this, 'logAction')) {
|
||||
$this->logAction('DELETE', 'Inventory', "Menghapus perlengkapan: {$supplyName}");
|
||||
}
|
||||
|
||||
// 4. EKSEKUSI HAPUS
|
||||
$supply->delete();
|
||||
|
||||
// FIX: Menggunakan .index
|
||||
return redirect()->route('inventory.supplies.index')->with('success', 'Data perlengkapan berhasil dihapus.');
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('inventory.supplies.index')
|
||||
->with('success', "Perlengkapan '{$supplyName}' berhasil dihapus dari sistem.");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
// Reporting error jika terjadi kegagalan sistem
|
||||
return back()->with('error', 'Gagal menghapus data. Silakan coba lagi atau hubungi IT.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Cat;
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\TreatmentDetail; // Menambahkan model Detail
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -18,19 +19,55 @@ class TreatmentController extends Controller
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Menampilkan daftar rekam medis dengan optimasi Eager Loading.
|
||||
* Menampilkan daftar rekam medis dengan optimasi Eager Loading & Filtering.
|
||||
*/
|
||||
public function index(): View
|
||||
public function index(Request $request): View|\Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
{
|
||||
$treatments = Treatment::with([
|
||||
'cat:id,nama_kucing,foto',
|
||||
'medicine:id,nama_obat,unit',
|
||||
// 1. QUERY DASAR DENGAN OPTIMASI EAGER LOADING
|
||||
$query = Treatment::with([
|
||||
'cat:id,nama_kucing,foto,treatment_status',
|
||||
'details.medicine:id,nama_obat,unit',
|
||||
'user:id,name'
|
||||
])
|
||||
->latest('tanggal')
|
||||
->paginate(15);
|
||||
->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', 1);
|
||||
});
|
||||
|
||||
return view('pages.treatments.index', compact('treatments'));
|
||||
// 2. LOGIKA FILTERING DINAMIS
|
||||
if ($request->filled('search')) {
|
||||
$query->whereHas('cat', function($q) use ($request) {
|
||||
$q->where('nama_kucing', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('pic_id')) {
|
||||
$query->where('user_id', $request->pic_id);
|
||||
}
|
||||
|
||||
if ($request->filled('start_date') && $request->filled('end_date')) {
|
||||
$query->whereBetween('tanggal', [$request->start_date, $request->end_date]);
|
||||
}
|
||||
|
||||
// 3. LOGIKA EXPORT (Pastikan blok IF ditutup dengan benar)
|
||||
if ($request->filled('export')) {
|
||||
$exportData = new \App\Exports\TreatmentExport($request);
|
||||
$fileName = 'Report_Active_Treatment_' . now()->format('Ymd_His');
|
||||
|
||||
if ($request->export === 'excel') {
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.xlsx");
|
||||
}
|
||||
|
||||
if ($request->export === 'pdf') {
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.pdf", \Maatwebsite\Excel\Excel::DOMPDF);
|
||||
}
|
||||
} // <--- Pastikan kurung ini menutup blok export, bukan menutup fungsi index
|
||||
|
||||
// 4. EKSEKUSI DATA UNTUK VIEW (Line 67)
|
||||
$treatments = $query->latest('tanggal')->paginate(20)->withQueryString();
|
||||
|
||||
$pics = \App\Models\User::select('id', 'name')->orderBy('name')->get();
|
||||
|
||||
return view('pages.treatments.index', compact('treatments', 'pics'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,147 +75,258 @@ class TreatmentController extends Controller
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$selectedCat = $request->filled('cat_id') ? Cat::findOrFail($request->cat_id) : null;
|
||||
$selectedCat = null;
|
||||
if ($request->filled('cat_id')) {
|
||||
$selectedCat = Cat::select('id', 'nama_kucing', 'ras')->findOrFail($request->cat_id);
|
||||
$cats = collect([$selectedCat]);
|
||||
} else {
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::where('qty', '>', 0)->get();
|
||||
}
|
||||
|
||||
// Menampilkan semua obat (termasuk stok 0 untuk kebutuhan edit/view,
|
||||
// namun di filter di frontend untuk input baru)
|
||||
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
|
||||
|
||||
return view('pages.treatments.create', compact('selectedCat', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan rekam medis baru & potong stok (mendukung desimal).
|
||||
* Simpan rekam medis baru & potong stok (Mendukung Multi-Obat).
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, &$validated) {
|
||||
$medicine = Medicine::findOrFail($request->medicine_id);
|
||||
|
||||
// Validasi kecukupan stok desimal
|
||||
if ($medicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty} {$medicine->unit}).");
|
||||
}
|
||||
|
||||
$fotoPath = null;
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// Otomatisasi data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $medicine->nama_obat;
|
||||
try {
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($request, $fotoPath) {
|
||||
|
||||
$treatment = Treatment::create($validated);
|
||||
$userId = auth()->id() ?? 1;
|
||||
$firstTreatment = null;
|
||||
|
||||
// Potong stok (decrement mendukung nilai float/decimal)
|
||||
$medicine->decrement('qty', $request->qty_used);
|
||||
if ($request->filled('items')) {
|
||||
foreach ($request->items as $item) {
|
||||
$medicine = \App\Models\Medicine::findOrFail($item['medicine_id']);
|
||||
|
||||
$this->logAction('TREATED', $treatment, "Mencatat pengobatan oleh " . auth()->user()->name);
|
||||
});
|
||||
if ($medicine->qty < $item['qty_used']) {
|
||||
throw new \Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty}).");
|
||||
}
|
||||
|
||||
$treatment = \App\Models\Treatment::create([
|
||||
'cat_id' => $request->cat_id,
|
||||
'user_id' => $userId,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
|
||||
// Langsung masukkan diagnosa tanpa embel-embel
|
||||
'diagnosa' => $request->diagnosa,
|
||||
|
||||
'foto_bukti' => $fotoPath,
|
||||
'status_perawatan' => 1,
|
||||
'medicine_id' => $item['medicine_id'],
|
||||
'qty_used' => $item['qty_used'],
|
||||
]);
|
||||
|
||||
if (!$firstTreatment) {
|
||||
$firstTreatment = $treatment;
|
||||
}
|
||||
|
||||
$medicine->decrement('qty', $item['qty_used']);
|
||||
}
|
||||
}
|
||||
|
||||
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
|
||||
|
||||
if ($firstTreatment) {
|
||||
$this->logAction('TREATED', $firstTreatment, "Mencatat pengobatan klinis untuk " . $firstTreatment->cat->nama_kucing);
|
||||
}
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis berhasil dicatat dengan presisi.');
|
||||
->with('success', 'Rekam medis klinis berhasil dicatat dan stok dipotong otomatis.');
|
||||
});
|
||||
|
||||
} catch (Exception $e) {
|
||||
if (isset($validated['foto_bukti'])) {
|
||||
Storage::disk('public')->delete($validated['foto_bukti']);
|
||||
} catch (\Exception $e) {
|
||||
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
|
||||
}
|
||||
return back()->withInput()->with('error', 'Gagal menyimpan: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Menampilkan detail spesifik satu rekam medis.
|
||||
*/
|
||||
public function show(Treatment $treatment): View
|
||||
{
|
||||
// Load details untuk menampilkan list obat
|
||||
$treatment->load(['cat', 'medicine', 'user', 'details.medicine']);
|
||||
return view('pages.treatments.show', compact('treatment'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Form edit rekam medis.
|
||||
*/
|
||||
public function edit(Treatment $treatment): View
|
||||
{
|
||||
$treatment->load('details');
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::all();
|
||||
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
|
||||
|
||||
return view('pages.treatments.edit', compact('treatment', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rekam medis & sinkronisasi ulang stok desimal.
|
||||
* Update rekam medis & sinkronisasi ulang stok desimal (Multi-Obat).
|
||||
*/
|
||||
public function update(Request $request, Treatment $treatment): RedirectResponse
|
||||
public function update(Request $request, Treatment $treatment)
|
||||
{
|
||||
// 1. Validasi Input
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $treatment, &$validated) {
|
||||
// 1. Kembalikan stok lama (Desimal Revert)
|
||||
$oldMedicine = Medicine::find($treatment->medicine_id);
|
||||
return DB::transaction(function () use ($request, $treatment) {
|
||||
|
||||
// 2. Kelola Foto Bukti (Hapus yang lama jika ada foto baru)
|
||||
$fotoPath = $treatment->foto_bukti;
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
|
||||
}
|
||||
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// 3. KEMBALIKAN STOK OBAT LAMA (Penting agar stok tidak bocor)
|
||||
$oldMedicine = \App\Models\Medicine::find($treatment->medicine_id);
|
||||
if ($oldMedicine) {
|
||||
$oldMedicine->increment('qty', $treatment->qty_used);
|
||||
}
|
||||
|
||||
// 2. Potong stok baru
|
||||
$newMedicine = Medicine::findOrFail($request->medicine_id);
|
||||
if ($newMedicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi untuk perubahan ini.");
|
||||
}
|
||||
$newMedicine->decrement('qty', $request->qty_used);
|
||||
// 4. Ambil data obat PERTAMA dari form untuk memperbarui data saat ini (ID 9)
|
||||
$firstItem = $request->items[0];
|
||||
$newMedicine = \App\Models\Medicine::findOrFail($firstItem['medicine_id']);
|
||||
|
||||
// 3. Kelola Foto
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
if ($newMedicine->qty < $firstItem['qty_used']) {
|
||||
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi (Sisa: {$newMedicine->qty}).");
|
||||
}
|
||||
|
||||
// 4. Update data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $newMedicine->nama_obat;
|
||||
$treatment->update($validated);
|
||||
// Format Diagnosa (Gabungan Diagnosa Utama + Dosis/Ket)
|
||||
$catatanDosis = isset($firstItem['dosis']) ? " | Dosis: " . $firstItem['dosis'] : "";
|
||||
$catatanKet = isset($firstItem['keterangan']) && $firstItem['keterangan'] != '' ? " (" . $firstItem['keterangan'] . ")" : "";
|
||||
$diagnosaFinal = $request->diagnosa . $catatanDosis . $catatanKet;
|
||||
|
||||
$this->logAction('UPDATED_TREATMENT', $treatment, "Diperbarui oleh " . auth()->user()->name);
|
||||
// EKSEKUSI UPDATE REKAM MEDIS (Tanpa kolom nama_obat!)
|
||||
$treatment->update([
|
||||
'cat_id' => $request->cat_id,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
'diagnosa' => $diagnosaFinal,
|
||||
'foto_bukti' => $fotoPath,
|
||||
'medicine_id' => $firstItem['medicine_id'],
|
||||
'qty_used' => $firstItem['qty_used'],
|
||||
]);
|
||||
|
||||
// Potong stok obat pertama
|
||||
$newMedicine->decrement('qty', $firstItem['qty_used']);
|
||||
|
||||
// 5. JIKA STAF MENAMBAHKAN OBAT EKSTRA DI HALAMAN EDIT
|
||||
// Buat sebagai record baru agar data tidak hilang
|
||||
if (count($request->items) > 1) {
|
||||
for ($i = 1; $i < count($request->items); $i++) {
|
||||
$extraItem = $request->items[$i];
|
||||
$extraMed = \App\Models\Medicine::findOrFail($extraItem['medicine_id']);
|
||||
|
||||
if ($extraMed->qty < $extraItem['qty_used']) {
|
||||
throw new Exception("Stok {$extraMed->nama_obat} tidak mencukupi.");
|
||||
}
|
||||
|
||||
$catatanDosisEx = isset($extraItem['dosis']) ? " | Dosis: " . $extraItem['dosis'] : "";
|
||||
$catatanKetEx = isset($extraItem['keterangan']) && $extraItem['keterangan'] != '' ? " (" . $extraItem['keterangan'] . ")" : "";
|
||||
$diagnosaFinalEx = $request->diagnosa . $catatanDosisEx . $catatanKetEx;
|
||||
|
||||
\App\Models\Treatment::create([
|
||||
'cat_id' => $request->cat_id,
|
||||
'user_id' => auth()->id() ?? 1,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
'diagnosa' => $diagnosaFinalEx,
|
||||
'foto_bukti' => $fotoPath,
|
||||
'status_perawatan' => $treatment->status_perawatan, // Ikuti status (Misal: 1 / Aktif)
|
||||
'medicine_id' => $extraItem['medicine_id'],
|
||||
'qty_used' => $extraItem['qty_used'],
|
||||
]);
|
||||
|
||||
$extraMed->decrement('qty', $extraItem['qty_used']);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Sinkronisasi Kucing & Log Activity
|
||||
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
|
||||
$this->logAction('UPDATED', $treatment, "Memperbarui rekam medis klinis untuk " . $treatment->cat->nama_kucing);
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis berhasil diperbarui dan stok obat disesuaikan ulang secara otomatis!');
|
||||
});
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)->with('success', 'Rekam medis & inventori berhasil di-sinkronisasi.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
return back()->withInput()->with('error', 'Gagal memperbarui: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus rekam medis & kembalikan stok.
|
||||
* Hapus rekam medis & kembalikan semua stok obat terkait.
|
||||
*/
|
||||
public function destroy(Treatment $treatment): RedirectResponse
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($treatment) {
|
||||
if ($treatment->medicine_id) {
|
||||
Medicine::where('id', $treatment->medicine_id)->increment('qty', $treatment->qty_used);
|
||||
// Kembalikan semua stok dari tabel detail
|
||||
foreach ($treatment->details as $detail) {
|
||||
Medicine::where('id', $detail->medicine_id)->increment('qty', $detail->qty_used);
|
||||
}
|
||||
|
||||
if ($treatment->foto_bukti) {
|
||||
Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
}
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
|
||||
$this->logAction('DELETED_TREATMENT', $treatment, "Menghapus catatan medis kucing: {$treatment->cat->nama_kucing}");
|
||||
|
||||
// Delete otomatis menghapus details karena cascade di migration
|
||||
$treatment->delete();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Data rekam medis dihapus dan stok dikembalikan.');
|
||||
return back()->with('success', 'Data rekam medis dihapus dan semua stok obat dikembalikan.');
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal menghapus data.');
|
||||
return back()->with('error', 'Gagal menghapus data: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Validasi: Terpusat untuk Clean Code & Mendukung Desimal.
|
||||
*/
|
||||
private function validateTreatment(Request $request)
|
||||
{
|
||||
return $request->validate([
|
||||
'cat_id' => 'required|exists:cats,id',
|
||||
'medicine_id' => 'required|exists:medicines,id',
|
||||
// 'numeric' mengizinkan input desimal (koma)
|
||||
'qty_used' => 'required|numeric|min:0.01',
|
||||
'tanggal' => 'required|date',
|
||||
'keluhan' => 'required|string|max:255',
|
||||
'diagnosa' => 'required|string',
|
||||
'dosis' => 'required|string',
|
||||
'keterangan' => 'nullable|string',
|
||||
'foto_bukti' => 'nullable|image|max:2048',
|
||||
|
||||
// Validasi untuk Single Medicine
|
||||
'medicine_id' => 'required_without:items|nullable|exists:medicines,id',
|
||||
'qty_used' => 'required_without:items|nullable|numeric|min:0',
|
||||
|
||||
// Validasi untuk Multi Medicine
|
||||
// FIX: Wajibkan array items jika mode single (medicine_id) kosong
|
||||
'items' => 'required_without:medicine_id|nullable|array',
|
||||
'items.*.medicine_id' => 'required_with:items|exists:medicines,id',
|
||||
'items.*.qty_used' => 'required_with:items|numeric|min:0.01',
|
||||
|
||||
], [
|
||||
// Pesan Error Custom (Opsional, agar lebih mudah di-debug)
|
||||
'items.required_without' => 'Anda harus memasukkan minimal 1 obat.',
|
||||
'items.*.medicine_id.required_with' => 'Ada pilihan obat yang masih kosong di daftar.',
|
||||
'items.*.qty_used.required_with' => 'Jumlah (Qty) obat harus diisi.',
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,12 @@ class UserController extends Controller
|
||||
Gate::authorize('check-access', ['users', 'read']);
|
||||
|
||||
$users = User::query()
|
||||
->with('role')
|
||||
->with('roleModel')
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
->paginate(10);
|
||||
@@ -147,6 +149,44 @@ class UserController extends Controller
|
||||
return redirect()->route('admin.users.index')->with('success', "User {$userName} telah dihapus.");
|
||||
}
|
||||
|
||||
public function toggleStatus(User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'update']);
|
||||
|
||||
if ($user->id === auth()->id() && $user->is_active) {
|
||||
return back()->with('error', 'Akun yang sedang digunakan tidak dapat dinonaktifkan.');
|
||||
}
|
||||
|
||||
$newStatus = !$user->is_active;
|
||||
$user->forceFill([
|
||||
'is_active' => $newStatus,
|
||||
'deactivated_at' => $newStatus ? null : now(),
|
||||
])->save();
|
||||
|
||||
$this->logAction(
|
||||
$newStatus ? 'USER_ACTIVATED' : 'USER_DEACTIVATED',
|
||||
$user,
|
||||
($newStatus ? 'Mengaktifkan' : 'Menonaktifkan') . " akun user: {$user->name}",
|
||||
'User'
|
||||
);
|
||||
|
||||
return back()->with('success', "Status akun {$user->name} berhasil diperbarui.");
|
||||
}
|
||||
|
||||
public function changePassword(Request $request, User $user): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['users', 'update']);
|
||||
|
||||
$validated = $request->validate([
|
||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||
]);
|
||||
|
||||
$user->forceFill(['password' => Hash::make($validated['password'])])->save();
|
||||
$this->logAction('USER_PASSWORD_CHANGED', $user, "Mengganti kata sandi user: {$user->name}", 'User');
|
||||
|
||||
return back()->with('success', "Kata sandi {$user->name} berhasil diganti.");
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* AUDIT TRAIL / LOGS SECTION */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -194,7 +234,6 @@ class UserController extends Controller
|
||||
public function clearLogs()
|
||||
{
|
||||
Gate::authorize('check-access', ['logs', 'delete']);
|
||||
ActivityLog::truncate();
|
||||
return back()->with('success', 'Seluruh riwayat aktivitas telah dibersihkan.');
|
||||
return back()->with('error', 'Penghapusan massal audit trail dinonaktifkan untuk menjaga jejak perubahan data. Gunakan kebijakan retensi/arsip bila diperlukan.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnLoginController
|
||||
{
|
||||
/**
|
||||
* Returns the challenge to assertion.
|
||||
*/
|
||||
public function options(AssertionRequest $request): Responsable
|
||||
{
|
||||
return $request->toVerify($request->validate(['email' => 'sometimes|email|string']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user in.
|
||||
*/
|
||||
public function login(AssertedRequest $request): Response
|
||||
{
|
||||
return response()->noContent($request->login() ? 204 : 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnRegisterController
|
||||
{
|
||||
/**
|
||||
* Returns a challenge to be verified by the user device.
|
||||
*/
|
||||
public function options(AttestationRequest $request): Responsable
|
||||
{
|
||||
return $request
|
||||
->fastRegistration()
|
||||
// ->userless()
|
||||
// ->allowDuplicates()
|
||||
->toCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a device for further WebAuthn authentication.
|
||||
*/
|
||||
public function register(AttestedRequest $request): Response
|
||||
{
|
||||
$request->save();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class AdminMiddleware
|
||||
|
||||
// 2. Ambil slug role (Mendukung sistem baru yang kita buat)
|
||||
// Kita gunakan optional() agar jika user tidak punya role, aplikasi tidak crash.
|
||||
$roleSlug = optional(auth()->user()->role)->slug ?? auth()->user()->role;
|
||||
$roleSlug = auth()->user()->roleModel?->slug ?? auth()->user()->getRawOriginal('role');
|
||||
|
||||
// 3. Logika Existing Anda (Tetap Dipertahankan)
|
||||
if ($roleSlug !== 'admin') {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiToken
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$plain = $request->bearerToken();
|
||||
if (!$plain) {
|
||||
return response()->json(['message' => 'Token akses tidak ditemukan.'], 401);
|
||||
}
|
||||
|
||||
$token = ApiToken::with('user')->where('token_hash', hash('sha256', $plain))->first();
|
||||
if (!$token || !$token->user || $token->user->is_active === false || ($token->expires_at && $token->expires_at->isPast())) {
|
||||
return response()->json(['message' => 'Token akses tidak valid atau sudah kedaluwarsa.'], 401);
|
||||
}
|
||||
|
||||
$token->forceFill(['last_used_at' => now()])->saveQuietly();
|
||||
$request->setUserResolver(fn () => $token->user);
|
||||
$request->attributes->set('api_token', $token);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureUserIsActive
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if ($user && $user->is_active === false) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->with('status', 'Akun Anda sedang dinonaktifkan. Hubungi administrator.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\WeightHistory;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStartRow;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
|
||||
class CatWeightImport implements ToCollection, WithStartRow
|
||||
{
|
||||
// Tambahkan variabel untuk menghitung data yang sukses
|
||||
private int $importedCount = 0;
|
||||
|
||||
public function startRow(): int
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
public function collection(Collection $rows)
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$catId = $row[0] ?? null;
|
||||
$berat = $row[2] ?? null;
|
||||
$tanggalExcel = $row[3] ?? null;
|
||||
|
||||
if (empty($catId) || empty($berat) || empty($tanggalExcel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedDate = null;
|
||||
try {
|
||||
if (is_numeric($tanggalExcel)) {
|
||||
$parsedDate = Date::excelToDateTimeObject($tanggalExcel);
|
||||
} else {
|
||||
$tanggalExcel = str_ireplace(
|
||||
['januari', 'februari', 'maret', 'april', 'mei', 'juni', 'juli', 'agustus', 'september', 'oktober', 'november', 'desember'],
|
||||
['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'],
|
||||
$tanggalExcel
|
||||
);
|
||||
$parsedDate = Carbon::parse($tanggalExcel);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$carbonDate = Carbon::instance($parsedDate);
|
||||
$dateOnly = $carbonDate->format('Y-m-d');
|
||||
$timeOnly = $carbonDate->format('H:i:s');
|
||||
|
||||
$existingRecord = WeightHistory::where('cat_id', $catId)
|
||||
->whereDate('tanggal_timbang', $dateOnly)
|
||||
->first();
|
||||
|
||||
if ($existingRecord) {
|
||||
$finalTime = ($timeOnly === '00:00:00') ? Carbon::parse($existingRecord->tanggal_timbang)->format('H:i:s') : $timeOnly;
|
||||
|
||||
$existingRecord->update([
|
||||
'berat' => $berat,
|
||||
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
|
||||
]);
|
||||
|
||||
// Tambah hitungan jika berhasil update
|
||||
$this->importedCount++;
|
||||
} else {
|
||||
$finalTime = ($timeOnly === '00:00:00') ? now()->format('H:i:s') : $timeOnly;
|
||||
|
||||
WeightHistory::create([
|
||||
'cat_id' => $catId,
|
||||
'berat' => $berat,
|
||||
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
|
||||
]);
|
||||
|
||||
// Tambah hitungan jika berhasil insert baru
|
||||
$this->importedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fungsi untuk melempar total hitungan ke Controller
|
||||
public function getImportedCount(): int
|
||||
{
|
||||
return $this->importedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ApiToken extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'name', 'token_hash', 'last_used_at', 'expires_at'];
|
||||
protected function casts(): array { return ['last_used_at' => 'datetime', 'expires_at' => 'datetime']; }
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
+129
-39
@@ -16,7 +16,6 @@ class Cat extends Model
|
||||
|
||||
/**
|
||||
* SINKRONISASI TOTAL: 22 Kolom Database
|
||||
* Memasukkan berat_terakhir, lokasi, dan kolom identitas lainnya.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_kucing', 'ras', 'registry', 'jenis_kelamin', 'tanggal_lahir',
|
||||
@@ -24,9 +23,39 @@ class Cat extends Model
|
||||
'jadwal_vaksin', 'jadwal_obat_cacing', 'jadwal_obat_kutu',
|
||||
'jadwal_grooming', 'induk_jantan_id', 'induk_betina_id',
|
||||
'lokasi_kandang', 'pic_id', 'berat_badan',
|
||||
'pedigree', 'microchip', 'status', 'kode_ems' // Kolom tambahan dari UI Anda
|
||||
'pedigree','sertifikat_ped_file', 'microchip', 'status', 'kode_ems','treatment_status',
|
||||
'cattery_status','adopted_at','adoption_notes'
|
||||
];
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi (Booted)
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
// A. Sebelum simpan: Sinkronisasi berat_terakhir
|
||||
static::saving(function ($cat) {
|
||||
if ($cat->isDirty('berat_badan')) {
|
||||
$cat->berat_terakhir = $cat->berat_badan;
|
||||
}
|
||||
});
|
||||
|
||||
// B. Setelah update berhasil: Catat ke tabel history
|
||||
static::updated(function ($cat) {
|
||||
if ($cat->wasChanged('berat_badan')) {
|
||||
// Kita gunakan try-catch agar jika tabel history bermasalah, update kucing tidak ikut mati
|
||||
try {
|
||||
$cat->weightHistories()->create([
|
||||
'berat' => $cat->berat_badan,
|
||||
'tanggal_timbang' => now(), // Pastikan kolom ini ada di migrasi Anda
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Log error diam-diam agar user tidak melihat Error 500
|
||||
\Illuminate\Support\Facades\Log::error("Gagal mencatat history berat: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Casting Laravel 11/12 Style
|
||||
*/
|
||||
@@ -38,9 +67,10 @@ class Cat extends Model
|
||||
'jadwal_obat_cacing' => 'date',
|
||||
'jadwal_obat_kutu' => 'date',
|
||||
'jadwal_grooming' => 'date',
|
||||
'registry' => 'array',
|
||||
'registry' => 'array', // Otomatis mengurus JSON di DB
|
||||
'berat_badan' => 'decimal:2',
|
||||
'berat_terakhir' => 'decimal:2',
|
||||
'adopted_at' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -48,9 +78,9 @@ class Cat extends Model
|
||||
/* RELATIONS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
public function treatments(): HasMany
|
||||
public function treatments()
|
||||
{
|
||||
return $this->hasMany(Treatment::class);
|
||||
return $this->hasMany(Treatment::class, 'cat_id');
|
||||
}
|
||||
|
||||
public function weightHistories(): HasMany
|
||||
@@ -89,26 +119,38 @@ class Cat extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
public function registrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CatRegistration::class)->orderByDesc('is_primary')->orderBy('id');
|
||||
}
|
||||
|
||||
public function primaryRegistration()
|
||||
{
|
||||
return $this->hasOne(CatRegistration::class)->where('is_primary', true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS */
|
||||
/* ACCESSORS (Penyempurnaan Null-Safety & Logic) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
protected function genderIcon(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
// Database menggunakan enum 'Jantan'/'Betina' (Case Sensitive)
|
||||
get: fn () => strtolower($this->jenis_kelamin) === 'jantan'
|
||||
? '<i class="fas fa-mars text-indigo-500"></i>'
|
||||
: '<i class="fas fa-venus text-rose-500"></i>'
|
||||
get: fn () => strtolower($this->jenis_kelamin ?? '') === 'jantan'
|
||||
? '<i class="fas fa-mars text-indigo-500" title="Jantan"></i>'
|
||||
: '<i class="fas fa-venus text-rose-500" title="Betina"></i>'
|
||||
);
|
||||
}
|
||||
|
||||
protected function fotoUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->foto
|
||||
? Storage::url($this->foto)
|
||||
: asset('images/default-cat.png')
|
||||
get: function () {
|
||||
if ($this->foto && Storage::disk('public')->exists($this->foto)) {
|
||||
return Storage::url($this->foto);
|
||||
}
|
||||
return asset('images/default-cat.png');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,57 +168,105 @@ class Cat extends Model
|
||||
);
|
||||
}
|
||||
|
||||
// Usia presisi untuk laporan medis (Tahun, Bulan, Hari)
|
||||
protected function usiaDetail(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->tanggal_lahir) return 'Data N/A';
|
||||
$diff = $this->tanggal_lahir->diff(now());
|
||||
return "{$diff->y} Thn, {$diff->m} Bln, {$diff->d} Hari";
|
||||
return "{$diff->y}th {$diff->m}bln {$diff->d}hr";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function registryLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->registry ?? 'NON-PED'
|
||||
);
|
||||
}
|
||||
|
||||
// Badges untuk registry
|
||||
/**
|
||||
* Badge Registry yang disempurnakan (lebih bersih)
|
||||
*/
|
||||
protected function registryBadges(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
// Jika tidak ada registry
|
||||
if (empty($this->registry)) {
|
||||
return '<span class="text-[9px] text-slate-400 font-bold italic tracking-widest">NON-PED</span>';
|
||||
$registries = $this->registry;
|
||||
|
||||
// Jika kosong atau eksplisit memilih NON-PED
|
||||
if (empty($registries) || (is_array($registries) && in_array('NON-PED', $registries))) {
|
||||
return '<span class="px-2 py-0.5 bg-slate-100 text-slate-400 border border-slate-200 rounded-lg text-[8px] font-black uppercase tracking-tighter">NON-PED</span>';
|
||||
}
|
||||
|
||||
// Mapping warna untuk setiap federasi
|
||||
$config = [
|
||||
'ICA' => ['bg' => 'bg-blue-50', 'text' => 'text-blue-700', 'border' => 'border-blue-100'],
|
||||
'CFA' => ['bg' => 'bg-rose-50', 'text' => 'text-rose-700', 'border' => 'border-rose-100'],
|
||||
'WCF' => ['bg' => 'bg-emerald-50', 'text' => 'text-emerald-700', 'border' => 'border-emerald-100'],
|
||||
'FFF' => ['bg' => 'bg-amber-50', 'text' => 'text-amber-700', 'border' => 'border-amber-100'],
|
||||
'ICA' => 'bg-blue-50 text-blue-700 border-blue-100',
|
||||
'CFA' => 'bg-rose-50 text-rose-700 border-rose-100',
|
||||
'WCF' => 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||
'FFF' => 'bg-amber-50 text-amber-700 border-amber-100',
|
||||
];
|
||||
|
||||
$html = '<div class="flex flex-wrap gap-1">';
|
||||
$registries = is_array($this->registry) ? $this->registry : [$this->registry];
|
||||
$list = is_array($registries) ? $registries : [$registries];
|
||||
|
||||
foreach ($registries as $reg) {
|
||||
$style = $config[$reg] ?? ['bg' => 'bg-slate-50', 'text' => 'text-slate-600', 'border' => 'border-slate-200'];
|
||||
|
||||
$html .= "<span class='px-2 py-0.5 {$style['bg']} {$style['text']} {$style['border']} border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm'>";
|
||||
$html .= $reg;
|
||||
$html .= "</span>";
|
||||
foreach ($list as $reg) {
|
||||
if($reg === 'NON-PED') continue; // Skip jika tercampur (opsional)
|
||||
$uReg = strtoupper($reg);
|
||||
$style = $config[$uReg] ?? 'bg-slate-50 text-slate-600 border-slate-200';
|
||||
$html .= "<span class='px-2 py-0.5 border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm {$style}'>{$uReg}</span>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function medicalRecords()
|
||||
{
|
||||
// Mengambil riwayat, terbaru diletakkan paling atas
|
||||
return $this->hasMany(MedicalRecord::class)->orderBy('date_given', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sinkronkan kolom status perawatan dari treatment yang masih aktif.
|
||||
* Nilai 1 = dalam perawatan, 2 = sehat.
|
||||
*/
|
||||
public function updateTreatmentStatus(): void
|
||||
{
|
||||
$status = $this->treatments()->where('status_perawatan', 1)->exists() ? 1 : 2;
|
||||
$this->forceFill(['treatment_status' => $status])->saveQuietly();
|
||||
}
|
||||
/**
|
||||
* Scope untuk filter kucing yang sedang sakit (Under Treatment)
|
||||
*/
|
||||
public function scopeUnderTreatment($query)
|
||||
{
|
||||
// Hapus logika whereHas lama, ganti dengan ini:
|
||||
return $query->where('treatment_status', 1);
|
||||
}
|
||||
|
||||
public function scopeHealthy($query)
|
||||
{
|
||||
return $query->where('treatment_status', 2);
|
||||
}
|
||||
|
||||
public function scopeActiveInCattery($query)
|
||||
{
|
||||
return $query->where(function ($q) {
|
||||
$q->whereNull('cattery_status')->orWhere('cattery_status', 'active');
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeAdopted($query)
|
||||
{
|
||||
return $query->where('cattery_status', 'adopted');
|
||||
}
|
||||
|
||||
public function scopeExternalParents($query)
|
||||
{
|
||||
return $query->where('cattery_status', 'external_parent');
|
||||
}
|
||||
|
||||
public function getCatteryStatusLabelAttribute(): string
|
||||
{
|
||||
return match ($this->cattery_status ?: 'active') {
|
||||
'adopted' => 'Teradopsi',
|
||||
'external_parent' => 'Induk eksternal',
|
||||
default => 'Aktif di cattery',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CatAssociation extends Model
|
||||
{
|
||||
protected $fillable = ['code', 'name', 'country', 'website', 'is_active'];
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function registrations()
|
||||
{
|
||||
return $this->hasMany(CatRegistration::class, 'association_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CatRegistration extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'cat_id', 'association_id', 'registration_number', 'pedigree_number',
|
||||
'certificate_number', 'issue_date', 'title', 'ownership_status',
|
||||
'is_primary', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['issue_date' => 'date', 'is_primary' => 'boolean'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (CatRegistration $registration) {
|
||||
$registration->loadMissing('documents');
|
||||
foreach ($registration->documents as $document) {
|
||||
if ($document->file_path) {
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function cat() { return $this->belongsTo(Cat::class); }
|
||||
public function association() { return $this->belongsTo(CatAssociation::class, 'association_id'); }
|
||||
public function documents() { return $this->hasMany(CatRegistrationDocument::class)->latest(); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CatRegistrationDocument extends Model
|
||||
{
|
||||
protected $fillable = ['cat_registration_id', 'label', 'document_type', 'file_path', 'issued_at'];
|
||||
protected function casts(): array { return ['issued_at' => 'date']; }
|
||||
public function registration() { return $this->belongsTo(CatRegistration::class, 'cat_registration_id'); }
|
||||
public function getUrlAttribute(): string { return Storage::disk('public')->url($this->file_path); }
|
||||
}
|
||||
+45
-12
@@ -3,19 +3,41 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Food extends Model
|
||||
{
|
||||
protected $table = 'foods';
|
||||
|
||||
// Gunakan guarded atau fillable, pastikan 'slug' dan 'category_id' diizinkan
|
||||
protected $guarded = ['id'];
|
||||
// Menggunakan fillable lebih aman untuk tracking kolom apa saja yang masuk ke DB
|
||||
protected $fillable = [
|
||||
'nama_makanan',
|
||||
'foto_produk',
|
||||
'slug',
|
||||
'qty',
|
||||
'unit',
|
||||
'category_id',
|
||||
'kategori',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting tipe data agar terbaca sebagai objek oleh Carbon
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke model Category
|
||||
*/
|
||||
public function category()
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
@@ -27,14 +49,13 @@ class Food extends Model
|
||||
{
|
||||
static::saving(function ($food) {
|
||||
// 1. OTOMATIS GENERATE SLUG
|
||||
// Hanya generate jika nama berubah atau slug masih kosong
|
||||
if ($food->isDirty('nama_makanan') || empty($food->slug)) {
|
||||
$food->slug = Str::slug($food->nama_makanan) . '-' . Str::random(5);
|
||||
$food->slug = Str::slug($food->nama_makanan) . '-' . Str::lower(Str::random(5));
|
||||
}
|
||||
|
||||
// 2. AUTO-CATEGORIZATION (SMART LOGIC)
|
||||
// Hanya jalankan jika category_id belum diisi manual
|
||||
if (empty($food->category_id)) {
|
||||
// Jalankan hanya jika category_id belum diisi manual atau nama makanan berubah
|
||||
if (empty($food->category_id) || $food->isDirty('nama_makanan')) {
|
||||
$productName = strtolower($food->nama_makanan);
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
$found = false;
|
||||
@@ -49,15 +70,13 @@ class Food extends Model
|
||||
$food->category_id = $cat->id;
|
||||
$food->kategori = $cat->name;
|
||||
$found = true;
|
||||
break; // Keluar dari loop keyword
|
||||
break 2; // Keluar dari kedua loop sekaligus
|
||||
}
|
||||
}
|
||||
if ($found) break; // Keluar dari loop kategori
|
||||
}
|
||||
|
||||
// 3. FALLBACK LOGIC
|
||||
// Jika tidak ada keyword yang cocok, cari kategori default
|
||||
if (!$found) {
|
||||
if (!$found && empty($food->category_id)) {
|
||||
$default = Category::where('name', 'Dry Food')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
@@ -66,10 +85,24 @@ class Food extends Model
|
||||
$food->category_id = $default->id;
|
||||
$food->kategori = $default->name;
|
||||
} else {
|
||||
$food->kategori = 'Umum';
|
||||
$food->kategori = $food->kategori ?: 'Umum';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SINKRONISASI TEKS KATEGORI
|
||||
// Jika category_id diisi manual di form, pastikan kolom 'kategori' (string) juga terupdate
|
||||
if ($food->isDirty('category_id') && !empty($food->category_id)) {
|
||||
$food->kategori = $food->category?->name ?? $food->kategori;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Cek Status Expired
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,9 @@ class Kitten extends Model
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if ($this->is_pedigree) {
|
||||
return '<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase border border-indigo-100 italic">Certificate OK</span>';
|
||||
return '<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase border border-indigo-100 italic">Sertifikat tersedia</span>';
|
||||
}
|
||||
return '<span class="px-2 py-0.5 bg-slate-50 text-slate-400 rounded text-[9px] font-black uppercase border border-slate-100">Non-Ped</span>';
|
||||
return '<span class="px-2 py-0.5 bg-slate-50 text-slate-400 rounded text-[9px] font-black uppercase border border-slate-100">Non-pedigree</span>';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+24
-8
@@ -21,11 +21,13 @@ class Matting extends Model
|
||||
'tgl_lahir_sire' => 'date',
|
||||
'tgl_lahir_dam' => 'date',
|
||||
'jumlah_anak_hidup' => 'integer',
|
||||
'pregnancy_confirmed_at' => 'date',
|
||||
'actual_birth_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SINKRONISASI OTOMATIS (The Engine)
|
||||
* SINKRONISASI OTOMATIS (Mesin sinkronisasi)
|
||||
* Saat data Matting disimpan, otomatis update data di tabel Cats.
|
||||
*/
|
||||
protected static function booted()
|
||||
@@ -34,8 +36,7 @@ class Matting extends Model
|
||||
// 1. Update data Sire (Bapak)
|
||||
if ($matting->sire_id && $matting->sire) {
|
||||
$matting->sire->update([
|
||||
'pedigree' => $matting->no_pedigree_bapak,
|
||||
'microchip' => $now_microchip_bapak ?? $matting->no_microchip_bapak,
|
||||
'microchip' => $matting->no_microchip_bapak,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_sire,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_bapak
|
||||
'kode_ems' => $matting->ems_bapak,
|
||||
@@ -45,7 +46,6 @@ class Matting extends Model
|
||||
// 2. Update data Dam (Ibu)
|
||||
if ($matting->dam_id && $matting->dam) {
|
||||
$matting->dam->update([
|
||||
'pedigree' => $matting->no_pedigree_ibu,
|
||||
'microchip' => $matting->no_microchip_ibu,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_dam,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_ibu
|
||||
@@ -74,7 +74,7 @@ class Matting extends Model
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS (UI & Breeding Intelligence) */
|
||||
/* ACCESSORS (UI & BREEDING) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -94,7 +94,7 @@ class Matting extends Model
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->edd || $this->status !== 'proses') return null;
|
||||
if (!$this->edd || !in_array($this->status, ['proses', 'hamil'], true)) return null;
|
||||
|
||||
$days = (int) now()->startOfDay()->diffInDays($this->edd->startOfDay(), false);
|
||||
|
||||
@@ -114,7 +114,8 @@ class Matting extends Model
|
||||
get: function () {
|
||||
$status = strtolower($this->status);
|
||||
return match($status) {
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">Monitoring</span>',
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">Menunggu konfirmasi</span>',
|
||||
'hamil' => '<span class="px-3 py-1 bg-amber-50 text-amber-700 rounded-full text-[10px] font-black uppercase border border-amber-100">Hamil / menunggu lahir</span>',
|
||||
'berhasil' => '<span class="px-3 py-1 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-black uppercase border border-emerald-100">Lahir</span>',
|
||||
'gagal' => '<span class="px-3 py-1 bg-rose-50 text-rose-600 rounded-full text-[10px] font-black uppercase border border-rose-100">Gagal</span>',
|
||||
default => '<span class="px-3 py-1 bg-slate-50 text-slate-600 rounded-full text-[10px] font-black uppercase">'.$status.'</span>',
|
||||
@@ -123,6 +124,21 @@ class Matting extends Model
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
public function scopeOpenBreeding($query)
|
||||
{
|
||||
return $query->whereIn('status', ['proses', 'hamil']);
|
||||
}
|
||||
|
||||
public function getFlowStepAttribute(): int
|
||||
{
|
||||
return match ($this->status) {
|
||||
'hamil' => 2,
|
||||
'berhasil', 'gagal' => 3,
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PIC (Point of Contact)
|
||||
*/
|
||||
@@ -131,7 +147,7 @@ class Matting extends Model
|
||||
// Pastikan kolom di tabel 'mattings' adalah 'user_id'
|
||||
// Jika kolomnya bernama 'pic_id', ganti 'user_id' menjadi 'pic_id'
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
'name' => 'Sistem / Admin'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo; // 1. TAMBAHKAN BARIS INI
|
||||
|
||||
class MedicalRecord extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'cat_id',
|
||||
'user_id',
|
||||
'type',
|
||||
'treatment_name',
|
||||
'dosage',
|
||||
'unit',
|
||||
'date_given',
|
||||
'next_schedule',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date_given' => 'date',
|
||||
'next_schedule' => 'date',
|
||||
];
|
||||
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class);
|
||||
}
|
||||
|
||||
// 2. PASTIKAN RELASI INI BENAR
|
||||
public function performer(): BelongsTo
|
||||
{
|
||||
// Karena kolomnya sekarang user_id (bukan performed_by),
|
||||
// kita beri tahu Laravel secara eksplisit:
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
+61
-18
@@ -4,23 +4,40 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class Medicine extends Model
|
||||
{
|
||||
/**
|
||||
* Mass assignment untuk kolom-kolom inventori.
|
||||
* Kolom 'kategori' tetap ada di fillable agar bisa diisi otomatis oleh sistem.
|
||||
* Mass assignment.
|
||||
* Menambahkan expired_at dan category_id agar sinkron dengan update database terbaru.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_obat',
|
||||
'foto_produk',
|
||||
'kategori',
|
||||
'category_id',
|
||||
'qty',
|
||||
'unit',
|
||||
'expired_date'
|
||||
'expired_date',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Casting (Laravel 11/12 style)
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_date' => 'date',
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float', // Mendukung dosis desimal (misal 2.5 ml)
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi & Integritas Data
|
||||
*/
|
||||
@@ -29,34 +46,36 @@ class Medicine extends Model
|
||||
static::saving(function ($medicine) {
|
||||
$name = strtolower($medicine->nama_obat);
|
||||
|
||||
// Ambil semua kategori dari database
|
||||
// 1. OTOMATISASI KATEGORI BERDASARKAN KEYWORDS
|
||||
$categories = \App\Models\Category::all();
|
||||
$matched = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
// Pecah kata kunci berdasarkan koma
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($name, trim($keyword))) {
|
||||
$medicine->category_id = $cat->id;
|
||||
$medicine->kategori = $cat->name; // Sinkronisasi teks kategori
|
||||
return;
|
||||
$medicine->kategori = $cat->name;
|
||||
$matched = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$medicine->kategori = 'Umum';
|
||||
if (!$matched) {
|
||||
$medicine->kategori = $medicine->kategori ?: 'Umum';
|
||||
}
|
||||
|
||||
// 2. SINKRONISASI TANGGAL EXPIRED (Double Guard)
|
||||
// Jika expired_at kosong tapi expired_date ada, samakan. Begitu juga sebaliknya.
|
||||
if ($medicine->expired_date && !$medicine->expired_at) {
|
||||
$medicine->expired_at = $medicine->expired_date;
|
||||
} elseif ($medicine->expired_at && !$medicine->expired_date) {
|
||||
$medicine->expired_date = $medicine->expired_at;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Casting tipe data untuk presisi medis.
|
||||
*/
|
||||
protected $casts = [
|
||||
'expired_date' => 'date', // Memperbaiki error isPast() di Blade
|
||||
'qty' => 'float', // Mendukung dosis desimal seperti 2.5 ml
|
||||
];
|
||||
|
||||
/**
|
||||
* Accessor: Mempermudah pemanggilan foto produk dengan fallback image.
|
||||
*/
|
||||
@@ -66,10 +85,26 @@ class Medicine extends Model
|
||||
return asset('storage/' . $this->foto_produk);
|
||||
}
|
||||
|
||||
// Menggunakan UI Avatars sebagai placeholder jika foto tidak ada
|
||||
return 'https://ui-avatars.com/api/?name=' . urlencode($this->nama_obat) . '&background=f1f5f9&color=64748b';
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Cek Status Expired secara instan.
|
||||
* Bisa digunakan di Blade: @if($medicine->is_expired) ... @endif
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Kategori Obat.
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke riwayat pengobatan kucing.
|
||||
*/
|
||||
@@ -77,4 +112,12 @@ class Medicine extends Model
|
||||
{
|
||||
return $this->hasMany(Treatment::class, 'medicine_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Melihat riwayat penggunaan obat ini di berbagai rekam medis
|
||||
*/
|
||||
public function treatmentDetails()
|
||||
{
|
||||
return $this->hasMany(TreatmentDetail::class);
|
||||
}
|
||||
}
|
||||
+62
-19
@@ -9,48 +9,75 @@ use Illuminate\Support\Str;
|
||||
class Supply extends Model
|
||||
{
|
||||
protected $table = 'supplies';
|
||||
protected $guarded = ['id'];
|
||||
|
||||
// Menggunakan fillable untuk keamanan mass assignment
|
||||
protected $fillable = [
|
||||
'nama_perlengkapan',
|
||||
'foto_produk',
|
||||
'slug',
|
||||
'qty',
|
||||
'unit',
|
||||
'category_id',
|
||||
'kategori',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Casting (Laravel 11/12)
|
||||
* Memastikan expired_at menjadi objek Carbon untuk mencegah error isPast()
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke model Category
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi & Integritas Data
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::saving(function ($supply) {
|
||||
// 1. Sinkronisasi Slug (Mencegah error SQL sebelumnya)
|
||||
// 1. OTOMATIS GENERATE SLUG
|
||||
if ($supply->isDirty('nama_perlengkapan') || empty($supply->slug)) {
|
||||
$supply->slug = Str::slug($supply->nama_perlengkapan) . '-' . Str::random(5);
|
||||
$supply->slug = Str::slug($supply->nama_perlengkapan) . '-' . Str::lower(Str::random(5));
|
||||
}
|
||||
|
||||
// 2. Auto-Categorization
|
||||
if (empty($supply->category_id)) {
|
||||
// 2. AUTO-CATEGORIZATION (SMART LOGIC)
|
||||
// Jalankan jika category_id kosong atau nama perlengkapan berubah
|
||||
if (empty($supply->category_id) || $supply->isDirty('nama_perlengkapan')) {
|
||||
$name = strtolower($supply->nama_perlengkapan);
|
||||
|
||||
// Ambil kategori yang punya keywords
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
$found = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
$keywordsArray = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$trimmed = trim($keyword);
|
||||
foreach ($keywordsArray as $keyword) {
|
||||
$cleanKeyword = trim($keyword);
|
||||
|
||||
// Cek apakah "pasir" atau keyword lain ada di nama produk
|
||||
if (!empty($trimmed) && str_contains($name, $trimmed)) {
|
||||
if (!empty($cleanKeyword) && str_contains($name, $cleanKeyword)) {
|
||||
$supply->category_id = $cat->id;
|
||||
// PENTING: Update kolom teks 'kategori' agar tampilan tidak 'Umum'
|
||||
$supply->kategori = $cat->name;
|
||||
return;
|
||||
$found = true;
|
||||
break 2; // Berhenti mencari jika sudah ketemu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback jika tidak ada yang cocok
|
||||
// Cari kategori yang mengandung kata 'Pasir' atau 'Umum'
|
||||
// 3. FALLBACK LOGIC
|
||||
if (!$found && empty($supply->category_id)) {
|
||||
$default = Category::where('name', 'LIKE', '%Pasir%')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
@@ -59,9 +86,25 @@ class Supply extends Model
|
||||
$supply->category_id = $default->id;
|
||||
$supply->kategori = $default->name;
|
||||
} else {
|
||||
$supply->kategori = 'Umum';
|
||||
$supply->kategori = $supply->kategori ?: 'Umum';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SINKRONISASI TEKS KATEGORI
|
||||
// Jika ID kategori diubah manual di form, update teks kolom 'kategori'
|
||||
if ($supply->isDirty('category_id') && !empty($supply->category_id)) {
|
||||
$supply->kategori = $supply->category?->name ?? $supply->kategori;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Membantu pengecekan expired di Blade secara rapi
|
||||
* Contoh: @if($supply->is_expired) ... @endif
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
}
|
||||
+59
-16
@@ -4,8 +4,11 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Treatment extends Model
|
||||
{
|
||||
@@ -24,10 +27,12 @@ class Treatment extends Model
|
||||
'foto_bukti',
|
||||
'medicine_id',
|
||||
'qty_used',
|
||||
'status_perawatan', // Tambahan Baru
|
||||
'remark_pasca_sakit'
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting data agar formatnya sesuai (Tanggal jadi object Carbon).
|
||||
* Casting data agar formatnya sesuai.
|
||||
*/
|
||||
protected $casts = [
|
||||
'tanggal' => 'datetime',
|
||||
@@ -35,7 +40,7 @@ class Treatment extends Model
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* RELASI (MENGATASI ERROR RELATION NOT FOUND) */
|
||||
/* RELASI */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -43,25 +48,19 @@ class Treatment extends Model
|
||||
*/
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'cat_id')->withDefault([
|
||||
'nama_kucing' => 'Kucing Terhapus'
|
||||
]);
|
||||
return $this->belongsTo(Cat::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi utama ke User (PIC).
|
||||
* Nama fungsi 'user' wajib ada karena dipanggil di TreatmentController baris 31.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
]);
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias untuk relasi user.
|
||||
* Nama fungsi 'pic' wajib ada karena dipanggil di DashboardController.
|
||||
* Alias untuk relasi user (Digunakan di Dashboard).
|
||||
*/
|
||||
public function pic(): BelongsTo
|
||||
{
|
||||
@@ -69,13 +68,30 @@ class Treatment extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Inventori Obat.
|
||||
* PENTING: Relasi ke Medicine tunggal (Legacy Support).
|
||||
* Ini yang memperbaiki error 'RelationNotFoundException' pada data lama.
|
||||
*/
|
||||
public function medicine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Medicine::class, 'medicine_id')->withDefault([
|
||||
'nama_obat' => $this->nama_obat ?? 'Obat Manual'
|
||||
]);
|
||||
return $this->belongsTo(Medicine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Detail Obat (Sistem Baru Multi-Medicine).
|
||||
*/
|
||||
public function details(): HasMany
|
||||
{
|
||||
return $this->hasMany(TreatmentDetail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi langsung ke banyak Medicine (Many-to-Many via pivot details).
|
||||
*/
|
||||
public function medicines(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Medicine::class, 'treatment_details')
|
||||
->withPivot('dosis', 'keterangan_pakai')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -84,7 +100,6 @@ class Treatment extends Model
|
||||
|
||||
/**
|
||||
* Mengambil URL foto bukti.
|
||||
* Cara akses di Blade: {{ $treatment->foto_url }}
|
||||
*/
|
||||
public function getFotoUrlAttribute(): string
|
||||
{
|
||||
@@ -95,6 +110,24 @@ class Treatment extends Model
|
||||
return Storage::url($this->foto_bukti);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menghitung Progres Pengobatan secara otomatis.
|
||||
* Cara pakai di Blade: {{ $treatment->status_progress }}
|
||||
*/
|
||||
public function getStatusProgressAttribute(): string
|
||||
{
|
||||
$text = strtolower($this->diagnosa . ' ' . $this->keterangan);
|
||||
|
||||
// Keyword indikator membaik
|
||||
$improvingKeywords = ['baik', 'makan', 'aktif', 'sembuh', 'lincah', 'normal', 'turun panas'];
|
||||
|
||||
if (Str::contains($text, $improvingKeywords)) {
|
||||
return 'MEMBAIK';
|
||||
}
|
||||
|
||||
return 'STUCK';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SCOPES */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -106,4 +139,14 @@ class Treatment extends Model
|
||||
{
|
||||
$query->orderBy('tanggal', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope khusus untuk mengambil rekam medis yang sedang aktif (Under Treatment).
|
||||
*/
|
||||
public function scopeActiveTreatment(Builder $query): Builder
|
||||
{
|
||||
return $query->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TreatmentDetail extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Menentukan kolom yang boleh diisi secara massal
|
||||
protected $fillable = [
|
||||
'treatment_id',
|
||||
'medicine_id',
|
||||
'dosis',
|
||||
'keterangan_pakai',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relasi balik ke Header Treatment (Rekam Medis Utama)
|
||||
*/
|
||||
public function treatment()
|
||||
{
|
||||
return $this->belongsTo(Treatment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke data Obat (Inventory)
|
||||
*/
|
||||
public function medicine()
|
||||
{
|
||||
return $this->belongsTo(Medicine::class);
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -8,12 +8,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo; // Tambahan: Import untuk relasi
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; // Tambahkan ini
|
||||
use Laragear\WebAuthn\WebAuthnAuthentication;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasPermissions; // Tambahan: Gunakan HasPermissions
|
||||
|
||||
use WebAuthnAuthentication;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
@@ -26,6 +30,8 @@ class User extends Authenticatable
|
||||
'role', // Kode existing Anda
|
||||
'role_id', // Tambahan: ID untuk relasi tabel Role
|
||||
'phone',
|
||||
'is_active',
|
||||
'deactivated_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -48,6 +54,8 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
'deactivated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -55,23 +63,25 @@ class User extends Authenticatable
|
||||
* Tambahan: Relasi ke tabel Roles
|
||||
* Setiap user memiliki satu Role.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
/**
|
||||
* Relasi role memakai nama roleModel agar tidak bertabrakan dengan kolom legacy `role`.
|
||||
*/
|
||||
public function roleModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
return $this->belongsTo(Role::class, 'role_id');
|
||||
}
|
||||
|
||||
public function hasAccess($module, $permission)
|
||||
public function hasAccess($module, $permission): bool
|
||||
{
|
||||
// Jika admin, berikan akses penuh secara otomatis
|
||||
if ($this->role && $this->role->slug === 'admin') {
|
||||
$role = $this->roleModel;
|
||||
if ($role?->slug === 'admin' || $this->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cek di tabel role_permissions melalui relasi role
|
||||
return $this->role->permissions()
|
||||
return $role?->permissions()
|
||||
->where('module_slug', $module)
|
||||
->where($permission, 1) // $permission berisi 'can_read', 'can_update', dll
|
||||
->exists();
|
||||
->where($permission, 1)
|
||||
->exists() ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,5 +6,27 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WeightHistory extends Model
|
||||
{
|
||||
//
|
||||
// Pastikan fillable sesuai dengan kolom di database
|
||||
protected $fillable = [
|
||||
'cat_id',
|
||||
'berat',
|
||||
'tanggal_timbang', // Pastikan baris ini ada
|
||||
'created_at',
|
||||
'updated_at'
|
||||
];
|
||||
|
||||
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tanggal_timbang' => 'date',
|
||||
'berat' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function cat()
|
||||
{
|
||||
return $this->belongsTo(Cat::class);
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,18 @@ use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate; // Import Gate untuk sistem permission
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void { /* ... */ }
|
||||
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
|
||||
if (config('app.env') === 'production') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
// 1. DAFTARKAN OBSERVERS (Tetap Utuh & Aman)
|
||||
if (Schema::hasTable('medicines')) {
|
||||
Medicine::observe(MedicineObserver::class);
|
||||
@@ -53,26 +58,30 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// 4. SISTEM OTORISASI GLOBAL (Gate Permission)
|
||||
Gate::define('check-access', function ($user, $module, $permission) {
|
||||
// Hindari pengecekan jika user tidak memiliki role_id atau data role
|
||||
if (!$user->role_id && !$user->role) return false;
|
||||
|
||||
// Deteksi Role: Apakah string (lama) atau object (baru)
|
||||
// Jika $user->role adalah string, ambil langsung. Jika object, ambil slug-nya.
|
||||
$roleIdentifier = is_object($user->role) ? $user->role->slug : $user->role;
|
||||
|
||||
// Admin Bypass: Role admin memiliki akses ke seluruh fitur
|
||||
if ($roleIdentifier === 'admin') {
|
||||
// Dukungan akun legacy yang masih menyimpan role sebagai string.
|
||||
if ($user->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$user->role_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->roleModel;
|
||||
if ($role?->slug === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Menyelaraskan nama kolom (can_read, can_create, can_update, can_delete)
|
||||
$column = str_starts_with($permission, 'can_') ? $permission : 'can_' . $permission;
|
||||
|
||||
// Cek matriks di tabel role_permissions berdasarkan role_id
|
||||
try {
|
||||
return \App\Models\RolePermission::where('role_id', $user->role_id)
|
||||
->where('module_slug', $module)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
trait HasPermissions {
|
||||
public function hasPermission($moduleSlug, $action) {
|
||||
// Jika user tidak punya role, akses ditolak
|
||||
if (!$this->role) return false;
|
||||
|
||||
// Cek ke tabel permission_role
|
||||
return $this->role->permissions()
|
||||
trait HasPermissions
|
||||
{
|
||||
public function hasPermission($moduleSlug, $action): bool
|
||||
{
|
||||
if ($this->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
$role = $this->roleModel;
|
||||
if (!$role) return false;
|
||||
$column = str_starts_with($action, 'can_') ? $action : 'can_' . $action;
|
||||
return $role->permissions()
|
||||
->where('module_slug', $moduleSlug)
|
||||
->where($action, 1)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
+19
-6
@@ -4,27 +4,40 @@ use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use App\Http\Middleware\AdminMiddleware;
|
||||
use App\Http\Middleware\AuthenticateApiToken;
|
||||
use App\Http\Middleware\EnsureUserIsActive;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
|
||||
// 1. Mendaftarkan alias middleware (Existing & Optimized)
|
||||
// 1. Mendaftarkan alias middleware
|
||||
$middleware->alias([
|
||||
'admin' => AdminMiddleware::class,
|
||||
'mobile.token' => AuthenticateApiToken::class,
|
||||
'active.user' => EnsureUserIsActive::class,
|
||||
]);
|
||||
|
||||
// 2. Mengarahkan Tamu (Guest) secara global ke halaman login jika mencoba akses rute terproteksi
|
||||
// 2. Mengarahkan Tamu ke halaman login
|
||||
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||
|
||||
// 3. Mengarahkan User yang sudah login ke Dashboard jika mencoba mengakses halaman login kembali
|
||||
// 3. Mengarahkan User login ke Dashboard
|
||||
$middleware->redirectUsersTo(fn () => route('dashboard'));
|
||||
|
||||
// 4. FIX HTTPS & PROXY (Sangat penting untuk Hostinger agar tidak 500/Loop)
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
// 5. PENGECEK CSRF (Buka ini jika masih error 419 saat pendaftaran biometrik)
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'webauthn/*',
|
||||
]);
|
||||
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
// Di sini Anda bisa menambahkan penanganan error kustom jika diperlukan di masa depan
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
})->create();
|
||||
@@ -10,6 +10,7 @@
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"intervention/image": "^3.11",
|
||||
"intervention/image-laravel": "^1.5",
|
||||
"laragear/webauthn": "^5.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
|
||||
Generated
+166
-11
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "caa17cbea90f285d9dc19b2eb78ab1e9",
|
||||
"content-hash": "a293add0741bc2bc83272d5a80311add",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1833,6 +1833,161 @@
|
||||
],
|
||||
"time": "2025-04-04T15:09:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laragear/meta-model",
|
||||
"version": "v3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Laragear/MetaModel.git",
|
||||
"reference": "5f6f15e390c934cb10068117555242c2d29e9caa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Laragear/MetaModel/zipball/5f6f15e390c934cb10068117555242c2d29e9caa",
|
||||
"reference": "5f6f15e390c934cb10068117555242c2d29e9caa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/container": "12.*|13.*",
|
||||
"illuminate/contracts": "12.*|13.*",
|
||||
"illuminate/database": "12.*|13.*",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"phpunit/phpunit": "12.*|13.*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laragear\\MetaModel\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Italo Israel Baeza Cabrera",
|
||||
"email": "DarkGhostHunter@Gmail.com",
|
||||
"homepage": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Let other developers customize your package model and migrations",
|
||||
"keywords": [
|
||||
"database",
|
||||
"eloquent",
|
||||
"laravel",
|
||||
"model"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Laragear/MetaModel/issues",
|
||||
"source": "https://github.com/Laragear/MetaModel"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"type": "Github Sponsorship"
|
||||
},
|
||||
{
|
||||
"url": "https://paypal.me/darkghosthunter",
|
||||
"type": "Paypal"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-04T16:50:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laragear/webauthn",
|
||||
"version": "v5.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Laragear/WebAuthn.git",
|
||||
"reference": "134eec9e6655b56e6ea5fb63f8174195c5ef55d3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Laragear/WebAuthn/zipball/134eec9e6655b56e6ea5fb63f8174195c5ef55d3",
|
||||
"reference": "134eec9e6655b56e6ea5fb63f8174195c5ef55d3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"illuminate/auth": "12.*|13.*",
|
||||
"illuminate/config": "12.*|13.*",
|
||||
"illuminate/database": "12.*|13.*",
|
||||
"illuminate/encryption": "12.*|13.*",
|
||||
"illuminate/http": "12.*|13.*",
|
||||
"illuminate/session": "12.*|13.*",
|
||||
"illuminate/support": "12.*|13.*",
|
||||
"laragear/meta-model": "3.*",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-sodium": "*",
|
||||
"orchestra/testbench": "10.*|11.*"
|
||||
},
|
||||
"suggest": {
|
||||
"paragonie/sodium_compat": "To enable EdDSA 25519 keys from authenticators, if `ext-sodium` is unavailable."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laragear\\WebAuthn\\WebAuthnServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laragear\\WebAuthn\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Lukas Buchs",
|
||||
"role": "Original developer"
|
||||
},
|
||||
{
|
||||
"name": "Italo Israel Baeza Cabrera",
|
||||
"email": "DarkGhostHunter@Gmail.com",
|
||||
"homepage": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Authenticate users with Passkeys: fingerprints, patterns and biometric data.",
|
||||
"homepage": "https://github.com/laragear/webauthn",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"faceid",
|
||||
"laravel",
|
||||
"passkeys",
|
||||
"touchid",
|
||||
"webauthn",
|
||||
"windows hello"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Laragear/WebAuthn/issues",
|
||||
"source": "https://github.com/Laragear/WebAuthn"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"type": "Github Sponsorship"
|
||||
},
|
||||
{
|
||||
"url": "https://paypal.me/darkghosthunter",
|
||||
"type": "Paypal"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-06T22:00:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.48.1",
|
||||
@@ -2802,29 +2957,29 @@
|
||||
},
|
||||
{
|
||||
"name": "maatwebsite/excel",
|
||||
"version": "3.1.67",
|
||||
"version": "3.1.68",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
|
||||
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
|
||||
"reference": "1854739267d81d38eae7d8c623caf523f30f256b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/1854739267d81d38eae7d8c623caf523f30f256b",
|
||||
"reference": "1854739267d81d38eae7d8c623caf523f30f256b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/semver": "^3.3",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
|
||||
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0",
|
||||
"php": "^7.0||^8.0",
|
||||
"phpoffice/phpspreadsheet": "^1.30.0",
|
||||
"psr/simple-cache": "^1.0||^2.0||^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
|
||||
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
|
||||
"laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0",
|
||||
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
|
||||
"predis/predis": "^1.1"
|
||||
},
|
||||
"type": "library",
|
||||
@@ -2867,7 +3022,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
|
||||
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
|
||||
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.68"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2879,7 +3034,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-26T09:13:16+00:00"
|
||||
"time": "2026-03-17T20:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
@@ -9931,5 +10086,5 @@
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
+9
-1
@@ -61,8 +61,9 @@ return [
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'driver' => 'eloquent-webauthn',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
'password_fallback' => true,
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
@@ -71,6 +72,13 @@ return [
|
||||
// ],
|
||||
],
|
||||
|
||||
|
||||
'relying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', 'Ily Cattery'),
|
||||
'id' => env('WEBAUTHN_ID', 'ilycats.earrinasia.com'), // Pastikan ini pas
|
||||
'icon' => null,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|
||||
@@ -28,6 +28,15 @@ return [
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
|
||||
'whatsapp' => [
|
||||
'provider' => env('WHATSAPP_PROVIDER', 'fonnte'),
|
||||
'fonnte_token' => env('FONNTE_TOKEN'),
|
||||
'waha_url' => env('WAHA_URL'),
|
||||
'waha_session' => env('WAHA_SESSION', 'default'),
|
||||
'waha_api_key' => env('WAHA_API_KEY'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relying Party
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| We will use your application information to inform the device who is the
|
||||
| relying party. While only the name is enough, you can further set the
|
||||
| a custom domain as ID and even an icon image data encoded as BASE64.
|
||||
|
|
||||
*/
|
||||
|
||||
'relying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', config('app.name')),
|
||||
'id' => env('WEBAUTHN_ID'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Origins
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, only your application domain is used as a valid origin for
|
||||
| all ceremonies. If you are using your app as a backend for an app or
|
||||
| UI you may set additional origins to check against the ceremonies.
|
||||
|
|
||||
| For multiple origins, separate them using comma, like `foo,bar`.
|
||||
*/
|
||||
|
||||
'origins' => env('WEBAUTHN_ORIGINS'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Challenge configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When making challenges your application needs to push at least 16 bytes
|
||||
| of randomness. Since we need to later check them, we'll also store the
|
||||
| bytes for a small amount of time inside this current request session.
|
||||
|
|
||||
| @see https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges
|
||||
|
|
||||
*/
|
||||
|
||||
'challenge' => [
|
||||
'bytes' => 16,
|
||||
'timeout' => 60,
|
||||
'key' => '_webauthn',
|
||||
],
|
||||
];
|
||||
@@ -20,8 +20,6 @@ return new class extends Migration
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
// No-op: kolom deleted_at dimiliki migration legacy sebelumnya.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('breeding_tables');
|
||||
Schema::dropIfExists('kittens');
|
||||
Schema::dropIfExists('mattings');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('medical_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Relasi ke kucing
|
||||
$table->foreignId('cat_id')->constrained()->onDelete('cascade');
|
||||
|
||||
// Jenis tindakan: Vaksin, Obat Cacing, Kutu, Grooming
|
||||
$table->enum('type', ['Vaccine', 'Deworming', 'Flea Treatment', 'Grooming', 'Medical Checkup']);
|
||||
|
||||
$table->string('treatment_name'); // Misal: Felocell 4, Drontal, NexGard
|
||||
$table->date('date_given'); // Tanggal pemberian
|
||||
$table->date('next_schedule')->nullable(); // Tanggal jadwal berikutnya
|
||||
|
||||
$table->text('notes')->nullable(); // Catatan tambahan (misal: reaksi setelah vaksin)
|
||||
|
||||
// PIC yang memberikan tindakan
|
||||
$table->foreignId('performed_by')->nullable()->constrained('users')->onDelete('set null');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
Schema::dropIfExists('medical_records');
|
||||
}
|
||||
};
|
||||
@@ -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(): void
|
||||
{
|
||||
Schema::table('medical_records', function (Blueprint $table) {
|
||||
$table->string('dosage')->nullable()->after('treatment_name'); // Menyimpan "1/2", "0.5", atau "1"
|
||||
$table->string('unit')->nullable()->after('dosage'); // Menyimpan "pcs", "tablet", atau "ml"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('medical_records', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('cats', function (Blueprint $table) {
|
||||
$table->string('sertifikat_ped_file')->nullable()->after('pedigree');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
return WebAuthnCredential::migration()->with(function (Blueprint $table) {
|
||||
// Here you can add custom columns to the Two Factor table.
|
||||
//
|
||||
// $table->string('alias')->nullable();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Migration legacy duplikat. Tabel medical_records sudah dibuat oleh migration sebelumnya.
|
||||
* Sengaja no-op agar fresh migration dan rollback tidak menghapus tabel yang benar.
|
||||
*/
|
||||
public function up(): void {}
|
||||
|
||||
public function down(): void {}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?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('medicines', function (Blueprint $table) {
|
||||
// Tambahkan kolom tanggal expired setelah unit
|
||||
$table->date('expired_at')->nullable()->after('unit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('medicines', function (Blueprint $table) {
|
||||
$table->dropColumn('expired_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?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('foods', function (Blueprint $table) {
|
||||
$table->date('expired_at')->nullable()->after('unit');
|
||||
});
|
||||
|
||||
Schema::table('supplies', function (Blueprint $table) {
|
||||
$table->date('expired_at')->nullable()->after('unit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('foods', function (Blueprint $table) {
|
||||
$table->dropColumn('expired_at');
|
||||
});
|
||||
|
||||
Schema::table('supplies', function (Blueprint $table) {
|
||||
$table->dropColumn('expired_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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('cats', function (Blueprint $table) {
|
||||
// Menambahkan kolom treatment_status
|
||||
// Default 2 (Healthy/Sehat)
|
||||
// Tipe integer agar ringan, diletakkan setelah kolom status yang sudah ada
|
||||
$table->integer('treatment_status')->default(2)->after('status')->comment('1: Under Treatment, 2: Healthy');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
$table->dropColumn('treatment_status');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('treatment_details', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Menghubungkan ke tabel rekam medis utama
|
||||
$table->foreignId('treatment_id')->constrained('treatments')->onDelete('cascade');
|
||||
|
||||
// Menghubungkan ke tabel inventory obat (medicines)
|
||||
// Jika kolom di tabel medicines adalah 'id', gunakan foreignId
|
||||
$table->foreignId('medicine_id')->constrained('medicines');
|
||||
|
||||
$table->string('dosis')->nullable(); // Contoh: 0.5ml atau 1 tablet
|
||||
$table->string('keterangan_pakai')->nullable(); // Contoh: Campur Nebu, Sesudah makan
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('treatment_details');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('treatments', function (Blueprint $table) {
|
||||
// Menghapus kolom obat lama karena sudah digantikan oleh tabel detail
|
||||
// Pastikan kolom ini memang ada sebelum dijalankan
|
||||
if (Schema::hasColumn('treatments', 'nama_obat')) {
|
||||
$table->dropColumn(['nama_obat', 'dosis', 'keterangan']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('treatments', function (Blueprint $table) {
|
||||
// Mengembalikan kolom jika rollback (opsional)
|
||||
$table->string('nama_obat')->nullable();
|
||||
$table->string('dosis')->nullable();
|
||||
$table->text('keterangan')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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('treatments', function (Blueprint $table) {
|
||||
$table->integer('status_perawatan')->default(1);
|
||||
$table->text('remark_pasca_sakit')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('treatments', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cat_associations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code', 30)->unique();
|
||||
$table->string('name');
|
||||
$table->string('country')->nullable();
|
||||
$table->string('website')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('cat_registrations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cat_id')->constrained('cats')->cascadeOnDelete();
|
||||
$table->foreignId('association_id')->constrained('cat_associations')->restrictOnDelete();
|
||||
$table->string('registration_number')->nullable();
|
||||
$table->string('pedigree_number')->nullable();
|
||||
$table->string('certificate_number')->nullable();
|
||||
$table->date('issue_date')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('ownership_status', 40)->nullable();
|
||||
$table->boolean('is_primary')->default(false);
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['cat_id', 'association_id'], 'cat_registration_association_unique');
|
||||
$table->index(['cat_id', 'is_primary']);
|
||||
});
|
||||
|
||||
Schema::create('cat_registration_documents', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cat_registration_id')->constrained('cat_registrations')->cascadeOnDelete();
|
||||
$table->string('label')->default('Sertifikat Pedigree');
|
||||
$table->string('document_type', 40)->default('pedigree');
|
||||
$table->string('file_path');
|
||||
$table->date('issued_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
$associations = [
|
||||
['code' => 'ICA', 'name' => 'Indonesian Cat Association', 'country' => 'Indonesia'],
|
||||
['code' => 'CFA', 'name' => 'The Cat Fanciers\' Association', 'country' => 'Amerika Serikat'],
|
||||
['code' => 'WCF', 'name' => 'World Cat Federation', 'country' => 'Internasional'],
|
||||
['code' => 'FIFe', 'name' => 'Fédération Internationale Féline', 'country' => 'Internasional'],
|
||||
['code' => 'FFF', 'name' => 'Federasi Feline Indonesia', 'country' => 'Indonesia'],
|
||||
];
|
||||
|
||||
foreach ($associations as $association) {
|
||||
DB::table('cat_associations')->updateOrInsert(
|
||||
['code' => $association['code']],
|
||||
array_merge($association, ['is_active' => 1, 'created_at' => now(), 'updated_at' => now()])
|
||||
);
|
||||
}
|
||||
|
||||
// Migrasi aman dari field pedigree/registry lama. Field lama tidak dihapus agar rollback mudah.
|
||||
if (Schema::hasTable('cats')) {
|
||||
DB::table('cats')->orderBy('id')->chunkById(100, function ($cats) {
|
||||
foreach ($cats as $cat) {
|
||||
$rawRegistry = $cat->registry ?? null;
|
||||
$registries = [];
|
||||
if (is_string($rawRegistry) && $rawRegistry !== '') {
|
||||
$decoded = json_decode($rawRegistry, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$registries = is_array($decoded) ? $decoded : [$decoded];
|
||||
} else {
|
||||
$registries = preg_split('/[,;|]+/', $rawRegistry) ?: [];
|
||||
}
|
||||
}
|
||||
$registries = array_values(array_unique(array_filter(array_map(fn($v) => trim((string)$v), $registries), fn($v) => $v && strtoupper($v) !== 'NON-PED')));
|
||||
|
||||
// Bila nomor pedigree lama ada tetapi registry kosong, simpan di asosiasi LEGACY.
|
||||
if (!$registries && !empty($cat->pedigree)) {
|
||||
$registries = ['LEGACY'];
|
||||
}
|
||||
|
||||
foreach ($registries as $index => $code) {
|
||||
$code = strtoupper($code);
|
||||
$associationId = DB::table('cat_associations')->whereRaw('UPPER(code) = ?', [$code])->value('id');
|
||||
if (!$associationId) {
|
||||
$associationId = DB::table('cat_associations')->insertGetId([
|
||||
'code' => $code,
|
||||
'name' => $code === 'LEGACY' ? 'Registrasi Lama' : $code,
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$registrationId = DB::table('cat_registrations')->insertGetId([
|
||||
'cat_id' => $cat->id,
|
||||
'association_id' => $associationId,
|
||||
'registration_number' => $index === 0 ? ($cat->pedigree ?? null) : null,
|
||||
'pedigree_number' => $index === 0 ? ($cat->pedigree ?? null) : null,
|
||||
'is_primary' => $index === 0 ? 1 : 0,
|
||||
'notes' => 'Dimigrasikan otomatis dari data registrasi lama.',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($index === 0 && !empty($cat->sertifikat_ped_file)) {
|
||||
DB::table('cat_registration_documents')->insert([
|
||||
'cat_registration_id' => $registrationId,
|
||||
'label' => 'Sertifikat lama',
|
||||
'document_type' => 'pedigree',
|
||||
'file_path' => $cat->sertifikat_ped_file,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cat_registration_documents');
|
||||
Schema::dropIfExists('cat_registrations');
|
||||
Schema::dropIfExists('cat_associations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('name')->default('Android');
|
||||
$table->string('token_hash', 64)->unique();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_tokens');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('cats', 'cattery_status')) {
|
||||
$table->string('cattery_status', 30)->default('active')->after('status')->index();
|
||||
}
|
||||
if (!Schema::hasColumn('cats', 'adopted_at')) {
|
||||
$table->date('adopted_at')->nullable()->after('cattery_status');
|
||||
}
|
||||
if (!Schema::hasColumn('cats', 'adoption_notes')) {
|
||||
$table->text('adoption_notes')->nullable()->after('adopted_at');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('users', 'is_active')) {
|
||||
$table->boolean('is_active')->default(true)->after('phone')->index();
|
||||
}
|
||||
if (!Schema::hasColumn('users', 'deactivated_at')) {
|
||||
$table->timestamp('deactivated_at')->nullable()->after('is_active');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('mattings', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('mattings', 'pregnancy_confirmed_at')) {
|
||||
$table->date('pregnancy_confirmed_at')->nullable()->after('status');
|
||||
}
|
||||
if (!Schema::hasColumn('mattings', 'actual_birth_date')) {
|
||||
$table->date('actual_birth_date')->nullable()->after('pregnancy_confirmed_at');
|
||||
}
|
||||
if (!Schema::hasColumn('mattings', 'breeding_notes')) {
|
||||
$table->text('breeding_notes')->nullable()->after('actual_birth_date');
|
||||
}
|
||||
});
|
||||
|
||||
if (!Schema::hasTable('notification_dispatches')) {
|
||||
Schema::create('notification_dispatches', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('notification_key', 190)->unique();
|
||||
$table->string('type', 40)->index();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('channel', 20)->default('whatsapp');
|
||||
$table->timestamp('sent_at')->nullable();
|
||||
$table->text('message')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('settings')) {
|
||||
$adminRoleId = Schema::hasTable('roles')
|
||||
? DB::table('roles')->where('slug', 'admin')->value('id')
|
||||
: null;
|
||||
|
||||
$defaults = [
|
||||
['key' => 'notif_wa_status', 'value' => 'active', 'group' => 'notification'],
|
||||
['key' => 'notif_hpl_wa', 'value' => 'active', 'group' => 'notification'],
|
||||
['key' => 'notif_inventory_expiry_wa', 'value' => 'active', 'group' => 'notification'],
|
||||
['key' => 'notif_weight_stale_wa', 'value' => 'active', 'group' => 'notification'],
|
||||
['key' => 'notif_hpl_role_id', 'value' => $adminRoleId, 'group' => 'notification'],
|
||||
['key' => 'notif_inventory_role_id', 'value' => $adminRoleId, 'group' => 'notification'],
|
||||
['key' => 'notif_weight_role_id', 'value' => $adminRoleId, 'group' => 'notification'],
|
||||
];
|
||||
|
||||
foreach ($defaults as $item) {
|
||||
DB::table('settings')->updateOrInsert(
|
||||
['key' => $item['key']],
|
||||
[
|
||||
'value' => $item['value'],
|
||||
'group' => $item['group'],
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notification_dispatches');
|
||||
|
||||
Schema::table('mattings', function (Blueprint $table) {
|
||||
foreach (['pregnancy_confirmed_at', 'actual_birth_date', 'breeding_notes'] as $column) {
|
||||
if (Schema::hasColumn('mattings', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
foreach (['is_active', 'deactivated_at'] as $column) {
|
||||
if (Schema::hasColumn('users', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
foreach (['cattery_status', 'adopted_at', 'adoption_notes'] as $column) {
|
||||
if (Schema::hasColumn('cats', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
-6
@@ -1409,7 +1409,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2670,7 +2669,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3095,7 +3093,6 @@
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -3216,7 +3213,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3305,7 +3301,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -3410,7 +3405,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Kucing">
|
||||
<rect width="128" height="128" rx="28" fill="#f1f5f9"/>
|
||||
<path d="M35 48 25 27l25 12a47 47 0 0 1 28 0l25-12-10 21c7 8 11 18 11 29 0 23-18 36-40 36S24 100 24 77c0-11 4-21 11-29Z" fill="#cbd5e1"/>
|
||||
<path d="M42 74c4-4 9-4 13 0M73 74c4-4 9-4 13 0" fill="none" stroke="#475569" stroke-width="5" stroke-linecap="round"/>
|
||||
<path d="M59 84c3 3 7 3 10 0" fill="none" stroke="#475569" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="m60 78 4 3 4-3" fill="#64748b"/>
|
||||
<path d="M42 86 19 82M43 92 21 96M86 86l23-4M85 92l22 4" stroke="#94a3b8" stroke-width="3" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 686 B |
+9
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+142
@@ -0,0 +1,142 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Kompatibilitas SweetAlert tanpa CDN eksternal.
|
||||
window.Swal = window.Swal || {
|
||||
fire: function (options) {
|
||||
options = options || {};
|
||||
if (typeof options === 'string') options = { title: options };
|
||||
var title = options.title || '';
|
||||
var rawText = options.text || options.html || '';
|
||||
var text = String(rawText).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
var message = [title, text].filter(Boolean).join('\n\n');
|
||||
|
||||
if (options.showCancelButton) {
|
||||
var ok = window.confirm(message || 'Lanjutkan?');
|
||||
return Promise.resolve({ isConfirmed: ok, isDismissed: !ok });
|
||||
}
|
||||
|
||||
if (message) window.alert(message);
|
||||
return Promise.resolve({ isConfirmed: true });
|
||||
}
|
||||
};
|
||||
|
||||
// Renderer line chart ringan sebagai pengganti Chart.js CDN untuk chart legacy ILYCats.
|
||||
if (!window.Chart) {
|
||||
window.Chart = function SimpleChart(target, config) {
|
||||
this.canvas = target && target.canvas ? target.canvas : target;
|
||||
this.config = config || {};
|
||||
var self = this;
|
||||
this._resize = function () { self.draw(); };
|
||||
window.addEventListener('resize', this._resize);
|
||||
this.draw();
|
||||
};
|
||||
|
||||
window.Chart.prototype.destroy = function () {
|
||||
window.removeEventListener('resize', this._resize);
|
||||
};
|
||||
|
||||
window.Chart.prototype.draw = function () {
|
||||
var canvas = this.canvas;
|
||||
var config = this.config || {};
|
||||
if (!canvas || !canvas.getContext || config.type !== 'line') return;
|
||||
|
||||
var labels = (config.data && config.data.labels) || [];
|
||||
var datasets = (config.data && config.data.datasets) || [];
|
||||
if (!labels.length || !datasets.length) return;
|
||||
|
||||
var parent = canvas.parentElement;
|
||||
var width = Math.max(280, (parent && parent.clientWidth) || canvas.clientWidth || 600);
|
||||
var height = Math.max(220, (parent && parent.clientHeight) || canvas.clientHeight || 300);
|
||||
var dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr;
|
||||
canvas.height = height * dpr;
|
||||
canvas.style.width = width + 'px';
|
||||
canvas.style.height = height + 'px';
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
var pad = { left: 44, right: 18, top: 16, bottom: 36 };
|
||||
var chartW = width - pad.left - pad.right;
|
||||
var chartH = height - pad.top - pad.bottom;
|
||||
var allValues = [];
|
||||
datasets.forEach(function (ds) {
|
||||
(ds.data || []).forEach(function (v) {
|
||||
if (v !== null && v !== undefined && !isNaN(Number(v))) allValues.push(Number(v));
|
||||
});
|
||||
});
|
||||
if (!allValues.length) return;
|
||||
|
||||
var minY = Math.min.apply(Math, allValues);
|
||||
var maxY = Math.max.apply(Math, allValues);
|
||||
var beginAtZero = !!(config.options && config.options.scales && config.options.scales.y && config.options.scales.y.beginAtZero === true);
|
||||
if (beginAtZero) minY = 0;
|
||||
if (minY === maxY) { minY = Math.max(0, minY - 1); maxY += 1; }
|
||||
var margin = (maxY - minY) * 0.12;
|
||||
if (!beginAtZero) minY = Math.max(0, minY - margin);
|
||||
maxY += margin;
|
||||
|
||||
var xAt = function (i) {
|
||||
return pad.left + (labels.length <= 1 ? chartW / 2 : (i / (labels.length - 1)) * chartW);
|
||||
};
|
||||
var yAt = function (v) {
|
||||
return pad.top + chartH - ((Number(v) - minY) / (maxY - minY)) * chartH;
|
||||
};
|
||||
|
||||
ctx.font = '11px system-ui, -apple-system, Segoe UI, sans-serif';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (var i = 0; i <= 4; i++) {
|
||||
var value = minY + (maxY - minY) * (i / 4);
|
||||
var y = yAt(value);
|
||||
ctx.strokeStyle = '#e2e8f0';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad.left, y);
|
||||
ctx.lineTo(pad.left + chartW, y);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText(value.toFixed(value < 10 ? 1 : 0), pad.left - 7, y);
|
||||
}
|
||||
|
||||
var labelStep = Math.max(1, Math.ceil(labels.length / 6));
|
||||
labels.forEach(function (label, idx) {
|
||||
if (idx % labelStep !== 0 && idx !== labels.length - 1) return;
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(label), xAt(idx), height - 15);
|
||||
});
|
||||
|
||||
var palette = ['#2563eb', '#059669', '#db2777', '#d97706', '#7c3aed', '#0891b2'];
|
||||
datasets.forEach(function (ds, index) {
|
||||
var color = ds.borderColor || palette[index % palette.length];
|
||||
var values = ds.data || [];
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = Number(ds.borderWidth || 2.5);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
var started = false;
|
||||
values.forEach(function (v, idx) {
|
||||
if (v === null || v === undefined) { started = false; return; }
|
||||
var x = xAt(idx), y = yAt(v);
|
||||
if (!started) { ctx.moveTo(x, y); started = true; } else { ctx.lineTo(x, y); }
|
||||
});
|
||||
ctx.stroke();
|
||||
|
||||
values.forEach(function (v, idx) {
|
||||
if (v === null || v === undefined) return;
|
||||
ctx.fillStyle = ds.pointBackgroundColor || '#fff';
|
||||
ctx.strokeStyle = ds.pointBorderColor || color;
|
||||
ctx.lineWidth = Number(ds.pointBorderWidth || 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(xAt(idx), yAt(v), Number(ds.pointRadius || 3), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
})();
|
||||
Vendored
+373
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Italo Israel Baeza Cabrera
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use Webpass instead
|
||||
* @see https://github.com/Laragear/webpass
|
||||
*/
|
||||
class WebAuthn {
|
||||
/**
|
||||
* Routes for WebAuthn assertion (login) and attestation (register).
|
||||
*
|
||||
* @type {{registerOptions: string, register: string, loginOptions: string, login: string, }}
|
||||
*/
|
||||
#routes = {
|
||||
registerOptions: "webauthn/register/options",
|
||||
register: "webauthn/register",
|
||||
loginOptions: "webauthn/login/options",
|
||||
login: "webauthn/login",
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to use in ALL requests done.
|
||||
*
|
||||
* @type {{Accept: string, "Content-Type": string, "X-Requested-With": string}}
|
||||
*/
|
||||
#headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
|
||||
/**
|
||||
* If set to true, the credentials option will be set to 'include' on all fetch calls,
|
||||
* or else it will use the default 'same-origin'. Use this if the backend is not the
|
||||
* same origin as the client or the XSRF protection will break without the session.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#includeCredentials = false
|
||||
|
||||
/**
|
||||
* Create a new WebAuthn instance.
|
||||
*
|
||||
* @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}}
|
||||
* @param headers {{string}}
|
||||
* @param includeCredentials {boolean}
|
||||
* @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars)
|
||||
*/
|
||||
constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) {
|
||||
console.warn('This WebAuthn Helper is deprecated and will be removed in the future. Consider migrating to @laragear/webpass')
|
||||
|
||||
Object.assign(this.#routes, routes);
|
||||
Object.assign(this.#headers, headers);
|
||||
|
||||
this.#includeCredentials = includeCredentials;
|
||||
|
||||
let xsrfToken;
|
||||
let csrfToken;
|
||||
|
||||
if (xcsrfToken === null) {
|
||||
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
||||
xsrfToken = WebAuthn.#XsrfToken;
|
||||
csrfToken = WebAuthn.#firstInputWithCsrfToken;
|
||||
} else {
|
||||
// Check if it is a CSRF or XSRF token
|
||||
if (xcsrfToken.length === 40) {
|
||||
csrfToken = xcsrfToken;
|
||||
} else if (xcsrfToken.length === 224) {
|
||||
xsrfToken = xcsrfToken;
|
||||
} else {
|
||||
throw new TypeError('CSRF token or XSRF token provided does not match requirements. Must be 40 or 224 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
if (xsrfToken !== null) {
|
||||
this.#headers["X-XSRF-TOKEN"] ??= xsrfToken;
|
||||
} else if (csrfToken !== null) {
|
||||
this.#headers["X-CSRF-TOKEN"] ??= csrfToken;
|
||||
} else {
|
||||
// We didn't find it, and since is required, we will bail out.
|
||||
throw new TypeError('Ensure a CSRF/XSRF token is manually set, or provided in a cookie "XSRF-TOKEN" or or there is meta tag named "csrf-token".');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSRF token if it exists as a form input tag.
|
||||
*
|
||||
* @returns string
|
||||
* @throws TypeError
|
||||
*/
|
||||
static get #firstInputWithCsrfToken() {
|
||||
// First, try finding an CSRF Token in the head.
|
||||
let token = Array.from(document.head.getElementsByTagName("meta"))
|
||||
.find(element => element.name === "csrf-token");
|
||||
|
||||
if (token) {
|
||||
return token.content;
|
||||
}
|
||||
|
||||
// Then, try to find a hidden input containing the CSRF token.
|
||||
token = Array.from(document.getElementsByTagName('input'))
|
||||
.find(input => input.name === "_token" && input.type === "hidden")
|
||||
|
||||
if (token) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the XSRF token if it exists in a cookie.
|
||||
*
|
||||
* Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2
|
||||
*
|
||||
* @returns {?string}
|
||||
*/
|
||||
static get #XsrfToken() {
|
||||
const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row));
|
||||
// We must remove all '%3D' from the end of the string.
|
||||
// Background:
|
||||
// The actual binary value of the CSFR value is encoded in Base64.
|
||||
// If the length of original, binary value is not a multiple of 3 bytes,
|
||||
// the encoding gets padded with `=` on the right; i.e. there might be
|
||||
// zero, one or two `=` at the end of the encoded value.
|
||||
// If the value is sent from the server to the client as part of a cookie,
|
||||
// the `=` character is URL-encoded as `%3D`, because `=` is already used
|
||||
// to separate a cookie key from its value.
|
||||
// When we send back the value to the server as part of an AJAX request,
|
||||
// Laravel expects an unpadded value.
|
||||
// Hence, we must remove the `%3D`.
|
||||
return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a fetch promise to resolve later.
|
||||
*
|
||||
* @param data {Object}
|
||||
* @param route {string}
|
||||
* @param headers {{string}}
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
#fetch(data, route, headers = {}) {
|
||||
const url = new URL(route, window.location.origin).href;
|
||||
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
credentials: this.#includeCredentials ? "include" : "same-origin",
|
||||
redirect: "error",
|
||||
headers: { ...this.#headers, ...headers },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
static #base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return atob(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param useAtob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static #uint8Array(input, useAtob = false) {
|
||||
return Uint8Array.from(
|
||||
useAtob ? atob(input) : WebAuthn.#base64UrlDecode(input), c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
static #arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
#parseIncomingServerOptions(publicKey) {
|
||||
console.debug(publicKey);
|
||||
|
||||
publicKey.challenge = WebAuthn.#uint8Array(publicKey.challenge);
|
||||
|
||||
if ('user' in publicKey) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: WebAuthn.#uint8Array(publicKey.user.id)
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
"excludeCredentials",
|
||||
"allowCredentials"
|
||||
]
|
||||
.filter(key => key in publicKey)
|
||||
.forEach(key => {
|
||||
publicKey[key] = publicKey[key].map(data => {
|
||||
return { ...data, id: WebAuthn.#uint8Array(data.id) };
|
||||
});
|
||||
});
|
||||
|
||||
console.log(publicKey);
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
#parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: WebAuthn.#arrayToBase64String(credentials.rawId),
|
||||
authenticatorAttachment: credentials.authenticatorAttachment,
|
||||
clientExtensionResults: credentials.getClientExtensionResults(),
|
||||
response: {},
|
||||
}
|
||||
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter(key => key in credentials.response)
|
||||
.forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key]));
|
||||
|
||||
return parseCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the Server.
|
||||
*
|
||||
* Throws the entire response if is not OK (HTTP 2XX).
|
||||
*
|
||||
* @param response {Response}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
* @throws Response
|
||||
*/
|
||||
static #handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Here we will do a small trick. Since most of the responses from the server
|
||||
// are JSON, we will automatically parse the JSON body from the response. If
|
||||
// it's not JSON, we will push the body verbatim and let the dev handle it.
|
||||
return new Promise((resolve) => {
|
||||
response
|
||||
.json()
|
||||
.then((json) => resolve(json))
|
||||
.catch(() => resolve(response.body));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user credentials from the browser/device.
|
||||
*
|
||||
* You can add request input if you are planning to register a user with WebAuthn from scratch.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async register(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.registerOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.create({ publicKey });
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
Object.assign(publicKeyCredential, request);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in a user with his credentials.
|
||||
*
|
||||
* If no credentials are given, the app may return a blank assertion for userless login.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async login(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.loginOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.get({ publicKey });
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
|
||||
console.log(publicKeyCredential);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.login, response).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser supports WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static supportsWebAuthn() {
|
||||
return typeof PublicKeyCredential != "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser doesn't support WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static doesntSupportWebAuthn() {
|
||||
return !this.supportsWebAuthn();
|
||||
}
|
||||
|
||||
}
|
||||
export default WebAuthn;
|
||||
@@ -1,3 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer components {
|
||||
.field-label { @apply mb-1.5 block text-xs font-medium text-slate-600; }
|
||||
.field-input { @apply w-full border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 shadow-none focus:border-indigo-500 focus:ring-indigo-500; }
|
||||
}
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Italo Israel Baeza Cabrera
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use Webpass instead
|
||||
* @see https://github.com/Laragear/webpass
|
||||
*/
|
||||
class WebAuthn {
|
||||
/**
|
||||
* Routes for WebAuthn assertion (login) and attestation (register).
|
||||
*
|
||||
* @type {{registerOptions: string, register: string, loginOptions: string, login: string, }}
|
||||
*/
|
||||
#routes = {
|
||||
registerOptions: "webauthn/register/options",
|
||||
register: "webauthn/register",
|
||||
loginOptions: "webauthn/login/options",
|
||||
login: "webauthn/login",
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to use in ALL requests done.
|
||||
*
|
||||
* @type {{Accept: string, "Content-Type": string, "X-Requested-With": string}}
|
||||
*/
|
||||
#headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
|
||||
/**
|
||||
* If set to true, the credentials option will be set to 'include' on all fetch calls,
|
||||
* or else it will use the default 'same-origin'. Use this if the backend is not the
|
||||
* same origin as the client or the XSRF protection will break without the session.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#includeCredentials = false
|
||||
|
||||
/**
|
||||
* Create a new WebAuthn instance.
|
||||
*
|
||||
* @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}}
|
||||
* @param headers {{string}}
|
||||
* @param includeCredentials {boolean}
|
||||
* @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars)
|
||||
*/
|
||||
constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) {
|
||||
console.warn('This WebAuthn Helper is deprecated and will be removed in the future. Consider migrating to @laragear/webpass')
|
||||
|
||||
Object.assign(this.#routes, routes);
|
||||
Object.assign(this.#headers, headers);
|
||||
|
||||
this.#includeCredentials = includeCredentials;
|
||||
|
||||
let xsrfToken;
|
||||
let csrfToken;
|
||||
|
||||
if (xcsrfToken === null) {
|
||||
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
||||
xsrfToken = WebAuthn.#XsrfToken;
|
||||
csrfToken = WebAuthn.#firstInputWithCsrfToken;
|
||||
} else{
|
||||
// Check if it is a CSRF or XSRF token
|
||||
if (xcsrfToken.length === 40) {
|
||||
csrfToken = xcsrfToken;
|
||||
} else if (xcsrfToken.length === 224) {
|
||||
xsrfToken = xcsrfToken;
|
||||
} else {
|
||||
throw new TypeError('CSRF token or XSRF token provided does not match requirements. Must be 40 or 224 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
if (xsrfToken !== null) {
|
||||
this.#headers["X-XSRF-TOKEN"] ??= xsrfToken;
|
||||
} else if (csrfToken !== null) {
|
||||
this.#headers["X-CSRF-TOKEN"] ??= csrfToken;
|
||||
} else {
|
||||
// We didn't find it, and since is required, we will bail out.
|
||||
throw new TypeError('Ensure a CSRF/XSRF token is manually set, or provided in a cookie "XSRF-TOKEN" or or there is meta tag named "csrf-token".');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSRF token if it exists as a form input tag.
|
||||
*
|
||||
* @returns string
|
||||
* @throws TypeError
|
||||
*/
|
||||
static get #firstInputWithCsrfToken() {
|
||||
// First, try finding an CSRF Token in the head.
|
||||
let token = Array.from(document.head.getElementsByTagName("meta"))
|
||||
.find(element => element.name === "csrf-token");
|
||||
|
||||
if (token) {
|
||||
return token.content;
|
||||
}
|
||||
|
||||
// Then, try to find a hidden input containing the CSRF token.
|
||||
token = Array.from(document.getElementsByTagName('input'))
|
||||
.find(input => input.name === "_token" && input.type === "hidden")
|
||||
|
||||
if (token) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the XSRF token if it exists in a cookie.
|
||||
*
|
||||
* Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2
|
||||
*
|
||||
* @returns {?string}
|
||||
*/
|
||||
static get #XsrfToken() {
|
||||
const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row));
|
||||
// We must remove all '%3D' from the end of the string.
|
||||
// Background:
|
||||
// The actual binary value of the CSFR value is encoded in Base64.
|
||||
// If the length of original, binary value is not a multiple of 3 bytes,
|
||||
// the encoding gets padded with `=` on the right; i.e. there might be
|
||||
// zero, one or two `=` at the end of the encoded value.
|
||||
// If the value is sent from the server to the client as part of a cookie,
|
||||
// the `=` character is URL-encoded as `%3D`, because `=` is already used
|
||||
// to separate a cookie key from its value.
|
||||
// When we send back the value to the server as part of an AJAX request,
|
||||
// Laravel expects an unpadded value.
|
||||
// Hence, we must remove the `%3D`.
|
||||
return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a fetch promise to resolve later.
|
||||
*
|
||||
* @param data {Object}
|
||||
* @param route {string}
|
||||
* @param headers {{string}}
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
#fetch(data, route, headers = {}) {
|
||||
const url = new URL(route, window.location.origin).href;
|
||||
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
credentials: this.#includeCredentials ? "include" : "same-origin",
|
||||
redirect: "error",
|
||||
headers: {...this.#headers, ...headers},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
static #base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return atob(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param useAtob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static #uint8Array(input, useAtob = false) {
|
||||
return Uint8Array.from(
|
||||
useAtob ? atob(input) : WebAuthn.#base64UrlDecode(input), c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
static #arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
#parseIncomingServerOptions(publicKey) {
|
||||
console.debug(publicKey);
|
||||
|
||||
publicKey.challenge = WebAuthn.#uint8Array(publicKey.challenge);
|
||||
|
||||
if ('user' in publicKey) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: WebAuthn.#uint8Array(publicKey.user.id)
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
"excludeCredentials",
|
||||
"allowCredentials"
|
||||
]
|
||||
.filter(key => key in publicKey)
|
||||
.forEach(key => {
|
||||
publicKey[key] = publicKey[key].map(data => {
|
||||
return {...data, id: WebAuthn.#uint8Array(data.id)};
|
||||
});
|
||||
});
|
||||
|
||||
console.log(publicKey);
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
#parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: WebAuthn.#arrayToBase64String(credentials.rawId),
|
||||
authenticatorAttachment: credentials.authenticatorAttachment,
|
||||
clientExtensionResults: credentials.getClientExtensionResults(),
|
||||
response: {},
|
||||
}
|
||||
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter(key => key in credentials.response)
|
||||
.forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key]));
|
||||
|
||||
return parseCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the Server.
|
||||
*
|
||||
* Throws the entire response if is not OK (HTTP 2XX).
|
||||
*
|
||||
* @param response {Response}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
* @throws Response
|
||||
*/
|
||||
static #handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Here we will do a small trick. Since most of the responses from the server
|
||||
// are JSON, we will automatically parse the JSON body from the response. If
|
||||
// it's not JSON, we will push the body verbatim and let the dev handle it.
|
||||
return new Promise((resolve) => {
|
||||
response
|
||||
.json()
|
||||
.then((json) => resolve(json))
|
||||
.catch(() => resolve(response.body));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user credentials from the browser/device.
|
||||
*
|
||||
* You can add request input if you are planning to register a user with WebAuthn from scratch.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async register(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.registerOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.create({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
Object.assign(publicKeyCredential, request);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in a user with his credentials.
|
||||
*
|
||||
* If no credentials are given, the app may return a blank assertion for userless login.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async login(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.loginOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.get({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
|
||||
console.log(publicKeyCredential);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.login, response).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser supports WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static supportsWebAuthn() {
|
||||
return typeof PublicKeyCredential != "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser doesn't support WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static doesntSupportWebAuthn() {
|
||||
return !this.supportsWebAuthn();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||
{{ __('Lupa kata sandi? Masukkan alamat email Anda dan kami akan mengirimkan tautan untuk mengatur ulang kata sandi.') }}
|
||||
</div>
|
||||
|
||||
<!-- Session Status -->
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Email Password Reset Link') }}
|
||||
{{ __('Kirim tautan atur ulang kata sandi') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,110 +1,39 @@
|
||||
<x-guest-layout>
|
||||
{{-- Title Browser Dinamis --}}
|
||||
<x-slot name="title">Masuk | {{ $setting->app_name ?? config('app.name') }}</x-slot>
|
||||
|
||||
<div class="min-h-screen flex bg-white overflow-hidden">
|
||||
|
||||
{{-- SISI KIRI: BRANDING (Optimasi Proporsi) --}}
|
||||
<div class="hidden lg:flex lg:w-[35%] relative bg-slate-900 justify-center items-center p-8">
|
||||
<div class="absolute inset-0 bg-cover bg-center opacity-30" style="background-image: url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop');"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900 opacity-95"></div>
|
||||
|
||||
<div class="relative z-10 text-center w-full max-w-xs">
|
||||
{{-- Logo: Dibuat lebih proporsional --}}
|
||||
<div class="inline-flex p-4 mb-6 bg-white/5 backdrop-blur-xl rounded-[2rem] border border-white/10 shadow-2xl">
|
||||
@if(isset($setting) && isset($setting->app_logo))
|
||||
<img src="{{ asset('storage/' . $setting->app_logo) }}"
|
||||
alt="Logo"
|
||||
class="h-20 w-auto max-w-[150px] object-contain filter drop-shadow-2xl">
|
||||
<div class="min-h-screen bg-slate-50 px-4 py-10 sm:flex sm:items-center sm:justify-center">
|
||||
<div class="mx-auto grid w-full max-w-4xl overflow-hidden border border-slate-200 bg-white shadow-sm lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div class="hidden border-r border-slate-200 bg-slate-100 p-10 lg:flex lg:flex-col lg:justify-between">
|
||||
<div>
|
||||
@if(isset($setting->app_logo) && $setting->app_logo)
|
||||
<img src="{{ asset('storage/'.$setting->app_logo) }}" alt="Logo" class="mb-8 h-16 w-auto object-contain">
|
||||
@else
|
||||
<div class="w-20 h-20 bg-indigo-600 rounded-2xl flex items-center justify-center text-3xl font-black text-white italic">I</div>
|
||||
<div class="mb-8 flex h-14 w-14 items-center justify-center rounded-lg bg-indigo-600 text-xl text-white"><i class="fas fa-cat"></i></div>
|
||||
@endif
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ $setting->app_name ?? 'ILY Cattery' }}</h1>
|
||||
<p class="mt-3 max-w-sm text-sm leading-6 text-slate-600">Kelola data kucing, riwayat pembiakan, kesehatan, pedigree, dan persediaan dalam satu aplikasi.</p>
|
||||
</div>
|
||||
|
||||
{{-- Nama Aplikasi: Tidak oversized --}}
|
||||
<h2 class="text-2xl xl:text-3xl font-black text-white tracking-tight mb-2 break-words">
|
||||
{{ $setting->app_name ?? 'Ily Cattery' }}
|
||||
</h2>
|
||||
|
||||
{{-- Slogan: Lebih ramping --}}
|
||||
<p class="text-indigo-300 text-[10px] font-bold uppercase tracking-[0.2em] leading-relaxed italic opacity-80">
|
||||
{{ $setting->app_slogan ?? 'Professional Cattery Management' }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-500">Akses hanya untuk pengguna yang terdaftar.</p>
|
||||
</div>
|
||||
<div class="p-6 sm:p-10 lg:p-12">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-2xl font-bold text-slate-900">Masuk ke ILYCats</h2>
|
||||
<p class="mt-2 text-sm text-slate-500">Gunakan email dan kata sandi akun Anda.</p>
|
||||
</div>
|
||||
|
||||
{{-- SISI KANAN: FORM LOGIN (Dibuat Lebih Center & Proporsional) --}}
|
||||
<div class="flex-1 flex items-center justify-center p-6 sm:p-12 lg:p-16 bg-slate-50/50">
|
||||
{{-- max-w-md: Lebar standar form agar tidak terlihat meluber --}}
|
||||
<div class="w-full max-w-md space-y-8">
|
||||
|
||||
<div class="space-y-2 text-center lg:text-left">
|
||||
{{-- Logo Mobile: Center di layar kecil --}}
|
||||
<div class="lg:hidden flex justify-center">
|
||||
@if(isset($setting) && isset($setting->app_logo))
|
||||
<img src="{{ asset('storage/' . $setting->app_logo) }}" class="h-14 w-auto mb-6">
|
||||
@endif
|
||||
</div>
|
||||
<h3 class="text-2xl lg:text-3xl font-black text-slate-900 tracking-tight">Selamat Datang.</h3>
|
||||
<p class="text-slate-500 font-medium text-sm lg:text-base">Silakan masuk untuk mengelola cattery Anda.</p>
|
||||
</div>
|
||||
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-6">
|
||||
@csrf
|
||||
|
||||
{{-- Email Input --}}
|
||||
<div class="group">
|
||||
<label for="email" class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block ml-1">Email / ID Pengguna</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none">
|
||||
<i class="fas fa-envelope text-slate-300 group-focus-within:text-indigo-600 transition-colors"></i>
|
||||
</div>
|
||||
<input id="email"
|
||||
class="block w-full pl-12 pr-4 py-4 bg-white border border-slate-100 rounded-2xl text-slate-900 text-sm font-semibold shadow-sm focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/20 transition-all placeholder:text-slate-300"
|
||||
type="email" name="email" :value="old('email')" required autofocus placeholder="nama@cattery.com" />
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-1" />
|
||||
</div>
|
||||
|
||||
{{-- Password Input --}}
|
||||
<div class="group">
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<label for="password" class="text-[10px] font-black text-slate-400 uppercase tracking-widest">Kata Sandi</label>
|
||||
@if (Route::has('password.request'))
|
||||
<a class="text-[10px] font-bold text-indigo-600 hover:text-indigo-800 italic transition-colors" href="{{ route('password.request') }}">Lupa?</a>
|
||||
@endif
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none">
|
||||
<i class="fas fa-lock text-slate-300 group-focus-within:text-indigo-600 transition-colors"></i>
|
||||
</div>
|
||||
<input id="password"
|
||||
class="block w-full pl-12 pr-4 py-4 bg-white border border-slate-100 rounded-2xl text-slate-900 text-sm font-semibold shadow-sm focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/20 transition-all placeholder:text-slate-300"
|
||||
type="password" name="password" required placeholder="••••••••" />
|
||||
</div>
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-1" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label for="remember_me" class="inline-flex items-center cursor-pointer group">
|
||||
<input id="remember_me" type="checkbox" class="w-5 h-5 rounded border-slate-200 text-indigo-600 shadow-sm focus:ring-indigo-500/20 cursor-pointer" name="remember">
|
||||
<span class="ml-3 text-xs font-bold text-slate-500 group-hover:text-slate-800 transition-colors">Ingat sesi</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full flex justify-center py-4 px-4 rounded-2xl shadow-xl shadow-indigo-600/20 text-sm font-black text-white bg-slate-900 hover:bg-indigo-700 focus:outline-none focus:ring-4 focus:ring-indigo-500/20 transition-all duration-300 hover:translate-y-[-1px] uppercase tracking-widest">
|
||||
Masuk Sistem
|
||||
</button>
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-5">@csrf
|
||||
<div><label for="email" class="mb-2 block text-sm font-medium text-slate-700">Email</label><input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username" placeholder="nama@contoh.com" class="w-full border-slate-300 px-3 py-3 text-sm focus:border-indigo-500 focus:ring-indigo-500"></div>
|
||||
<div x-data="{show:false}"><label for="password-field" class="mb-2 block text-sm font-medium text-slate-700">Kata sandi</label><div class="relative"><input id="password-field" :type="show?'text':'password'" name="password" required autocomplete="current-password" class="w-full border-slate-300 px-3 py-3 pr-12 text-sm focus:border-indigo-500 focus:ring-indigo-500"><button type="button" @click="show=!show" class="absolute inset-y-0 right-0 px-4 text-slate-400" :aria-label="show?'Sembunyikan kata sandi':'Tampilkan kata sandi'"><i class="fas" :class="show?'fa-eye-slash':'fa-eye'"></i></button></div></div>
|
||||
<div class="flex items-center justify-between gap-4"><label class="flex items-center gap-2 text-sm text-slate-600"><input type="checkbox" name="remember" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">Ingat saya di perangkat ini</label>@if(Route::has('password.request'))<a href="{{ route('password.request') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-700">Lupa kata sandi?</a>@endif</div>
|
||||
<button type="submit" class="w-full bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700">Masuk</button>
|
||||
<button type="button" id="login-passkey" class="w-full border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50"><i class="fas fa-fingerprint mr-2 text-slate-500"></i>Masuk dengan biometrik</button>
|
||||
</form>
|
||||
|
||||
<div class="pt-6 text-center">
|
||||
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-widest italic opacity-50">
|
||||
© {{ date('Y') }} {{ $setting->app_name ?? 'ILY CATTERY' }} • SECURE ACCESS
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-8 border-t border-slate-200 pt-5 text-center text-xs text-slate-400">{{ date('Y') }} {{ $setting->app_name ?? 'ILY Cattery' }} · Akses aman</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import WebAuthn from "{{ asset('vendor/webauthn/webauthn.js') }}";
|
||||
const btn=document.getElementById('login-passkey');
|
||||
if(btn){btn.addEventListener('click',async()=>{const original=btn.innerHTML;try{btn.disabled=true;btn.textContent='Memeriksa biometrik…';const response=await (new WebAuthn()).login();if(response)window.location.href="{{ route('dashboard') }}";}catch(e){alert('Biometrik tidak dapat digunakan. Pastikan biometrik sudah didaftarkan pada profil Anda.');}finally{btn.disabled=false;btn.innerHTML=original;}})}
|
||||
</script>
|
||||
</x-guest-layout>
|
||||
@@ -28,9 +28,9 @@
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<!-- Konfirmasi Kata Sandi -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-input-label for="password_confirmation" :value="__('Konfirmasi Kata Sandi')" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
@@ -41,11 +41,11 @@
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}">
|
||||
{{ __('Already registered?') }}
|
||||
{{ __('Sudah memiliki akun?') }}
|
||||
</a>
|
||||
|
||||
<x-primary-button class="ms-4">
|
||||
{{ __('Register') }}
|
||||
{{ __('Daftar') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<!-- Konfirmasi Kata Sandi -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-input-label for="password_confirmation" :value="__('Konfirmasi Kata Sandi')" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Reset Password') }}
|
||||
{{ __('Atur ulang kata sandi') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ __('Log Out') }}
|
||||
{{ __('Keluar') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@php
|
||||
// Mengambil data dari database dengan fallback jika data kosong
|
||||
$appName = \App\Models\Setting::where('key', 'app_name')->value('value') ?? 'Ily Cattery';
|
||||
$appSlogan = \App\Models\Setting::where('key', 'app_slogan')->value('value') ?? 'Premium Breeding Management';
|
||||
$appSlogan = \App\Models\Setting::where('key', 'app_slogan')->value('value') ?? 'Pengelolaan Cattery ILY';
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
@props(['active', 'icon'])
|
||||
@props([
|
||||
'active' => false,
|
||||
'icon' => null,
|
||||
'tone' => 'indigo',
|
||||
])
|
||||
|
||||
@php
|
||||
// Logika untuk menentukan class CSS jika link sedang aktif atau tidak
|
||||
$classes = ($active ?? false)
|
||||
? 'flex items-center px-4 py-2.5 text-sm font-bold text-indigo-700 bg-indigo-50 rounded-xl border border-indigo-100 transition-all'
|
||||
: 'flex items-center px-4 py-2.5 text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-indigo-600 rounded-xl transition-all';
|
||||
? 'group flex items-center rounded-xl bg-slate-100 px-2.5 py-2 text-sm font-bold text-slate-900 transition-all'
|
||||
: 'group flex items-center rounded-xl px-2.5 py-2 text-sm font-medium text-slate-600 transition-all hover:bg-slate-50 hover:text-slate-900';
|
||||
|
||||
$iconClasses = ($active ?? false)
|
||||
? 'text-indigo-600'
|
||||
: 'text-slate-400 group-hover:text-indigo-500';
|
||||
$tones = [
|
||||
'indigo' => ['idle' => 'bg-indigo-50 text-indigo-600', 'active' => 'bg-indigo-600 text-white', 'paw' => 'text-indigo-500'],
|
||||
'blue' => ['idle' => 'bg-blue-50 text-blue-600', 'active' => 'bg-blue-600 text-white', 'paw' => 'text-blue-500'],
|
||||
'sky' => ['idle' => 'bg-sky-50 text-sky-600', 'active' => 'bg-sky-600 text-white', 'paw' => 'text-sky-500'],
|
||||
'rose' => ['idle' => 'bg-rose-50 text-rose-600', 'active' => 'bg-rose-600 text-white', 'paw' => 'text-rose-500'],
|
||||
'emerald' => ['idle' => 'bg-emerald-50 text-emerald-600', 'active' => 'bg-emerald-600 text-white', 'paw' => 'text-emerald-500'],
|
||||
'amber' => ['idle' => 'bg-amber-50 text-amber-600', 'active' => 'bg-amber-500 text-white', 'paw' => 'text-amber-500'],
|
||||
'violet' => ['idle' => 'bg-violet-50 text-violet-600', 'active' => 'bg-violet-600 text-white', 'paw' => 'text-violet-500'],
|
||||
'cyan' => ['idle' => 'bg-cyan-50 text-cyan-600', 'active' => 'bg-cyan-600 text-white', 'paw' => 'text-cyan-500'],
|
||||
'slate' => ['idle' => 'bg-slate-100 text-slate-600', 'active' => 'bg-slate-700 text-white', 'paw' => 'text-slate-500'],
|
||||
];
|
||||
|
||||
$toneSet = $tones[$tone] ?? $tones['indigo'];
|
||||
$iconClasses = ($active ?? false) ? $toneSet['active'] : $toneSet['idle'];
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
@if($icon)
|
||||
<i class="fas {{ $icon }} mr-3 w-5 text-center {{ $iconClasses }}"></i>
|
||||
<span class="relative mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl {{ $iconClasses }} transition-transform group-hover:-rotate-3 group-hover:scale-105">
|
||||
<i class="fas {{ $icon }} text-sm"></i>
|
||||
<span class="absolute -bottom-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border-2 border-white bg-white shadow-sm {{ $toneSet['paw'] }}">
|
||||
<i class="fas fa-paw text-[6px]"></i>
|
||||
</span>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<span class="truncate">
|
||||
{{ $slot }}
|
||||
</span>
|
||||
<span class="truncate">{{ $slot }}</span>
|
||||
|
||||
@if($active ?? false)
|
||||
<span class="ml-auto w-1.5 h-1.5 rounded-full bg-indigo-600"></span>
|
||||
<span class="ml-auto h-1.5 w-1.5 rounded-full bg-slate-700"></span>
|
||||
@endif
|
||||
</a>
|
||||
@@ -4,14 +4,14 @@
|
||||
$cleanStatus = strtolower(trim($status ?? ''));
|
||||
$safeWords = ['sehat', 'normal', 'aktif', 'ok', 'good', 'sembuh', '-', ''];
|
||||
|
||||
$isHealthy = in_array($cleanStatus, $safeWords);
|
||||
$isSehat = in_array($cleanStatus, $safeWords);
|
||||
|
||||
$classes = $isHealthy
|
||||
$classes = $isSehat
|
||||
? 'bg-green-100 text-green-700 border-green-200'
|
||||
: 'bg-red-100 text-red-700 border-red-200';
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes->merge(['class' => "px-3 py-1 rounded-full text-xs font-bold border $classes"]) }}>
|
||||
<i class="fas {{ $isHealthy ? 'fa-check-circle' : 'fa-pills' }} mr-1"></i>
|
||||
{{ $isHealthy ? 'Sehat / Normal' : $status }}
|
||||
<i class="fas {{ $isSehat ? 'fa-check-circle' : 'fa-pills' }} mr-1"></i>
|
||||
{{ $isSehat ? 'Sehat / Normal' : $status }}
|
||||
</span>
|
||||
@@ -1,7 +1,7 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Dashboard') }}
|
||||
{{ __('Beranda') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
|
||||
@@ -1,182 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<html lang="id" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
@php
|
||||
// Optimasi: Gunakan try-catch agar jika tabel settings belum dimigrasi, app tidak crash
|
||||
try {
|
||||
$settings = \App\Models\Setting::whereIn('key', ['app_name', 'app_slogan', 'app_favicon', 'app_version'])
|
||||
->pluck('value', 'key');
|
||||
} catch (\Exception $e) {
|
||||
$settings = collect();
|
||||
}
|
||||
|
||||
$appName = $settings['app_name'] ?? config('app.name', 'Ily Cattery');
|
||||
$appSlogan = $settings['app_slogan'] ?? 'Dedikasi untuk Kesehatan dan Kebahagiaan Anabul';
|
||||
$appVersion = $settings['app_version'] ?? 'v1.0.0';
|
||||
$favicon = $settings['app_favicon'] ?? null;
|
||||
$appName = \App\Models\Setting::where('key','app_name')->value('value') ?? config('app.name','ILY Cattery');
|
||||
$favicon = \App\Models\Setting::where('key','app_favicon')->value('value');
|
||||
@endphp
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}">
|
||||
|
||||
<title>{{ $appName }}</title>
|
||||
|
||||
{{-- Fonts & Icons --}}
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,800,900&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
{{-- Scripts & Styles --}}
|
||||
<link rel="icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}">
|
||||
<link rel="stylesheet" href="{{ asset('vendor/fontawesome/css/all.min.css') }}">
|
||||
@vite(['resources/css/app.css','resources/js/app.js'])
|
||||
|
||||
<script src="{{ asset('vendor/ilycats/compat.js') }}" defer></script>
|
||||
@stack('styles')
|
||||
|
||||
<style>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e1; }
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* Animasi Fade In untuk Main Content */
|
||||
.page-fade-in { animation: fadeIn 0.4s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased text-slate-900 bg-slate-50 h-full overflow-hidden">
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
|
||||
{{-- Sidebar Navigation --}}
|
||||
<body class="h-full bg-slate-50 font-sans text-slate-800 antialiased" x-data="{sidebarOpen: window.innerWidth >= 1024}" @resize.window="if(window.innerWidth>=1024) sidebarOpen=true">
|
||||
<div class="flex min-h-screen">
|
||||
@include('layouts.navigation-sidebar')
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-slate-50 relative">
|
||||
|
||||
{{-- Top Navigation --}}
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
@include('layouts.navigation')
|
||||
|
||||
{{-- Main Content Area --}}
|
||||
<main class="flex-1 overflow-y-auto custom-scrollbar flex flex-col">
|
||||
<div class="max-w-7xl mx-auto w-full p-4 md:p-8 flex-grow page-fade-in">
|
||||
|
||||
{{-- Notifikasi Error/Validasi (Hanya muncul jika bukan AJAX) --}}
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full max-w-[1500px] p-4 md:p-6 lg:p-8">
|
||||
@if($errors->any())
|
||||
<div x-data="{ show: true }" x-show="show" x-cloak
|
||||
class="mb-6 p-4 bg-rose-50 border-2 border-rose-100 text-rose-700 rounded-3xl flex items-start shadow-sm transition-all">
|
||||
<i class="fas fa-exclamation-circle mt-0.5 mr-3 text-lg"></i>
|
||||
<div>
|
||||
<h4 class="text-[11px] font-black uppercase tracking-widest mb-1">Terjadi Kesalahan Input:</h4>
|
||||
<ul class="list-disc list-inside text-[10px] font-bold opacity-80">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
<button @click="show = false" class="ml-auto text-rose-300 hover:text-rose-500"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
<div class="mb-5 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700"><p class="font-semibold">Periksa kembali data yang diisi.</p><ul class="mt-1 list-disc pl-5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>
|
||||
@endif
|
||||
|
||||
{{-- Main Slot (Halaman Konten Anda) --}}
|
||||
@if(session('success'))<div class="mb-5 rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700">{{ session('success') }}</div>@endif
|
||||
@if(session('error'))<div class="mb-5 rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">{{ session('error') }}</div>@endif
|
||||
@if(session('warning'))<div class="mb-5 rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700">{{ session('warning') }}</div>@endif
|
||||
{{ $slot }}
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<footer class="w-full py-8 border-t border-slate-100 mt-auto bg-white/50 backdrop-blur-md">
|
||||
<div class="max-w-7xl mx-auto px-8 text-center">
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-1">
|
||||
{{ $appName }}
|
||||
</p>
|
||||
<p class="text-[9px] text-slate-300 font-bold tracking-widest italic uppercase">
|
||||
{{ $appSlogan }} — {{ $appVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SWEETALERT 2 INTEGRATION --}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// 1. KONFIGURASI TOAST
|
||||
const IlyToast = Swal.mixin({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
showConfirmButton: false,
|
||||
timer: 3500,
|
||||
timerProgressBar: true,
|
||||
didOpen: (toast) => {
|
||||
toast.addEventListener('mouseenter', Swal.stopTimer)
|
||||
toast.addEventListener('mouseleave', Swal.resumeTimer)
|
||||
},
|
||||
customClass: {
|
||||
popup: 'rounded-2xl shadow-2xl border border-slate-50 p-4',
|
||||
title: 'text-[11px] font-black uppercase tracking-widest ml-2'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. FUNGSI GLOBAL TOAST (Bisa dipanggil di file JS manapun)
|
||||
window.showToast = function(message, icon = 'success') {
|
||||
IlyToast.fire({
|
||||
icon: icon,
|
||||
title: message,
|
||||
iconColor: icon === 'success' ? '#4f46e5' : '#e11d48',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. AUTO-TRIGGER FLASH MESSAGE
|
||||
@if(session('success'))
|
||||
showToast("{{ session('success') }}", 'success');
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
showToast("{{ session('error') }}", 'error');
|
||||
@endif
|
||||
|
||||
// 4. FUNGSI GLOBAL KONFIRMASI DELETE (Universal)
|
||||
// Mendukung pemanggilan via ID Form (confirmDelete('formId', 'Nama'))
|
||||
// Atau pemanggilan via Element (confirmDelete(this, 'Nama'))
|
||||
window.confirmDelete = function(target, itemName = 'Data ini') {
|
||||
const finalName = itemName || 'Data terpilih';
|
||||
|
||||
Swal.fire({
|
||||
title: 'HAPUS DATA?',
|
||||
html: "<div class='text-sm text-slate-500 font-medium'>Anda akan menghapus:<br><b class='text-slate-800 uppercase tracking-tighter'>" + finalName + "</b><br><br><span class='text-rose-500 font-bold italic'>Peringatan: Data yang dihapus tidak dapat dipulihkan!</span></div>",
|
||||
icon: 'warning',
|
||||
iconColor: '#e11d48',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#e11d48',
|
||||
cancelButtonColor: '#f1f5f9',
|
||||
confirmButtonText: 'YA, HAPUS PERMANEN',
|
||||
cancelButtonText: 'BATAL',
|
||||
reverseButtons: true,
|
||||
customClass: {
|
||||
popup: 'rounded-[2.5rem] p-10 border-none shadow-2xl',
|
||||
confirmButton: 'rounded-2xl font-black uppercase text-[10px] px-8 py-4 ml-3 tracking-widest shadow-lg shadow-rose-100',
|
||||
cancelButton: 'rounded-2xl font-black uppercase text-[10px] px-8 py-4 text-slate-500 tracking-widest'
|
||||
}
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Logic: Jika target adalah string, cari by ID. Jika object, cari closest form.
|
||||
if (typeof target === 'string') {
|
||||
const form = document.getElementById(target);
|
||||
if(form) form.submit();
|
||||
} else {
|
||||
const form = target.closest('form');
|
||||
if(form) form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
window.confirmDelete = function(target, itemName='data ini') {
|
||||
if (!window.confirm(`Hapus ${itemName}? Tindakan ini tidak dapat dibatalkan.`)) return;
|
||||
if (typeof target === 'string') document.getElementById(target)?.submit();
|
||||
else target.closest('form')?.submit();
|
||||
};
|
||||
</script>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user