Compare commits

...

12 Commits

Author SHA1 Message Date
itdept edd2f626be chore: stop tracking compiled blade cache 2026-09-23 13:56:51 +07:00
itdept 507fc6ccdf initial commit 2026-09-23 12:45:02 +07:00
Dwi F Sukmana d16f454083 perbaikan sidebar 2026-02-09 09:53:54 +07:00
Dwi F Sukmana 17187459be perbaikan login page 2026-02-09 09:36:42 +07:00
Dwi F Sukmana 74a09ee9b2 perbaikan role management 2026-02-09 09:20:49 +07:00
Dwi F Sukmana c89489b125 perbaikan food modul 2026-02-09 08:52:07 +07:00
Dwi F Sukmana bcb407592a Update navbar profile 2026-02-08 20:24:39 +07:00
Dwi F Sukmana 6133073005 Update navbar 2026-02-08 20:20:58 +07:00
Dwi F Sukmana e83502af40 ganti label & perbaikan fungsi report & matting 2026-02-08 18:45:10 +07:00
Dwi F Sukmana 9301d81259 ganti label 2026-02-08 16:01:30 +07:00
Dwi F Sukmana 12b2d8dc98 Selesaikan konflik README dan inisialisasi proyek ilycats 2026-02-08 15:32:14 +07:00
Dwi Sukmana e9dccd8fba Initial commit 2026-02-08 11:46:53 +07:00
155 changed files with 8895 additions and 2900 deletions
+5
View File
@@ -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"><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"> <p align="center">
@@ -57,3 +58,7 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
## License ## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). 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()}");
}
}
}
+83
View File
@@ -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']
],
],
];
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
($item->sire?->nama_kucing ?? '-') . ' x ' . ($item->dam?->nama_kucing ?? '-'), ($item->sire?->nama_kucing ?? '-') . ' x ' . ($item->dam?->nama_kucing ?? '-'),
($item->dam?->pedigree ?? '-'), ($item->dam?->pedigree ?? '-'),
'S: ' . ($item->ems_bapak ?? '-') . ' / D: ' . ($item->ems_ibu ?? '-'), '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 ?? '-'), 'S: ' . ($item->no_microchip_bapak ?? '-') . ' / D: ' . ($item->no_microchip_ibu ?? '-'),
$tglKawin, $tglKawin,
$eddDate, $eddDate,
@@ -66,7 +66,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
return [ return [
AfterSheet::class => function(AfterSheet $event) { AfterSheet::class => function(AfterSheet $event) {
$sheet = $event->sheet; $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('A2', 'Dicetak Oleh: ' . Auth::user()->name);
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i')); $sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records'); $sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
+142
View File
@@ -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
],
],
],
];
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ class UnifiedReportExport implements FromCollection, WithHeadings, WithMapping,
$sheet = $event->sheet; $sheet = $event->sheet;
// Menulis Metadata // 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('A2', 'Dicetak Oleh: ' . Auth::user()->name);
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i')); $sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records'); $sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
+67 -11
View File
@@ -2,19 +2,75 @@
namespace App\Helpers; namespace App\Helpers;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class WhatsappHelper class WhatsappHelper
{ {
public static function send($target, $message) public static function send(string $target, string $message): bool
{ {
$token = "YOUR_API_TOKEN"; $target = self::normalizePhone($target);
$curl = curl_init(); $provider = strtolower((string) config('services.whatsapp.provider', 'fonnte'));
curl_setopt_array($curl, [
CURLOPT_URL => 'https://api.fonnte.com/send', if ($provider === 'waha') {
CURLOPT_POST => true, $baseUrl = rtrim((string) config('services.whatsapp.waha_url'), '/');
CURLOPT_POSTFIELDS => ['target' => $target, 'message' => $message], if ($baseUrl === '') {
CURLOPT_HTTPHEADER => ["Authorization: $token"], 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,
]);
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,
]); ]);
curl_exec($curl);
curl_close($curl); 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\Models\{Matting, Cat, Kitten};
use App\Traits\LogsActivity; use App\Traits\LogsActivity;
use Illuminate\Http\{Request, RedirectResponse, JsonResponse}; use Illuminate\Http\{Request, RedirectResponse, JsonResponse};
use Illuminate\Support\Facades\{DB, Storage}; use Illuminate\Support\Facades\{DB, Storage, Gate};
use Illuminate\View\View; use Illuminate\View\View;
class MattingController extends Controller class MattingController extends Controller
@@ -15,6 +15,7 @@ class MattingController extends Controller
public function index(): View public function index(): View
{ {
Gate::authorize('check-access', ['matting', 'read']);
$mattings = Matting::with(['sire', 'dam']) $mattings = Matting::with(['sire', 'dam'])
->withCount('kittens') ->withCount('kittens')
->latest() ->latest()
@@ -25,6 +26,7 @@ class MattingController extends Controller
public function create(): View public function create(): View
{ {
Gate::authorize('check-access', ['matting', 'create']);
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']); $sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']);
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']); $dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']);
return view('pages.matting.create', compact('sires', 'dams')); return view('pages.matting.create', compact('sires', 'dams'));
@@ -32,6 +34,7 @@ class MattingController extends Controller
public function getCatData($id): JsonResponse public function getCatData($id): JsonResponse
{ {
Gate::authorize('check-access', ['matting', 'read']);
$cat = Cat::find($id); $cat = Cat::find($id);
if (!$cat) return response()->json(['error' => 'Kucing tidak ditemukan'], 404); if (!$cat) return response()->json(['error' => 'Kucing tidak ditemukan'], 404);
@@ -48,6 +51,7 @@ class MattingController extends Controller
*/ */
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
Gate::authorize('check-access', ['matting', 'create']);
$this->validateMatting($request); $this->validateMatting($request);
return DB::transaction(function () use ($request) { return DB::transaction(function () use ($request) {
@@ -79,6 +83,7 @@ class MattingController extends Controller
*/ */
public function edit(Matting $matting): View public function edit(Matting $matting): View
{ {
Gate::authorize('check-access', ['matting', 'update']);
// Load relasi agar data anak muncul di form edit // Load relasi agar data anak muncul di form edit
$matting->load('kittens.cat'); $matting->load('kittens.cat');
@@ -93,6 +98,7 @@ class MattingController extends Controller
*/ */
public function update(Request $request, Matting $matting): RedirectResponse public function update(Request $request, Matting $matting): RedirectResponse
{ {
Gate::authorize('check-access', ['matting', 'update']);
$this->validateMatting($request); $this->validateMatting($request);
return DB::transaction(function () use ($request, $matting) { 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) * SINKRONISASI DATA INDUK (EMS Fix)
*/ */
private function syncParentData(Request $request) private function syncParentData(Request $request)
{ {
Cat::where('id', $request->sire_id)->update([ Cat::where('id', $request->sire_id)->update([
'pedigree' => $request->no_pedigree_bapak,
'microchip' => $request->no_microchip_bapak, 'microchip' => $request->no_microchip_bapak,
'tanggal_lahir' => $request->tgl_lahir_sire, 'tanggal_lahir' => $request->tgl_lahir_sire,
'kode_ems' => $request->ems_bapak // Sinkron EMS Bapak 'kode_ems' => $request->ems_bapak // Sinkron EMS Bapak
]); ]);
Cat::where('id', $request->dam_id)->update([ Cat::where('id', $request->dam_id)->update([
'pedigree' => $request->no_pedigree_ibu,
'microchip' => $request->no_microchip_ibu, 'microchip' => $request->no_microchip_ibu,
'tanggal_lahir' => $request->tgl_lahir_dam, 'tanggal_lahir' => $request->tgl_lahir_dam,
'kode_ems' => $request->ems_ibu // Sinkron EMS Ibu 'kode_ems' => $request->ems_ibu // Sinkron EMS Ibu
@@ -218,6 +244,7 @@ class MattingController extends Controller
public function destroy(Matting $matting): RedirectResponse public function destroy(Matting $matting): RedirectResponse
{ {
Gate::authorize('check-access', ['matting', 'delete']);
return DB::transaction(function () use ($matting) { return DB::transaction(function () use ($matting) {
try { try {
$pairName = ($matting->sire->nama_kucing ?? 'Sire') . ' x ' . ($matting->dam->nama_kucing ?? 'Dam'); $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', 'sire_id' => 'required|exists:cats,id',
'dam_id' => 'required|exists:cats,id', 'dam_id' => 'required|exists:cats,id',
'tgl_kawin_awal' => 'required|date', '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_bapak' => 'nullable|string',
'ems_ibu' => 'nullable|string', 'ems_ibu' => 'nullable|string',
'no_pedigree_bapak' => 'nullable|string', 'no_pedigree_bapak' => 'nullable|string',
+427 -139
View File
@@ -2,17 +2,24 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\{Cat, User, Matting}; use App\Models\{Cat, User, Matting,Medicine};
use App\Traits\LogsActivity; use App\Traits\LogsActivity;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\View\View; 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 Illuminate\Support\Carbon;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use SimpleSoftwareIO\QrCode\Facades\QrCode; use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Intervention\Image\Laravel\Facades\Image; 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 class CatController extends Controller
{ {
@@ -21,47 +28,97 @@ class CatController extends Controller
/** /**
* Menampilkan daftar kucing dengan filter cerdas. * Menampilkan daftar kucing dengan filter cerdas.
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
// Proteksi Akses: Read Gate::authorize('check-access', ['cats', 'read']);
Gate::authorize('check-access', ['cats', 'read']);
$query = Cat::query()->with(['pic', 'sire', 'dam']); $now = now();
$listScope = $request->get('list_scope', 'active');
// 1. Pencarian Global if (!in_array($listScope, ['active', 'adopted', 'external_parent', 'all'], true)) {
if ($request->filled('search')) { $listScope = 'active';
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_kucing', 'like', "%{$search}%")
->orWhere('ras', 'like', "%{$search}%")
->orWhere('microchip', 'like', "%{$search}%")
->orWhere('pedigree', 'like', "%{$search}%")
->orWhere('kode_ems', 'like', "%{$search}%");
});
}
// 2. Filter Spesifik
if ($request->filled('status')) $query->where('status', $request->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();
// 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}%");
}
})->count();
$breeds = Cat::distinct()->pluck('ras');
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
} }
$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();
}
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_kucing', 'like', "%{$search}%")
->orWhere('ras', 'like', "%{$search}%")
->orWhere('microchip', 'like', "%{$search}%")
->orWhere('pedigree', 'like', "%{$search}%")
->orWhere('kode_ems', 'like', "%{$search}%")
->orWhere('lokasi_kandang', 'like', "%{$search}%")
->orWhereHas('pic', fn($sub) => $sub->where('name', 'like', "%{$search}%"));
});
}
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);
$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';
if ($sort === 'pic_name') {
$query->leftJoin('users', 'cats.pic_id', '=', 'users.id')->select('cats.*')->orderBy('users.name', $direction);
} else {
$query->orderBy("cats.$sort", $direction);
}
$cats = $query->paginate(15)->withQueryString();
$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 public function create(): View
{ {
// Proteksi Akses: Create // Proteksi Akses: Create
@@ -75,47 +132,100 @@ class CatController extends Controller
} }
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
Gate::authorize('check-access', ['cats', 'create']); Gate::authorize('check-access', ['cats', 'create']);
$validated = $this->validateCat($request); // 1. Validasi
$validated = $this->validateCat($request);
return DB::transaction(function () use ($request, $validated) { return DB::transaction(function () use ($request, $validated) {
if ($request->hasFile('foto')) { // 2. Handling Upload Foto (Fix: Encoding Intervention v3)
$validated['foto'] = $this->handleImageUpload($request->file('foto')); if ($request->hasFile('foto')) {
} $validated['foto'] = $this->handleImageUpload($request->file('foto'));
}
if (isset($validated['berat_badan'])) { // 3. Handling Berat Badan (Sederhanakan)
$validated['berat_terakhir'] = $validated['berat_badan']; // 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;
}
if (empty($validated['status'])) { // 4. Handling Status Otomatis
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active'; 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 // 5. Simpan Data
if (isset($validated['registry'])) { // Pastikan kolom-kolom induk juga masuk ke validated jika ada di request
$validated['registry'] = $validated['registry']; $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); $cat = Cat::create($validated);
$this->logAction('CREATED', $cat, "Mendaftarkan pasien baru: {$cat->nama_kucing}", 'Cat');
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan."); // 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 public function show(Cat $cat): View
{ {
Gate::authorize('check-access', ['cats', 'read']); // 1. Otorisasi (TETAP ADA)
Gate::authorize('check-access', ['cats', 'read']);
$cat->load([ // 2. Eager Loading (DITAMBAHKAN medicalRecords & performer)
'treatments' => fn($q) => $q->latest(), // Kita memuat medicalRecords agar tidak terjadi query berulang di dalam Loop Blade (N+1 Problem)
'sire', 'dam', 'pic', $cat->load([
'weightHistories' => fn($q) => $q->latest()->limit(12) 'treatments' => fn($q) => $q->latest(),
]); 'medicalRecords' => fn($q) => $q->latest(), // Baru: Riwayat Vaksin/Obat Cacing
return view('pages.cats.show', compact('cat')); 'medicalRecords.performer', // Baru: Siapa yang melakukan tindakan
} 'sire',
'dam',
'pic',
'weightHistories' => fn($q) => $q->latest()->limit(12),
'registrations.association',
'registrations.documents'
]);
// 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 public function edit(Cat $cat): View
{ {
@@ -130,30 +240,53 @@ class CatController extends Controller
} }
public function update(Request $request, Cat $cat): RedirectResponse public function update(Request $request, Cat $cat): RedirectResponse
{ {
Gate::authorize('check-access', ['cats', 'update']); // 1. Otorisasi Akses
Gate::authorize('check-access', ['cats', 'update']);
$validated = $this->validateCat($request, $cat->id); // 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) { return DB::transaction(function () use ($request, $validated, $cat) {
if ($request->hasFile('foto')) {
if ($cat->foto) Storage::disk('public')->delete($cat->foto); // 3. Handling Foto
$validated['foto'] = $this->handleImageUpload($request->file('foto')); if ($request->hasFile('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; * 4. PENYEMPURNAAN REGISTRY (FIX UPDATE)
if ($cat->berat_badan != $request->berat_badan) { * Jika di form semua checkbox tidak dicentang, $request->registry akan kosong.
$cat->weightHistories()->create(['berat' => $request->berat_badan]); * 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');
$cat->update($validated); // 5. Update Data Utama
$this->logAction('UPDATED', $cat, "Update data medis/profil: {$cat->nama_kucing}", 'Cat'); // Logika berat_badan sudah otomatis dihandle oleh Model (static::saving/updated)
$cat->update($validated);
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.");
});
}
public function destroy(Cat $cat): RedirectResponse public function destroy(Cat $cat): RedirectResponse
{ {
@@ -189,71 +322,226 @@ class CatController extends Controller
]); ]);
} }
/** //cetak download health ledger pdf
* PDF ID Card / Medical Pass
* PENYEMPURNAAN: Perbaikan Barcode (Base64 SVG) public function downloadReport(Cat $cat)
*/ {
public function downloadPdf(Cat $cat) Gate::authorize('check-access', ['cats', 'read']);
$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')
]);
$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', 'read']); Gate::authorize('check-access', ['cats', 'update']);
$cat->load(['pic', 'sire', 'dam']);
// FIX: Konversi SVG ke Base64 agar tampil di domPDF tanpa Imagick return view('pages.cats.bulk-weight');
$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', [ public function downloadWeightTemplate(): BinaryFileResponse
'cat' => $cat, {
'qrCode' => $qrCode, // Dikirim sebagai Base64 Gate::authorize('check-access', ['cats', 'update']);
'printedAt' => Carbon::now()->translatedFormat('d F Y H:i')
// 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.',
]); ]);
return $pdf->setPaper([0, 0, 300, 500], 'portrait') try {
->stream('MEDICAL_PASS_' . strtoupper($cat->nama_kucing) . '.pdf'); // 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) */ /* HELPERS (TETAP UTUH) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
private function validateCat(Request $request, $id = null)
private function validateCat(Request $request, $id = null)
{
return $request->validate([
// Identitas Dasar
'nama_kucing' => 'required|string|max:255',
'ras' => 'required|string|max:100',
'jenis_kelamin' => 'required|in:Jantan,Betina',
'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', \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',
// 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.',
]);
}
public function completeTreatment(Request $request, Cat $cat)
{ {
return $request->validate([ Gate::authorize('check-access', ['cats', 'update']);
'nama_kucing' => 'required|string|max:255',
'ras' => 'required|string', $request->validate([
'jenis_kelamin' => 'required|in:Jantan,Betina', 'treatment_id' => 'required|exists:treatments,id',
'tanggal_lahir' => 'nullable|date', 'remark_pasca_sakit' => 'required|string|max:500',
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
'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',
'pic_id' => 'nullable|exists:users,id',
'berat_badan' => 'nullable|numeric|min:0',
'riwayat_sakit_terakhir' => 'nullable|string',
'jadwal_vaksin' => 'nullable|date',
'jadwal_obat_cacing' => 'nullable|date',
'jadwal_obat_kutu' => 'nullable|date',
'jadwal_grooming' => 'nullable|date',
]); ]);
// ========================================================
// 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.");
} }
private function handleImageUpload($file): string public function pedigree(Cat $cat)
{ {
$filename = 'cat_' . uniqid() . '.png'; return view('pages.cats.pedigree', compact('cat'));
$path = 'cats/' . $filename;
$img = Image::read($file)->cover(500, 500, 'center')->toPng();
Storage::disk('public')->put($path, (string) $img);
return $path;
} }
} }
@@ -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'),
]);
}
}
+39 -11
View File
@@ -10,6 +10,7 @@ use App\Traits\LogsActivity;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class CategoryController extends Controller class CategoryController extends Controller
{ {
@@ -18,13 +19,41 @@ class CategoryController extends Controller
/** /**
* Menampilkan daftar kategori. * Menampilkan daftar kategori.
*/ */
public function index(): View public function index(Request $request): View
{ {
// Mengambil kategori beserta hitungan item di dalamnya (optional untuk insight) // 1. Proteksi Akses (Sesuai Matriks Role Management)
$categories = Category::latest()->get(); Gate::authorize('check-access', ['categories', 'read']);
return view('pages.inventory.categories.index', compact('categories'));
$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'
));
}
/** /**
* Menyimpan kategori baru. * Menyimpan kategori baru.
*/ */
@@ -46,13 +75,12 @@ class CategoryController extends Controller
* Menampilkan halaman edit (Hanya Admin). * Menampilkan halaman edit (Hanya Admin).
*/ */
public function edit(Category $category): View 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')); return view('pages.inventory.categories.edit', compact('category'));
} }
/** /**
* Memperbarui data kategori. * Memperbarui data kategori.
+440 -59
View File
@@ -3,86 +3,467 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request; 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 Carbon\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Pagination\LengthAwarePaginator;
class DashboardController extends Controller 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 = [ $stats = [
'total_cats' => Cat::count(), 'total_cats' => Cat::activeInCattery()->count(),
'total_staff' => \App\Models\User::count(), 'total_kittens' => Kitten::count(),
'sick_cats' => Cat::whereNotNull('riwayat_sakit_terakhir')->count(), 'sick_cats' => Cat::activeInCattery()->where('treatment_status', 1)->count(),
'total_mating' => Matting::where('status', 'proses')->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'])
$upcomingBirths = Matting::with(['dam', 'sire', 'pic']) ->whereIn('status', ['proses', 'hamil'])
->where('status', 'proses') ->whereNotNull('tgl_kawin_awal')
->whereNotNull('tanggal_kawin')
->whereBetween('tanggal_kawin', [
now()->subDays(70)->toDateString(),
now()->subDays(56)->toDateString()
])
->get() ->get()
->map(function ($mating) { ->map(function ($mating) use ($now) {
$mating->edd = Carbon::parse($mating->tanggal_kawin)->addDays(63); $mating->edd = Carbon::parse($mating->tgl_kawin_awal)->addDays(63);
$mating->days_to_go = (int) now()->diffInDays($mating->edd, false); $mating->days_to_go = (int) $now->copy()->startOfDay()->diffInDays($mating->edd->copy()->startOfDay(), false);
return $mating; 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'); ->sortBy('days_to_go');
// 3. Medical Alerts $overdueAlerts = [
$medicalAlerts = [ 'vaccine' => [
'vaccine' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<=', now()->addDays(3))->count(), 'count' => Cat::activeInCattery()->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
'flea' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<=', now()->addDays(3))->count(), 'url' => route('cats.index', ['filter_overdue' => 'vaccine']),
'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(), '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 = [ * Berat terakhir untuk daftar/ranking pada bulan yang dipilih.
'medicines' => Medicine::where('qty', '<', 5)->get(['nama_obat as name', 'qty', 'unit']), * Kode ini dipertahankan agar fungsi dashboard lama tetap tersedia.
'foods' => Food::where('qty', '<', 5)->get(['nama_makanan as name', 'qty', 'unit']), */
'supplies' => Supply::where('qty', '<', 5)->get(['nama_perlengkapan as name', 'qty', 'unit']), $weightQuery = DB::table('weight_histories')
]; ->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
$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')
->select( ->select(
DB::raw('AVG(berat) as avg_weight'), 'cats.id as cat_id',
DB::raw("DATE_FORMAT(tanggal_timbang, '%M') as month"), 'cats.nama_kucing',
DB::raw('MONTH(tanggal_timbang) as month_num') 'cats.jenis_kelamin',
'cats.status',
'cats.tanggal_lahir',
'weight_histories.berat',
'weight_histories.tanggal_timbang'
) )
->where('tanggal_timbang', '>=', now()->subMonths(6)) ->where(function ($q) { $q->whereNull('cats.cattery_status')->orWhere('cats.cattery_status', 'active'); })
->groupBy('month', 'month_num') ->whereIn('weight_histories.id', function ($query) use ($selectedMonth, $selectedYear) {
->orderBy('month_num') $query->select(DB::raw('MAX(id)'))
->get(); ->from('weight_histories')
->whereMonth('tanggal_timbang', $selectedMonth)
->whereYear('tanggal_timbang', $selectedYear)
->groupBy('cat_id');
});
if ($weightTrends->isEmpty()) { if ($selectedGender) {
$weightTrends = collect([(object)['month' => now()->format('F'), 'avg_weight' => 0]]); $weightQuery->where('cats.jenis_kelamin', $selectedGender);
} }
// 7. Recent Activity if ($selectedStatus) {
$recentTreatments = Treatment::with(['cat', 'pic'])->latest()->take(5)->get(); 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( return view('pages.dashboard.index', compact(
'stats', 'stats',
'upcomingBirths', 'upcomingBirths',
'medicalAlerts', 'overdueAlerts',
'lowStock', 'individualWeightData',
'totalLowStock', 'weightSummary',
'chartData', 'weightTrendLabels',
'weightTrends', 'weightTrendSeries',
'recentTreatments' 'categoryAverageTrend',
)); 'weightCategorySummary',
'weightKpis',
'weightLatestTable',
'weightTableCategory',
'weightTrendCategory',
'weightTrendMonths',
'weightMovements',
'weightPeriodLabel',
'lowStock',
'totalLowStock',
'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';
}
}
+59 -7
View File
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
class FoodController extends Controller class FoodController extends Controller
{ {
@@ -18,17 +19,68 @@ class FoodController extends Controller
* Tampilkan daftar stok makanan. * Tampilkan daftar stok makanan.
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
$query = Food::with('category'); // Proteksi Akses
Gate::authorize('check-access', ['foods', 'read']);
if ($request->filled('search')) { $now = now();
$query->where('nama_makanan', 'like', '%' . $request->search . '%'); $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);
} }
$foods = $query->latest()->paginate(10);
return view('pages.inventory.foods.index', compact('foods'));
} }
// 2. SEARCH GLOBAL
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_makanan', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%");
});
}
// 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. * Tampilkan form tambah.
*/ */
@@ -3,18 +3,25 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\{Matting, User, Cat}; use App\Models\{Matting, User, Cat, Setting};
use App\Exports\MattingExport; use App\Exports\MattingExport;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Gate;
class MattingReportController extends Controller class MattingReportController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
// 1. Eager Loading Lengkap (Sire, Dam, PIC, dan Detail Anak) // PERBAIKAN: Proteksi akses menggunakan matriks permission
$query = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->withCount('kittens'); 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) // 2. Filter Nama Indukan (Sire atau Dam)
if ($request->filled('parent_name')) { if ($request->filled('parent_name')) {
$name = $request->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')) { if ($request->filled('registry')) {
$reg = $request->registry; $reg = is_array($request->registry) ? $request->registry : [$request->registry];
$query->where(function($q) use ($reg) { $query->where(function($q) use ($reg) {
$q->where('registry', $reg) $q->whereIn('registry', $reg) // Cek di table matting
->orWhereHas('sire', fn($sq) => $sq->where('registry', $reg)) ->orWhereHas('sire', fn($sq) => $sq->whereIn('registry', $reg)) // Cek di table cats (sire)
->orWhereHas('dam', fn($sq) => $sq->where('registry', $reg)); ->orWhereHas('dam', fn($sq) => $sq->whereIn('registry', $reg)); // Cek di table cats (dam)
}); });
} }
@@ -64,49 +71,48 @@ $query = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->withCount('kitten
} }
// 8. Eksekusi dengan Pagination // 8. Eksekusi dengan Pagination
$mattings = $query->latest()->paginate(15)->withQueryString(); $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'); $breeds = Cat::distinct()->whereNotNull('ras')->pluck('ras');
$registries = ['ICA', 'CFA', 'WCF', 'FFF', 'LOOF']; $registries = ['ICA', 'CFA', 'WCF', 'FFF', 'LOOF'];
$statuses = ['Breeding', 'Hamil', 'Teradopsi', 'RIP']; $statuses = ['Breeding', 'Hamil', 'Teradopsi', 'RIP'];
return view('pages.reports.matting.index', compact( return view('pages.reports.matting.index', compact(
'mattings', 'breeds', 'registries', 'statuses' 'mattings', 'breeds', 'registries', 'statuses'
)); ));
} }
protected function processExport($data, $format) protected function processExport($data, $format)
{ {
$fileName = "Lineage_Report_" . now()->format('Ymd_His'); $fileName = "Lineage_Report_" . now()->format('Ymd_His');
if ($format === 'xlsx') { if ($format === 'xlsx') {
return Excel::download(new MattingExport($data), "{$fileName}.xlsx"); return Excel::download(new MattingExport($data), "{$fileName}.xlsx");
}
// Penyempurnaan PDF: Tambahkan opsi font dan akses file lokal
$pdf = Pdf::loadView('pages.reports.matting.pdf', [
'data' => $data,
'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)
->download("{$fileName}.pdf");
} }
// Penyempurnaan PDF: Tambahkan opsi font dan akses file lokal (untuk logo)
$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')
]);
return $pdf->setPaper('a4', 'landscape')
->setOption([
'tempDir' => public_path(),
'chroot' => public_path(),
])
->setWarnings(false) // Menghindari PDF korup karena error kecil di blade
->download("{$fileName}.pdf");
}
public function show($id) 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); $matting = Matting::with(['sire', 'dam', 'pic', 'kittens.cat'])->findOrFail($id);
return view('pages.reports.matting.show', compact('matting')); return view('pages.reports.matting.show', compact('matting'));
} }
} }
@@ -3,15 +3,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Treatment; use App\Models\Treatment;
use App\Models\Medicine;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MedicalDashboardController extends Controller class MedicalDashboardController extends Controller
{ {
public function index() public function index()
{ {
// 1. Tren Penyakit Bulanan (Data 6 Bulan Terakhir)
$monthlyTrends = Treatment::select( $monthlyTrends = Treatment::select(
DB::raw('MONTHNAME(tanggal) as month'), DB::raw('MONTHNAME(tanggal) as month'),
DB::raw('COUNT(*) as total') DB::raw('COUNT(*) as total')
@@ -21,20 +18,28 @@ class MedicalDashboardController extends Controller
->orderBy(DB::raw('MONTH(tanggal)')) ->orderBy(DB::raw('MONTH(tanggal)'))
->get(); ->get();
// 2. Daftar Obat Paling Sering Digunakan (Top 5) // treatments memakai medicine_id. Nama obat diambil dari tabel medicines.
$topMedicines = Treatment::select('nama_obat', DB::raw('SUM(qty_used) as total_used')) $topObats = Treatment::query()
->groupBy('nama_obat') ->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') ->orderByDesc('total_used')
->take(5) ->limit(5)
->get(); ->get();
// 3. Keluhan/Penyakit yang Paling Sering Muncul
$topComplaints = Treatment::select('keluhan', DB::raw('COUNT(*) as total')) $topComplaints = Treatment::select('keluhan', DB::raw('COUNT(*) as total'))
->whereNotNull('keluhan')
->where('keluhan', '!=', '')
->groupBy('keluhan') ->groupBy('keluhan')
->orderByDesc('total') ->orderByDesc('total')
->take(5) ->limit(5)
->get(); ->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'));
}
}
+65 -8
View File
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use App\Imports\MedicineImport; use App\Imports\MedicineImport;
use App\Exports\MedicineTemplateExport; use App\Exports\MedicineTemplateExport;
use Illuminate\Support\Facades\Gate;
class MedicineController extends Controller class MedicineController extends Controller
{ {
@@ -20,18 +21,74 @@ class MedicineController extends Controller
* Tampilkan daftar obat-obatan (Index). * Tampilkan daftar obat-obatan (Index).
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
$query = Medicine::query(); // Proteksi Akses
Gate::authorize('check-access', ['medicines', 'read']);
if ($request->filled('search')) { $now = now();
$query->where('nama_obat', 'like', '%' . $request->search . '%'); $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);
} }
$medicines = $query->latest()->paginate(10);
return view('pages.inventory.medicines.index', compact('medicines'));
} }
// 2. SEARCH GLOBAL
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_obat', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%");
});
}
// 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. * Tampilkan form tambah obat.
*/ */
+15 -6
View File
@@ -8,14 +8,12 @@ use App\Exports\UnifiedReportExport;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use Barryvdh\DomPDF\Facade\Pdf; use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\Gate; // WAJIB DIIMPORT untuk sistem permission use Illuminate\Support\Facades\Gate;
class ReportController extends Controller class ReportController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
// PERBAIKAN UTAMA: Proteksi menggunakan matriks permission slug 'reports'
// Ini menggantikan pengecekan manual 'admin'
Gate::authorize('check-access', ['reports', 'read']); Gate::authorize('check-access', ['reports', 'read']);
$module = $request->get('module', 'cats'); $module = $request->get('module', 'cats');
@@ -76,7 +74,19 @@ class ReportController extends Controller
protected function applyModuleFilters($query, $request, $module) protected function applyModuleFilters($query, $request, $module)
{ {
$status = $request->status; $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 // Modul Maintenance
if (in_array($module, ['vaccine', 'deworming', 'flea', 'grooming']) && $request->filled('status')) { if (in_array($module, ['vaccine', 'deworming', 'flea', 'grooming']) && $request->filled('status')) {
@@ -112,7 +122,7 @@ class ReportController extends Controller
private function getDateColumn($module) { private function getDateColumn($module) {
return match($module) { return match($module) {
'cats' => 'tanggal_lahir', 'cats' => 'tanggal_lahir',
'medicine', 'food', 'supplies' => 'expired_date', 'medicine', 'food', 'supplies' => 'expired_date',
'vaccine' => 'jadwal_vaksin', 'vaccine' => 'jadwal_vaksin',
'deworming' => 'jadwal_obat_cacing', 'deworming' => 'jadwal_obat_cacing',
@@ -124,7 +134,6 @@ class ReportController extends Controller
protected function processExport($data, $module, $format) 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'); $fileName = "Report_{$module}_" . now()->format('Ymd_His');
if ($format === 'xlsx') { if ($format === 'xlsx') {
return Excel::download(new UnifiedReportExport($data, $module), "{$fileName}.xlsx"); return Excel::download(new UnifiedReportExport($data, $module), "{$fileName}.xlsx");
+6
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Role; use App\Models\Role;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class RoleController extends Controller class RoleController extends Controller
{ {
@@ -13,6 +14,7 @@ class RoleController extends Controller
*/ */
public function index() public function index()
{ {
Gate::authorize('check-access', ['roles', 'read']);
$roles = Role::all(); $roles = Role::all();
// Menambahkan prefix 'pages.' sesuai struktur folder Anda // Menambahkan prefix 'pages.' sesuai struktur folder Anda
return view('pages.admin.roles.index', compact('roles')); return view('pages.admin.roles.index', compact('roles'));
@@ -20,12 +22,14 @@ class RoleController extends Controller
public function create() public function create()
{ {
Gate::authorize('check-access', ['roles', 'create']);
// Mengarahkan ke folder pages sesuai standarisasi Anda // Mengarahkan ke folder pages sesuai standarisasi Anda
return view('pages.admin.roles.create'); return view('pages.admin.roles.create');
} }
public function store(Request $request) public function store(Request $request)
{ {
Gate::authorize('check-access', ['roles', 'create']);
$request->validate([ $request->validate([
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:roles,slug', 'slug' => 'required|string|max:255|unique:roles,slug',
@@ -53,6 +57,7 @@ class RoleController extends Controller
public function edit($id) public function edit($id)
{ {
Gate::authorize('check-access', ['roles', 'update']);
$role = Role::with('permissions')->findOrFail($id); $role = Role::with('permissions')->findOrFail($id);
/** /**
@@ -94,6 +99,7 @@ class RoleController extends Controller
*/ */
public function update(Request $request, $id) public function update(Request $request, $id)
{ {
Gate::authorize('check-access', ['roles', 'update']);
$role = Role::findOrFail($id); $role = Role::findOrFail($id);
DB::transaction(function () use ($request, $role) { DB::transaction(function () use ($request, $role) {
+32 -18
View File
@@ -4,11 +4,14 @@ namespace App\Http\Controllers;
use App\Models\Setting; use App\Models\Setting;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Role;
use App\Helpers\WhatsappHelper;
use App\Traits\LogsActivity; use App\Traits\LogsActivity;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
use Intervention\Image\Laravel\Facades\Image; use Intervention\Image\Laravel\Facades\Image;
class SettingController extends Controller class SettingController extends Controller
@@ -23,11 +26,15 @@ class SettingController extends Controller
'app_slogan', 'app_slogan',
'app_version', // Tambahan baru 'app_version', // Tambahan baru
'app_address', 'app_address',
'wa_number' 'wa_number',
'notif_hpl_role_id',
'notif_inventory_role_id',
'notif_weight_role_id'
]; ];
public function index() public function index()
{ {
Gate::authorize('check-access', ['settings', 'read']);
$settings = Setting::pluck('value', 'key')->all(); $settings = Setting::pluck('value', 'key')->all();
// Tambahkan default jika version belum ada // Tambahkan default jika version belum ada
@@ -44,11 +51,13 @@ class SettingController extends Controller
->take(5) ->take(5)
->get(); ->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) public function update(Request $request)
{ {
Gate::authorize('check-access', ['settings', 'update']);
$request->validate([ $request->validate([
'app_logo' => 'nullable|image|mimes:png,jpg,jpeg|max:2048', 'app_logo' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
'app_favicon' => 'nullable|image|mimes:png,jpg,ico|max:512', '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!'); return back()->with('success', 'Konfigurasi ' . $request->app_name . ' berhasil diperbarui!');
} }
@@ -101,21 +124,12 @@ class SettingController extends Controller
private function checkWhatsAppService() private function checkWhatsAppService()
{ {
// Gunakan cache atau timeout pendek agar page load tidak berat $provider = strtoupper((string) config('services.whatsapp.provider', 'fonnte'));
try { return [
$response = Http::timeout(3)->withHeaders([ 'active' => WhatsappHelper::configured(),
'Authorization' => config('services.fonnte.token') ?? env('FONNTE_TOKEN'), 'quota' => null,
])->post('https://api.fonnte.com/device'); 'message' => WhatsappHelper::configured() ? "{$provider} dikonfigurasi" : "{$provider} belum dikonfigurasi",
];
$data = $response->json();
return [
'active' => ($data['status'] ?? false) == true,
'quota' => $data['quota'] ?? 0,
'message' => ($data['status'] ?? false) ? 'API Terhubung' : ($data['reason'] ?? 'Perangkat tidak aktif')
];
} catch (\Exception $e) {
return ['active' => false, 'quota' => 0, 'message' => 'Layanan WA Offline'];
}
} }
private function checkEmailService() private function checkEmailService()
+93 -18
View File
@@ -9,6 +9,8 @@ use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
class SupplyController extends Controller class SupplyController extends Controller
{ {
@@ -17,18 +19,74 @@ class SupplyController extends Controller
/** /**
* Tampilkan daftar perlengkapan. * Tampilkan daftar perlengkapan.
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
$query = Supply::with('category'); // 1. Proteksi Akses (Sesuai Matriks Role Management)
Gate::authorize('check-access', ['supplies', 'read']);
if ($request->filled('search')) { $now = now();
$query->where('nama_perlengkapan', 'like', '%' . $request->search . '%'); $query = Supply::with('category');
// 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'
));
}
/** /**
* Tampilkan form tambah. * Tampilkan form tambah.
*/ */
@@ -103,24 +161,41 @@ class SupplyController extends Controller
/** /**
* Hapus data perlengkapan. * Hapus data perlengkapan.
*/ */
public function destroy(Supply $supply): RedirectResponse public function destroy(Supply $supply): RedirectResponse
{ {
if (auth()->user()->role !== 'admin') { // 1. OTORISASI DINAMIS (Inilah perbaikan untuk akses Supervisor)
abort(403, 'Akses Ditolak.'); // Menggunakan Gate 'check-access' agar sinkron dengan Role Management Anda
} Gate::authorize('check-access', ['supplies', 'delete']);
try {
DB::beginTransaction();
$supplyName = $supply->nama_perlengkapan; $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); Storage::disk('public')->delete($supply->foto_produk);
} }
// PENTING: Log dilakukan SEBELUM delete agar ID tidak null // 3. AUDIT TRAIL
$this->logAction('DELETED_SUPPLY', $supply, "Menghapus perlengkapan: {$supplyName}"); // 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(); $supply->delete();
// FIX: Menggunakan .index DB::commit();
return redirect()->route('inventory.supplies.index')->with('success', 'Data perlengkapan berhasil dihapus.');
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.');
} }
}
} }
+228 -80
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Cat; use App\Models\Cat;
use App\Models\Treatment; use App\Models\Treatment;
use App\Models\Medicine; use App\Models\Medicine;
use App\Models\TreatmentDetail; // Menambahkan model Detail
use App\Traits\LogsActivity; use App\Traits\LogsActivity;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -18,167 +19,314 @@ class TreatmentController extends Controller
use LogsActivity; 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([ // 1. QUERY DASAR DENGAN OPTIMASI EAGER LOADING
'cat:id,nama_kucing,foto', $query = Treatment::with([
'medicine:id,nama_obat,unit', 'cat:id,nama_kucing,foto,treatment_status',
'user:id,name' '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'));
}
/** /**
* Form tambah rekam medis. * Form tambah rekam medis.
*/ */
public function create(Request $request): View public function create(Request $request): View
{ {
$selectedCat = $request->filled('cat_id') ? Cat::findOrFail($request->cat_id) : null; $selectedCat = null;
$cats = Cat::select('id', 'nama_kucing', 'ras')->get(); if ($request->filled('cat_id')) {
$medicines = Medicine::where('qty', '>', 0)->get(); $selectedCat = Cat::select('id', 'nama_kucing', 'ras')->findOrFail($request->cat_id);
$cats = collect([$selectedCat]);
} else {
$cats = Cat::select('id', 'nama_kucing', 'ras')->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')); 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); $validated = $this->validateTreatment($request);
$fotoPath = null;
if ($request->hasFile('foto_bukti')) {
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
}
try { try {
DB::transaction(function () use ($request, &$validated) { return \Illuminate\Support\Facades\DB::transaction(function () use ($request, $fotoPath) {
$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}).");
}
if ($request->hasFile('foto_bukti')) {
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
}
// Otomatisasi data sistem
$validated['user_id'] = auth()->id();
$validated['nama_obat'] = $medicine->nama_obat;
$treatment = Treatment::create($validated); $userId = auth()->id() ?? 1;
$firstTreatment = null;
// Potong stok (decrement mendukung nilai float/decimal) if ($request->filled('items')) {
$medicine->decrement('qty', $request->qty_used); 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 klinis berhasil dicatat dan stok dipotong otomatis.');
}); });
return redirect()->route('cats.show', $request->cat_id) } catch (\Exception $e) {
->with('success', 'Rekam medis berhasil dicatat dengan presisi.'); if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
} catch (Exception $e) {
if (isset($validated['foto_bukti'])) {
Storage::disk('public')->delete($validated['foto_bukti']);
} }
return back()->withInput()->with('error', 'Gagal menyimpan: ' . $e->getMessage()); 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. * Form edit rekam medis.
*/ */
public function edit(Treatment $treatment): View public function edit(Treatment $treatment): View
{ {
$treatment->load('details');
$cats = Cat::select('id', 'nama_kucing', 'ras')->get(); $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')); 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); $validated = $this->validateTreatment($request);
try { try {
DB::transaction(function () use ($request, $treatment, &$validated) { return DB::transaction(function () use ($request, $treatment) {
// 1. Kembalikan stok lama (Desimal Revert)
$oldMedicine = Medicine::find($treatment->medicine_id); // 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) { if ($oldMedicine) {
$oldMedicine->increment('qty', $treatment->qty_used); $oldMedicine->increment('qty', $treatment->qty_used);
} }
// 2. Potong stok baru // 4. Ambil data obat PERTAMA dari form untuk memperbarui data saat ini (ID 9)
$newMedicine = Medicine::findOrFail($request->medicine_id); $firstItem = $request->items[0];
if ($newMedicine->qty < $request->qty_used) { $newMedicine = \App\Models\Medicine::findOrFail($firstItem['medicine_id']);
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi untuk perubahan ini.");
}
$newMedicine->decrement('qty', $request->qty_used);
// 3. Kelola Foto if ($newMedicine->qty < $firstItem['qty_used']) {
if ($request->hasFile('foto_bukti')) { throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi (Sisa: {$newMedicine->qty}).");
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
} }
// 4. Update data sistem // Format Diagnosa (Gabungan Diagnosa Utama + Dosis/Ket)
$validated['user_id'] = auth()->id(); $catatanDosis = isset($firstItem['dosis']) ? " | Dosis: " . $firstItem['dosis'] : "";
$validated['nama_obat'] = $newMedicine->nama_obat; $catatanKet = isset($firstItem['keterangan']) && $firstItem['keterangan'] != '' ? " (" . $firstItem['keterangan'] . ")" : "";
$treatment->update($validated); $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) { } catch (Exception $e) {
return back()->withInput()->with('error', 'Gagal memperbarui: ' . $e->getMessage()); 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 public function destroy(Treatment $treatment): RedirectResponse
{ {
try { try {
DB::transaction(function () use ($treatment) { DB::transaction(function () use ($treatment) {
if ($treatment->medicine_id) { // Kembalikan semua stok dari tabel detail
Medicine::where('id', $treatment->medicine_id)->increment('qty', $treatment->qty_used); 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}"); $this->logAction('DELETED_TREATMENT', $treatment, "Menghapus catatan medis kucing: {$treatment->cat->nama_kucing}");
// Delete otomatis menghapus details karena cascade di migration
$treatment->delete(); $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) { } catch (Exception $e) {
return back()->with('error', 'Gagal menghapus data.'); return back()->with('error', 'Gagal menghapus data: ' . $e->getMessage());
} }
} }
private function validateTreatment(Request $request)
/**
* Helper Validasi: Terpusat untuk Clean Code & Mendukung Desimal.
*/
private function validateTreatment(Request $request)
{ {
return $request->validate([ return $request->validate([
'cat_id' => 'required|exists:cats,id', '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', 'tanggal' => 'required|date',
'keluhan' => 'required|string|max:255', 'keluhan' => 'required|string|max:255',
'diagnosa' => 'required|string', 'diagnosa' => 'required|string',
'dosis' => 'required|string',
'keterangan' => 'nullable|string',
'foto_bukti' => 'nullable|image|max:2048', '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.',
]); ]);
} }
} }
+43 -4
View File
@@ -29,10 +29,12 @@ class UserController extends Controller
Gate::authorize('check-access', ['users', 'read']); Gate::authorize('check-access', ['users', 'read']);
$users = User::query() $users = User::query()
->with('role') ->with('roleModel')
->when($request->search, function ($query, $search) { ->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}%"); ->orWhere('email', 'like', "%{$search}%");
});
}) })
->latest() ->latest()
->paginate(10); ->paginate(10);
@@ -147,6 +149,44 @@ class UserController extends Controller
return redirect()->route('admin.users.index')->with('success', "User {$userName} telah dihapus."); 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 */ /* AUDIT TRAIL / LOGS SECTION */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -194,7 +234,6 @@ class UserController extends Controller
public function clearLogs() public function clearLogs()
{ {
Gate::authorize('check-access', ['logs', 'delete']); Gate::authorize('check-access', ['logs', 'delete']);
ActivityLog::truncate(); return back()->with('error', 'Penghapusan massal audit trail dinonaktifkan untuk menjaga jejak perubahan data. Gunakan kebijakan retensi/arsip bila diperlukan.');
return back()->with('success', 'Seluruh riwayat aktivitas telah dibersihkan.');
} }
} }
@@ -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();
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class AdminMiddleware
// 2. Ambil slug role (Mendukung sistem baru yang kita buat) // 2. Ambil slug role (Mendukung sistem baru yang kita buat)
// Kita gunakan optional() agar jika user tidak punya role, aplikasi tidak crash. // 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) // 3. Logika Existing Anda (Tetap Dipertahankan)
if ($roleSlug !== 'admin') { 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);
}
}
+87
View File
@@ -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;
}
}
+12
View File
@@ -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); }
}
+134 -44
View File
@@ -16,7 +16,6 @@ class Cat extends Model
/** /**
* SINKRONISASI TOTAL: 22 Kolom Database * SINKRONISASI TOTAL: 22 Kolom Database
* Memasukkan berat_terakhir, lokasi, dan kolom identitas lainnya.
*/ */
protected $fillable = [ protected $fillable = [
'nama_kucing', 'ras', 'registry', 'jenis_kelamin', 'tanggal_lahir', '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_vaksin', 'jadwal_obat_cacing', 'jadwal_obat_kutu',
'jadwal_grooming', 'induk_jantan_id', 'induk_betina_id', 'jadwal_grooming', 'induk_jantan_id', 'induk_betina_id',
'lokasi_kandang', 'pic_id', 'berat_badan', '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 * Casting Laravel 11/12 Style
*/ */
@@ -38,9 +67,10 @@ class Cat extends Model
'jadwal_obat_cacing' => 'date', 'jadwal_obat_cacing' => 'date',
'jadwal_obat_kutu' => 'date', 'jadwal_obat_kutu' => 'date',
'jadwal_grooming' => 'date', 'jadwal_grooming' => 'date',
'registry' => 'array', 'registry' => 'array', // Otomatis mengurus JSON di DB
'berat_badan' => 'decimal:2', 'berat_badan' => 'decimal:2',
'berat_terakhir' => 'decimal:2', 'berat_terakhir' => 'decimal:2',
'adopted_at' => 'date',
]; ];
} }
@@ -48,10 +78,10 @@ class Cat extends Model
/* RELATIONS */ /* RELATIONS */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
public function treatments(): HasMany public function treatments()
{ {
return $this->hasMany(Treatment::class); return $this->hasMany(Treatment::class, 'cat_id');
} }
public function weightHistories(): HasMany 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 protected function genderIcon(): Attribute
{ {
return Attribute::make( return Attribute::make(
// Database menggunakan enum 'Jantan'/'Betina' (Case Sensitive) get: fn () => strtolower($this->jenis_kelamin ?? '') === 'jantan'
get: fn () => strtolower($this->jenis_kelamin) === 'jantan' ? '<i class="fas fa-mars text-indigo-500" title="Jantan"></i>'
? '<i class="fas fa-mars text-indigo-500"></i>' : '<i class="fas fa-venus text-rose-500" title="Betina"></i>'
: '<i class="fas fa-venus text-rose-500"></i>'
); );
} }
protected function fotoUrl(): Attribute protected function fotoUrl(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: fn () => $this->foto get: function () {
? Storage::url($this->foto) if ($this->foto && Storage::disk('public')->exists($this->foto)) {
: asset('images/default-cat.png') 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 protected function usiaDetail(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: function () { get: function () {
if (!$this->tanggal_lahir) return 'Data N/A'; if (!$this->tanggal_lahir) return 'Data N/A';
$diff = $this->tanggal_lahir->diff(now()); $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 /**
{ * Badge Registry yang disempurnakan (lebih bersih)
return Attribute::make( */
get: fn () => $this->registry ?? 'NON-PED'
);
}
// Badges untuk registry
protected function registryBadges(): Attribute protected function registryBadges(): Attribute
{ {
return Attribute::make( return Attribute::make(
get: function () { get: function () {
// Jika tidak ada registry $registries = $this->registry;
if (empty($this->registry)) {
return '<span class="text-[9px] text-slate-400 font-bold italic tracking-widest">NON-PED</span>'; // 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 = [ $config = [
'ICA' => ['bg' => 'bg-blue-50', 'text' => 'text-blue-700', 'border' => 'border-blue-100'], 'ICA' => 'bg-blue-50 text-blue-700 border-blue-100',
'CFA' => ['bg' => 'bg-rose-50', 'text' => 'text-rose-700', 'border' => 'border-rose-100'], 'CFA' => 'bg-rose-50 text-rose-700 border-rose-100',
'WCF' => ['bg' => 'bg-emerald-50', 'text' => 'text-emerald-700', 'border' => 'border-emerald-100'], 'WCF' => 'bg-emerald-50 text-emerald-700 border-emerald-100',
'FFF' => ['bg' => 'bg-amber-50', 'text' => 'text-amber-700', 'border' => 'border-amber-100'], 'FFF' => 'bg-amber-50 text-amber-700 border-amber-100',
]; ];
$html = '<div class="flex flex-wrap gap-1">'; $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) { foreach ($list as $reg) {
$style = $config[$reg] ?? ['bg' => 'bg-slate-50', 'text' => 'text-slate-600', 'border' => 'border-slate-200']; if($reg === 'NON-PED') continue; // Skip jika tercampur (opsional)
$uReg = strtoupper($reg);
$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'>"; $style = $config[$uReg] ?? 'bg-slate-50 text-slate-600 border-slate-200';
$html .= $reg; $html .= "<span class='px-2 py-0.5 border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm {$style}'>{$uReg}</span>";
$html .= "</span>";
} }
$html .= '</div>'; $html .= '</div>';
return $html; 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',
};
}
} }
+16
View File
@@ -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');
}
}
+36
View File
@@ -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(); }
}
+14
View File
@@ -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); }
}
+47 -14
View File
@@ -3,19 +3,41 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class Food extends Model class Food extends Model
{ {
protected $table = 'foods'; protected $table = 'foods';
// Gunakan guarded atau fillable, pastikan 'slug' dan 'category_id' diizinkan // Menggunakan fillable lebih aman untuk tracking kolom apa saja yang masuk ke DB
protected $guarded = ['id']; 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 * Relasi ke model Category
*/ */
public function category() public function category(): BelongsTo
{ {
return $this->belongsTo(Category::class, 'category_id'); return $this->belongsTo(Category::class, 'category_id');
} }
@@ -27,14 +49,13 @@ class Food extends Model
{ {
static::saving(function ($food) { static::saving(function ($food) {
// 1. OTOMATIS GENERATE SLUG // 1. OTOMATIS GENERATE SLUG
// Hanya generate jika nama berubah atau slug masih kosong
if ($food->isDirty('nama_makanan') || empty($food->slug)) { 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) // 2. AUTO-CATEGORIZATION (SMART LOGIC)
// Hanya jalankan jika category_id belum diisi manual // Jalankan hanya jika category_id belum diisi manual atau nama makanan berubah
if (empty($food->category_id)) { if (empty($food->category_id) || $food->isDirty('nama_makanan')) {
$productName = strtolower($food->nama_makanan); $productName = strtolower($food->nama_makanan);
$categories = Category::whereNotNull('keywords')->get(); $categories = Category::whereNotNull('keywords')->get();
$found = false; $found = false;
@@ -49,27 +70,39 @@ class Food extends Model
$food->category_id = $cat->id; $food->category_id = $cat->id;
$food->kategori = $cat->name; $food->kategori = $cat->name;
$found = true; $found = true;
break; // Keluar dari loop keyword break 2; // Keluar dari kedua loop sekaligus
} }
} }
if ($found) break; // Keluar dari loop kategori
} }
// 3. FALLBACK LOGIC // 3. FALLBACK LOGIC
// Jika tidak ada keyword yang cocok, cari kategori default if (!$found && empty($food->category_id)) {
if (!$found) {
$default = Category::where('name', 'Dry Food') $default = Category::where('name', 'Dry Food')
->orWhere('name', 'Umum') ->orWhere('name', 'Umum')
->first(); ->first();
if ($default) { if ($default) {
$food->category_id = $default->id; $food->category_id = $default->id;
$food->kategori = $default->name; $food->kategori = $default->name;
} else { } 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;
}
} }
+2 -2
View File
@@ -91,9 +91,9 @@ class Kitten extends Model
return Attribute::make( return Attribute::make(
get: function () { get: function () {
if ($this->is_pedigree) { 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
View File
@@ -21,11 +21,13 @@ class Matting extends Model
'tgl_lahir_sire' => 'date', 'tgl_lahir_sire' => 'date',
'tgl_lahir_dam' => 'date', 'tgl_lahir_dam' => 'date',
'jumlah_anak_hidup' => 'integer', '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. * Saat data Matting disimpan, otomatis update data di tabel Cats.
*/ */
protected static function booted() protected static function booted()
@@ -34,8 +36,7 @@ class Matting extends Model
// 1. Update data Sire (Bapak) // 1. Update data Sire (Bapak)
if ($matting->sire_id && $matting->sire) { if ($matting->sire_id && $matting->sire) {
$matting->sire->update([ $matting->sire->update([
'pedigree' => $matting->no_pedigree_bapak, 'microchip' => $matting->no_microchip_bapak,
'microchip' => $now_microchip_bapak ?? $matting->no_microchip_bapak,
'tanggal_lahir' => $matting->tgl_lahir_sire, 'tanggal_lahir' => $matting->tgl_lahir_sire,
// Pastikan kolom di tabel 'mattings' adalah ems_bapak // Pastikan kolom di tabel 'mattings' adalah ems_bapak
'kode_ems' => $matting->ems_bapak, 'kode_ems' => $matting->ems_bapak,
@@ -45,7 +46,6 @@ class Matting extends Model
// 2. Update data Dam (Ibu) // 2. Update data Dam (Ibu)
if ($matting->dam_id && $matting->dam) { if ($matting->dam_id && $matting->dam) {
$matting->dam->update([ $matting->dam->update([
'pedigree' => $matting->no_pedigree_ibu,
'microchip' => $matting->no_microchip_ibu, 'microchip' => $matting->no_microchip_ibu,
'tanggal_lahir' => $matting->tgl_lahir_dam, 'tanggal_lahir' => $matting->tgl_lahir_dam,
// Pastikan kolom di tabel 'mattings' adalah ems_ibu // 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( return Attribute::make(
get: function () { 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); $days = (int) now()->startOfDay()->diffInDays($this->edd->startOfDay(), false);
@@ -114,7 +114,8 @@ class Matting extends Model
get: function () { get: function () {
$status = strtolower($this->status); $status = strtolower($this->status);
return match($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>', '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>', '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>', 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) * PIC (Point of Contact)
*/ */
@@ -131,7 +147,7 @@ class Matting extends Model
// Pastikan kolom di tabel 'mattings' adalah 'user_id' // Pastikan kolom di tabel 'mattings' adalah 'user_id'
// Jika kolomnya bernama 'pic_id', ganti 'user_id' menjadi 'pic_id' // Jika kolomnya bernama 'pic_id', ganti 'user_id' menjadi 'pic_id'
return $this->belongsTo(User::class, 'user_id')->withDefault([ return $this->belongsTo(User::class, 'user_id')->withDefault([
'name' => 'System / Admin' 'name' => 'Sistem / Admin'
]); ]);
} }
} }
+42
View File
@@ -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');
}
}
+73 -30
View File
@@ -4,58 +4,77 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Carbon\Carbon;
class Medicine extends Model class Medicine extends Model
{ {
/** /**
* Mass assignment untuk kolom-kolom inventori. * Mass assignment.
* Kolom 'kategori' tetap ada di fillable agar bisa diisi otomatis oleh sistem. * Menambahkan expired_at dan category_id agar sinkron dengan update database terbaru.
*/ */
protected $fillable = [ protected $fillable = [
'nama_obat', 'nama_obat',
'foto_produk', 'foto_produk',
'kategori', 'kategori',
'category_id',
'qty', 'qty',
'unit', '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 * Logic Otomatisasi & Integritas Data
*/ */
protected static function booted() protected static function booted()
{ {
static::saving(function ($medicine) { static::saving(function ($medicine) {
$name = strtolower($medicine->nama_obat); $name = strtolower($medicine->nama_obat);
// Ambil semua kategori dari database
$categories = \App\Models\Category::all();
foreach ($categories as $cat) {
// Pecah kata kunci berdasarkan koma
$keywords = explode(',', strtolower($cat->keywords));
foreach ($keywords as $keyword) { // 1. OTOMATISASI KATEGORI BERDASARKAN KEYWORDS
if (str_contains($name, trim($keyword))) { $categories = \App\Models\Category::all();
$medicine->category_id = $cat->id; $matched = false;
$medicine->kategori = $cat->name; // Sinkronisasi teks kategori
return; foreach ($categories as $cat) {
$keywords = explode(',', strtolower($cat->keywords));
foreach ($keywords as $keyword) {
if (str_contains($name, trim($keyword))) {
$medicine->category_id = $cat->id;
$medicine->kategori = $cat->name;
$matched = true;
break 2;
}
} }
} }
}
$medicine->kategori = 'Umum'; if (!$matched) {
}); $medicine->kategori = $medicine->kategori ?: 'Umum';
} }
/** // 2. SINKRONISASI TANGGAL EXPIRED (Double Guard)
* Casting tipe data untuk presisi medis. // Jika expired_at kosong tapi expired_date ada, samakan. Begitu juga sebaliknya.
*/ if ($medicine->expired_date && !$medicine->expired_at) {
protected $casts = [ $medicine->expired_at = $medicine->expired_date;
'expired_date' => 'date', // Memperbaiki error isPast() di Blade } elseif ($medicine->expired_at && !$medicine->expired_date) {
'qty' => 'float', // Mendukung dosis desimal seperti 2.5 ml $medicine->expired_date = $medicine->expired_at;
]; }
});
}
/** /**
* Accessor: Mempermudah pemanggilan foto produk dengan fallback image. * Accessor: Mempermudah pemanggilan foto produk dengan fallback image.
@@ -66,10 +85,26 @@ class Medicine extends Model
return asset('storage/' . $this->foto_produk); 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'; 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. * Relasi ke riwayat pengobatan kucing.
*/ */
@@ -77,4 +112,12 @@ class Medicine extends Model
{ {
return $this->hasMany(Treatment::class, 'medicine_id'); return $this->hasMany(Treatment::class, 'medicine_id');
} }
/**
* Melihat riwayat penggunaan obat ini di berbagai rekam medis
*/
public function treatmentDetails()
{
return $this->hasMany(TreatmentDetail::class);
}
} }
+70 -27
View File
@@ -9,59 +9,102 @@ use Illuminate\Support\Str;
class Supply extends Model class Supply extends Model
{ {
protected $table = 'supplies'; 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 public function category(): BelongsTo
{ {
return $this->belongsTo(Category::class, 'category_id'); return $this->belongsTo(Category::class, 'category_id');
} }
/**
* Logic Otomatisasi & Integritas Data
*/
protected static function booted() protected static function booted()
{ {
parent::booted();
static::saving(function ($supply) { static::saving(function ($supply) {
// 1. Sinkronisasi Slug (Mencegah error SQL sebelumnya) // 1. OTOMATIS GENERATE SLUG
if ($supply->isDirty('nama_perlengkapan') || empty($supply->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 // 2. AUTO-CATEGORIZATION (SMART LOGIC)
if (empty($supply->category_id)) { // Jalankan jika category_id kosong atau nama perlengkapan berubah
if (empty($supply->category_id) || $supply->isDirty('nama_perlengkapan')) {
$name = strtolower($supply->nama_perlengkapan); $name = strtolower($supply->nama_perlengkapan);
// Ambil kategori yang punya keywords
$categories = Category::whereNotNull('keywords')->get(); $categories = Category::whereNotNull('keywords')->get();
$found = false;
foreach ($categories as $cat) { foreach ($categories as $cat) {
$keywords = explode(',', strtolower($cat->keywords)); $keywordsArray = explode(',', strtolower($cat->keywords));
foreach ($keywords as $keyword) { foreach ($keywordsArray as $keyword) {
$trimmed = trim($keyword); $cleanKeyword = trim($keyword);
// Cek apakah "pasir" atau keyword lain ada di nama produk if (!empty($cleanKeyword) && str_contains($name, $cleanKeyword)) {
if (!empty($trimmed) && str_contains($name, $trimmed)) {
$supply->category_id = $cat->id; $supply->category_id = $cat->id;
// PENTING: Update kolom teks 'kategori' agar tampilan tidak 'Umum'
$supply->kategori = $cat->name; $supply->kategori = $cat->name;
return; $found = true;
break 2; // Berhenti mencari jika sudah ketemu
} }
} }
} }
// 3. Fallback jika tidak ada yang cocok // 3. FALLBACK LOGIC
// Cari kategori yang mengandung kata 'Pasir' atau 'Umum' if (!$found && empty($supply->category_id)) {
$default = Category::where('name', 'LIKE', '%Pasir%') $default = Category::where('name', 'LIKE', '%Pasir%')
->orWhere('name', 'Umum') ->orWhere('name', 'Umum')
->first(); ->first();
if ($default) { if ($default) {
$supply->category_id = $default->id; $supply->category_id = $default->id;
$supply->kategori = $default->name; $supply->kategori = $default->name;
} else { } 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;
}
} }
+62 -19
View File
@@ -4,8 +4,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; 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\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Treatment extends Model class Treatment extends Model
{ {
@@ -24,10 +27,12 @@ class Treatment extends Model
'foto_bukti', 'foto_bukti',
'medicine_id', 'medicine_id',
'qty_used', '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 = [ protected $casts = [
'tanggal' => 'datetime', '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 public function cat(): BelongsTo
{ {
return $this->belongsTo(Cat::class, 'cat_id')->withDefault([ return $this->belongsTo(Cat::class)->withTrashed();
'nama_kucing' => 'Kucing Terhapus'
]);
} }
/** /**
* Relasi utama ke User (PIC). * Relasi utama ke User (PIC).
* Nama fungsi 'user' wajib ada karena dipanggil di TreatmentController baris 31.
*/ */
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class, 'user_id')->withDefault([ return $this->belongsTo(User::class);
'name' => 'System / Admin'
]);
} }
/** /**
* Alias untuk relasi user. * Alias untuk relasi user (Digunakan di Dashboard).
* Nama fungsi 'pic' wajib ada karena dipanggil di DashboardController.
*/ */
public function pic(): BelongsTo public function pic(): BelongsTo
{ {
@@ -69,22 +68,38 @@ 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 public function medicine(): BelongsTo
{ {
return $this->belongsTo(Medicine::class, 'medicine_id')->withDefault([ return $this->belongsTo(Medicine::class);
'nama_obat' => $this->nama_obat ?? 'Obat Manual' }
]);
/**
* 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();
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* ACCESSORS */ /* ACCESSORS */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/** /**
* Mengambil URL foto bukti. * Mengambil URL foto bukti.
* Cara akses di Blade: {{ $treatment->foto_url }}
*/ */
public function getFotoUrlAttribute(): string public function getFotoUrlAttribute(): string
{ {
@@ -95,8 +110,26 @@ class Treatment extends Model
return Storage::url($this->foto_bukti); 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 */ /* SCOPES */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/** /**
@@ -106,4 +139,14 @@ class Treatment extends Model
{ {
$query->orderBy('tanggal', 'desc'); $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);
});
}
} }
+35
View File
@@ -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);
}
}
+24 -14
View File
@@ -8,12 +8,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Relations\BelongsTo; // Tambahan: Import untuk relasi 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 class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasPermissions; // Tambahan: Gunakan HasPermissions use HasFactory, Notifiable, HasPermissions; // Tambahan: Gunakan HasPermissions
use WebAuthnAuthentication;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
@@ -26,6 +30,8 @@ class User extends Authenticatable
'role', // Kode existing Anda 'role', // Kode existing Anda
'role_id', // Tambahan: ID untuk relasi tabel Role 'role_id', // Tambahan: ID untuk relasi tabel Role
'phone', 'phone',
'is_active',
'deactivated_at',
]; ];
/** /**
@@ -48,6 +54,8 @@ class User extends Authenticatable
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_active' => 'boolean',
'deactivated_at' => 'datetime',
]; ];
} }
@@ -55,23 +63,25 @@ class User extends Authenticatable
* Tambahan: Relasi ke tabel Roles * Tambahan: Relasi ke tabel Roles
* Setiap user memiliki satu Role. * 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 $role = $this->roleModel;
if ($this->role && $this->role->slug === 'admin') { if ($role?->slug === 'admin' || $this->getRawOriginal('role') === 'admin') {
return true; return true;
} }
// Cek di tabel role_permissions melalui relasi role return $role?->permissions()
return $this->role->permissions() ->where('module_slug', $module)
->where('module_slug', $module) ->where($permission, 1)
->where($permission, 1) // $permission berisi 'can_read', 'can_update', dll ->exists() ?? false;
->exists(); }
}
} }
+24 -2
View File
@@ -6,5 +6,27 @@ use Illuminate\Database\Eloquent\Model;
class WeightHistory extends 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);
}
}
+27 -18
View File
@@ -12,13 +12,18 @@ use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate; // Import Gate untuk sistem permission use Illuminate\Support\Facades\Gate; // Import Gate untuk sistem permission
use Illuminate\Support\Facades\URL;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
public function register(): void { /* ... */ }
public function boot(): void public function boot(): void
{ {
if (config('app.env') === 'production') {
URL::forceScheme('https');
}
// 1. DAFTARKAN OBSERVERS (Tetap Utuh & Aman) // 1. DAFTARKAN OBSERVERS (Tetap Utuh & Aman)
if (Schema::hasTable('medicines')) { if (Schema::hasTable('medicines')) {
Medicine::observe(MedicineObserver::class); Medicine::observe(MedicineObserver::class);
@@ -51,29 +56,33 @@ class AppServiceProvider extends ServiceProvider
$view->with($stockData); $view->with($stockData);
}); });
// 4. SISTEM OTORISASI GLOBAL (Gate Permission) // 4. SISTEM OTORISASI GLOBAL (Gate Permission)
Gate::define('check-access', function ($user, $module, $permission) { Gate::define('check-access', function ($user, $module, $permission) {
// Hindari pengecekan jika user tidak memiliki role_id atau data role // Dukungan akun legacy yang masih menyimpan role sebagai string.
if (!$user->role_id && !$user->role) return false; if ($user->getRawOriginal('role') === 'admin') {
return true;
// 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; if (!$user->role_id) {
return false;
// Admin Bypass: Role admin memiliki akses ke seluruh fitur }
if ($roleIdentifier === 'admin') {
$role = $user->roleModel;
if ($role?->slug === 'admin') {
return true; return true;
} }
// Menyelaraskan nama kolom (can_read, can_create, can_update, can_delete)
$column = str_starts_with($permission, 'can_') ? $permission : 'can_' . $permission; $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) return \App\Models\RolePermission::where('role_id', $user->role_id)
->where('module_slug', $module) ->where('module_slug', $module)
->where($column, 1) ->where($column, 1)
->exists(); ->exists();
} catch (\Throwable $e) {
return false;
}
}); });
} }
} }
+13 -9
View File
@@ -1,15 +1,19 @@
<?php <?php
namespace App\Traits; namespace App\Traits;
trait HasPermissions { trait HasPermissions
public function hasPermission($moduleSlug, $action) { {
// Jika user tidak punya role, akses ditolak public function hasPermission($moduleSlug, $action): bool
if (!$this->role) return false; {
if ($this->getRawOriginal('role') === 'admin') {
// Cek ke tabel permission_role return true;
return $this->role->permissions() }
$role = $this->roleModel;
if (!$role) return false;
$column = str_starts_with($action, 'can_') ? $action : 'can_' . $action;
return $role->permissions()
->where('module_slug', $moduleSlug) ->where('module_slug', $moduleSlug)
->where($action, 1) ->where($column, 1)
->exists(); ->exists();
} }
} }
+20 -7
View File
@@ -3,28 +3,41 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\AdminMiddleware; use App\Http\Middleware\AdminMiddleware;
use App\Http\Middleware\AuthenticateApiToken;
use App\Http\Middleware\EnsureUserIsActive;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware) {
// 1. Mendaftarkan alias middleware (Existing & Optimized) // 1. Mendaftarkan alias middleware
$middleware->alias([ $middleware->alias([
'admin' => AdminMiddleware::class, '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')); $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')); $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 { ->withExceptions(function (Exceptions $exceptions) {
// Di sini Anda bisa menambahkan penanganan error kustom jika diperlukan di masa depan //
})->create(); })->create();
+1
View File
@@ -10,6 +10,7 @@
"barryvdh/laravel-dompdf": "^3.1", "barryvdh/laravel-dompdf": "^3.1",
"intervention/image": "^3.11", "intervention/image": "^3.11",
"intervention/image-laravel": "^1.5", "intervention/image-laravel": "^1.5",
"laragear/webauthn": "^5.0",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"maatwebsite/excel": "^3.1", "maatwebsite/excel": "^3.1",
Generated
+166 -11
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "caa17cbea90f285d9dc19b2eb78ab1e9", "content-hash": "a293add0741bc2bc83272d5a80311add",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -1833,6 +1833,161 @@
], ],
"time": "2025-04-04T15:09:55+00:00" "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", "name": "laravel/framework",
"version": "v12.48.1", "version": "v12.48.1",
@@ -2802,29 +2957,29 @@
}, },
{ {
"name": "maatwebsite/excel", "name": "maatwebsite/excel",
"version": "3.1.67", "version": "3.1.68",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git", "url": "https://github.com/SpartnerNL/Laravel-Excel.git",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d" "reference": "1854739267d81d38eae7d8c623caf523f30f256b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d", "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/1854739267d81d38eae7d8c623caf523f30f256b",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d", "reference": "1854739267d81d38eae7d8c623caf523f30f256b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"composer/semver": "^3.3", "composer/semver": "^3.3",
"ext-json": "*", "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", "php": "^7.0||^8.0",
"phpoffice/phpspreadsheet": "^1.30.0", "phpoffice/phpspreadsheet": "^1.30.0",
"psr/simple-cache": "^1.0||^2.0||^3.0" "psr/simple-cache": "^1.0||^2.0||^3.0"
}, },
"require-dev": { "require-dev": {
"laravel/scout": "^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", "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
"predis/predis": "^1.1" "predis/predis": "^1.1"
}, },
"type": "library", "type": "library",
@@ -2867,7 +3022,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", "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": [ "funding": [
{ {
@@ -2879,7 +3034,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-08-26T09:13:16+00:00" "time": "2026-03-17T20:51:10+00:00"
}, },
{ {
"name": "maennchen/zipstream-php", "name": "maennchen/zipstream-php",
@@ -9931,5 +10086,5 @@
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }
+9 -1
View File
@@ -61,8 +61,9 @@ return [
'providers' => [ 'providers' => [
'users' => [ 'users' => [
'driver' => 'eloquent', 'driver' => 'eloquent-webauthn',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', App\Models\User::class),
'password_fallback' => true,
], ],
// 'users' => [ // '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 | Resetting Passwords
+9
View File
@@ -28,6 +28,15 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), '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' => [ 'slack' => [
'notifications' => [ 'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
+53
View File
@@ -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 public function down(): void
{ {
Schema::table('cats', function (Blueprint $table) { // No-op: kolom deleted_at dimiliki migration legacy sebelumnya.
$table->dropSoftDeletes();
});
} }
}; };
@@ -57,6 +57,7 @@ return new class extends Migration
*/ */
public function down(): void 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);
}
}
});
}
};
-6
View File
@@ -1409,7 +1409,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -2670,7 +2669,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3095,7 +3093,6 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -3216,7 +3213,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -3305,7 +3301,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -3410,7 +3405,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+8
View File
@@ -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

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.
+142
View File
@@ -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();
});
});
};
}
})();
+373
View File
@@ -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;
+6
View File
@@ -1,3 +1,9 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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
View File
@@ -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> <x-guest-layout>
<div class="mb-4 text-sm text-gray-600"> <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> </div>
<!-- Session Status --> <!-- Session Status -->
@@ -18,7 +18,7 @@
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
<x-primary-button> <x-primary-button>
{{ __('Email Password Reset Link') }} {{ __('Kirim tautan atur ulang kata sandi') }}
</x-primary-button> </x-primary-button>
</div> </div>
</form> </form>
+27 -98
View File
@@ -1,110 +1,39 @@
<x-guest-layout> <x-guest-layout>
{{-- Title Browser Dinamis --}}
<x-slot name="title">Masuk | {{ $setting->app_name ?? config('app.name') }}</x-slot> <x-slot name="title">Masuk | {{ $setting->app_name ?? config('app.name') }}</x-slot>
<div class="min-h-screen bg-slate-50 px-4 py-10 sm:flex sm:items-center sm:justify-center">
<div class="min-h-screen flex bg-white overflow-hidden"> <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">
{{-- SISI KIRI: BRANDING (Optimasi Proporsi) --}} <div>
<div class="hidden lg:flex lg:w-[35%] relative bg-slate-900 justify-center items-center p-8"> @if(isset($setting->app_logo) && $setting->app_logo)
<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> <img src="{{ asset('storage/'.$setting->app_logo) }}" alt="Logo" class="mb-8 h-16 w-auto object-contain">
<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">
@else @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 @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> </div>
<p class="text-xs text-slate-500">Akses hanya untuk pengguna yang terdaftar.</p>
{{-- 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>
</div> </div>
</div> <div class="p-6 sm:p-10 lg:p-12">
<div class="mb-8">
{{-- SISI KANAN: FORM LOGIN (Dibuat Lebih Center & Proporsional) --}} <h2 class="text-2xl font-bold text-slate-900">Masuk ke ILYCats</h2>
<div class="flex-1 flex items-center justify-center p-6 sm:p-12 lg:p-16 bg-slate-50/50"> <p class="mt-2 text-sm text-slate-500">Gunakan email dan kata sandi akun Anda.</p>
{{-- 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> </div>
<x-auth-session-status class="mb-4" :status="session('status')" /> <x-auth-session-status class="mb-4" :status="session('status')" />
<form method="POST" action="{{ route('login') }}" class="space-y-5">@csrf
<form method="POST" action="{{ route('login') }}" class="space-y-6"> <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>
@csrf <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>
{{-- Email Input --}} <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>
<div class="group"> <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>
<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> </form>
<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 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>
</div> </div>
</div> </div>
</div> </div>
</x-guest-layout> <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>
+4 -4
View File
@@ -28,9 +28,9 @@
<x-input-error :messages="$errors->get('password')" class="mt-2" /> <x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> </div>
<!-- Confirm Password --> <!-- Konfirmasi Kata Sandi -->
<div class="mt-4"> <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" <x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password" type="password"
@@ -41,11 +41,11 @@
<div class="flex items-center justify-end mt-4"> <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') }}"> <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> </a>
<x-primary-button class="ms-4"> <x-primary-button class="ms-4">
{{ __('Register') }} {{ __('Daftar') }}
</x-primary-button> </x-primary-button>
</div> </div>
</form> </form>
@@ -19,9 +19,9 @@
<x-input-error :messages="$errors->get('password')" class="mt-2" /> <x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> </div>
<!-- Confirm Password --> <!-- Konfirmasi Kata Sandi -->
<div class="mt-4"> <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" <x-text-input id="password_confirmation" class="block mt-1 w-full"
type="password" type="password"
@@ -32,7 +32,7 @@
<div class="flex items-center justify-end mt-4"> <div class="flex items-center justify-end mt-4">
<x-primary-button> <x-primary-button>
{{ __('Reset Password') }} {{ __('Atur ulang kata sandi') }}
</x-primary-button> </x-primary-button>
</div> </div>
</form> </form>
+1 -1
View File
@@ -24,7 +24,7 @@
@csrf @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"> <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> </button>
</form> </form>
</div> </div>
+1 -1
View File
@@ -3,7 +3,7 @@
@php @php
// Mengambil data dari database dengan fallback jika data kosong // Mengambil data dari database dengan fallback jika data kosong
$appName = \App\Models\Setting::where('key', 'app_name')->value('value') ?? 'Ily Cattery'; $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 @endphp
<div class="flex flex-col items-center gap-2"> <div class="flex flex-col items-center gap-2">
@@ -1,26 +1,43 @@
@props(['active', 'icon']) @props([
'active' => false,
'icon' => null,
'tone' => 'indigo',
])
@php @php
// Logika untuk menentukan class CSS jika link sedang aktif atau tidak $classes = ($active ?? false)
$classes = ($active ?? false) ? 'group flex items-center rounded-xl bg-slate-100 px-2.5 py-2 text-sm font-bold text-slate-900 transition-all'
? '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' : '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';
: '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';
$iconClasses = ($active ?? false) $tones = [
? 'text-indigo-600' 'indigo' => ['idle' => 'bg-indigo-50 text-indigo-600', 'active' => 'bg-indigo-600 text-white', 'paw' => 'text-indigo-500'],
: 'text-slate-400 group-hover: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 @endphp
<a {{ $attributes->merge(['class' => $classes]) }}> <a {{ $attributes->merge(['class' => $classes]) }}>
@if($icon) @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 @endif
<span class="truncate"> <span class="truncate">{{ $slot }}</span>
{{ $slot }}
</span>
@if($active ?? false) @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 @endif
</a> </a>
@@ -4,14 +4,14 @@
$cleanStatus = strtolower(trim($status ?? '')); $cleanStatus = strtolower(trim($status ?? ''));
$safeWords = ['sehat', 'normal', 'aktif', 'ok', 'good', 'sembuh', '-', '']; $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-green-100 text-green-700 border-green-200'
: 'bg-red-100 text-red-700 border-red-200'; : 'bg-red-100 text-red-700 border-red-200';
@endphp @endphp
<span {{ $attributes->merge(['class' => "px-3 py-1 rounded-full text-xs font-bold border $classes"]) }}> <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> <i class="fas {{ $isSehat ? 'fa-check-circle' : 'fa-pills' }} mr-1"></i>
{{ $isHealthy ? 'Sehat / Normal' : $status }} {{ $isSehat ? 'Sehat / Normal' : $status }}
</span> </span>
+1 -1
View File
@@ -1,7 +1,7 @@
<x-app-layout> <x-app-layout>
<x-slot name="header"> <x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight"> <h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Dashboard') }} {{ __('Beranda') }}
</h2> </h2>
</x-slot> </x-slot>
+44 -180
View File
@@ -1,182 +1,46 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full"> <html lang="id" class="h-full">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
@php
@php $appName = \App\Models\Setting::where('key','app_name')->value('value') ?? config('app.name','ILY Cattery');
// Optimasi: Gunakan try-catch agar jika tabel settings belum dimigrasi, app tidak crash $favicon = \App\Models\Setting::where('key','app_favicon')->value('value');
try { @endphp
$settings = \App\Models\Setting::whereIn('key', ['app_name', 'app_slogan', 'app_favicon', 'app_version']) <title>{{ $appName }}</title>
->pluck('value', 'key'); <link rel="icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}">
} catch (\Exception $e) { <link rel="stylesheet" href="{{ asset('vendor/fontawesome/css/all.min.css') }}">
$settings = collect(); @vite(['resources/css/app.css','resources/js/app.js'])
} <script src="{{ asset('vendor/ilycats/compat.js') }}" defer></script>
@stack('styles')
$appName = $settings['app_name'] ?? config('app.name', 'Ily Cattery'); <style>[x-cloak]{display:none!important}</style>
$appSlogan = $settings['app_slogan'] ?? 'Dedikasi untuk Kesehatan dan Kebahagiaan Anabul'; </head>
$appVersion = $settings['app_version'] ?? 'v1.0.0'; <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">
$favicon = $settings['app_favicon'] ?? null; <div class="flex min-h-screen">
@endphp @include('layouts.navigation-sidebar')
<div class="flex min-w-0 flex-1 flex-col">
<link rel="icon" type="image/x-icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}"> @include('layouts.navigation')
<main class="flex-1 overflow-y-auto">
<title>{{ $appName }}</title> <div class="mx-auto w-full max-w-[1500px] p-4 md:p-6 lg:p-8">
@if($errors->any())
{{-- Fonts & Icons --}} <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>
<link rel="preconnect" href="https://fonts.bunny.net"> @endif
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,800,900&display=swap" rel="stylesheet" /> @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
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> @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
{{-- Scripts & Styles --}} {{ $slot }}
@vite(['resources/css/app.css', 'resources/js/app.js']) </div>
</main>
@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>
</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 --}}
@include('layouts.navigation-sidebar')
<div class="flex-1 flex flex-col min-w-0 bg-slate-50 relative">
{{-- Top Navigation --}}
@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) --}}
@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>
@endif
{{-- Main Slot (Halaman Konten Anda) --}}
{{ $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> </div>
</div>
{{-- SWEETALERT 2 INTEGRATION --}} <script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script> window.confirmDelete = function(target, itemName='data ini') {
<script> if (!window.confirm(`Hapus ${itemName}? Tindakan ini tidak dapat dibatalkan.`)) return;
document.addEventListener('DOMContentLoaded', function() { if (typeof target === 'string') document.getElementById(target)?.submit();
else target.closest('form')?.submit();
// 1. KONFIGURASI TOAST };
const IlyToast = Swal.mixin({ </script>
toast: true, @stack('scripts')
position: 'top-end', </body>
showConfirmButton: false, </html>
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();
}
}
});
}
});
</script>
@stack('scripts')
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More