Compare commits

...

11 Commits

Author SHA1 Message Date
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
237 changed files with 19679 additions and 2787 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
+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');
@@ -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) {
@@ -157,14 +163,12 @@ class MattingController extends Controller
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 +222,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');
+355 -74
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
{ {
@@ -26,9 +33,10 @@ class CatController extends Controller
// Proteksi Akses: Read // Proteksi Akses: Read
Gate::authorize('check-access', ['cats', 'read']); Gate::authorize('check-access', ['cats', 'read']);
$query = Cat::query()->with(['pic', 'sire', 'dam']); $now = now();
$query = Cat::query()->with(['pic:id,name', 'sire', 'dam', 'registrations.association']);
// 1. Pencarian Global // 1. PENCARIAN GLOBAL
if ($request->filled('search')) { if ($request->filled('search')) {
$search = $request->search; $search = $request->search;
$query->where(function($q) use ($search) { $query->where(function($q) use ($search) {
@@ -36,28 +44,86 @@ class CatController extends Controller
->orWhere('ras', 'like', "%{$search}%") ->orWhere('ras', 'like', "%{$search}%")
->orWhere('microchip', 'like', "%{$search}%") ->orWhere('microchip', 'like', "%{$search}%")
->orWhere('pedigree', 'like', "%{$search}%") ->orWhere('pedigree', 'like', "%{$search}%")
->orWhere('kode_ems', 'like', "%{$search}%"); ->orWhere('kode_ems', 'like', "%{$search}%")
->orWhere('lokasi_kandang', 'like', "%{$search}%")
->orWhereHas('pic', function($sub) use ($search) {
$sub->where('name', 'like', "%{$search}%");
});
}); });
} }
// 2. Filter Spesifik // 2. FILTER OVERDUE (Link dari Dashboard)
if ($request->filled('status')) $query->where('status', $request->status); if ($request->filled('filter_overdue')) {
$filter = $request->get('filter_overdue');
$query->where(function($q) use ($filter, $now) {
if ($filter === 'vaccine') {
$q->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now);
} elseif ($filter === 'flea' || $filter === 'obat_kutu') {
$q->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now);
} elseif ($filter === 'deworming' || $filter === 'obat_cacing') {
$q->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now);
} elseif ($filter === 'grooming') {
$q->whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now);
}
});
}
// --- TAMBAHAN: 2.1 FILTER KESEHATAN (Link dari Dashboard Under Treatment) ---
if ($request->get('filter_health') === 'treatment') {
// Menggunakan kolom treatment_status: 1 (Treatment), 2 (Healthy)
$query->where('treatment_status', 1);
}
// 3. FILTER STATUS & LOGIKA USIA DINAMIS
if ($request->filled('status')) {
$status = $request->status;
if (in_array(strtolower($status), ['adult', 'dewasa'])) {
// Dewasa: > 12 bulan
$query->where('tanggal_lahir', '<=', $now->copy()->subMonths(12));
} elseif ($status === 'Junior') {
// Junior: 7 bulan - 12 bulan
$query->whereBetween('tanggal_lahir', [
$now->copy()->subMonths(12)->addDay(),
$now->copy()->subMonths(7)
]);
} elseif ($status === 'Kitten') {
// Kitten: < 7 bulan
$query->where('tanggal_lahir', '>', $now->copy()->subMonths(7));
} else {
$query->where('status', $status);
}
}
// 4. FILTER STANDAR (Ras & Gender)
if ($request->filled('ras')) $query->where('ras', $request->ras); if ($request->filled('ras')) $query->where('ras', $request->ras);
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender); if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
$cats = $query->latest()->paginate(15)->withQueryString(); // 5. VALIDASI SORTING
$allowedSorts = ['nama_kucing', 'lokasi_kandang', 'berat_badan', 'created_at', 'pic_name'];
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'created_at';
$direction = strtolower($request->get('direction')) === 'asc' ? 'asc' : 'desc';
// 3. Statistik Dashboard (Tetap Utuh) if ($sort === 'pic_name') {
$totalCats = Cat::count(); $query->leftJoin('users', 'cats.pic_id', '=', 'users.id')
$safeWords = ['sehat', 'normal', 'aktif', 'sembuh', '-', 'tidak ada', 'clean']; ->select('cats.*')
$sickCats = Cat::whereNotNull('riwayat_sakit_terakhir') ->orderBy('users.name', $direction);
->where(function($q) use ($safeWords) { } else {
foreach ($safeWords as $word) { $query->orderBy("cats.$sort", $direction);
$q->where('riwayat_sakit_terakhir', 'NOT LIKE', "%{$word}%");
} }
})->count();
$breeds = Cat::distinct()->pluck('ras'); // 6. EKSEKUSI DENGAN PAGINASI
$cats = $query->paginate(15)->withQueryString();
// 7. STATISTIK (DIPERBAIKI: Menggunakan treatment_status)
$totalCats = Cat::count();
// Perbaikan: Tidak lagi menggunakan string search yang tidak akurat (15 vs 4)
// Sekarang langsung merujuk pada kolom treatment_status bernilai 1
$sickCats = Cat::where('treatment_status', 1)->count();
$breeds = Cat::distinct()->whereNotNull('ras')->pluck('ras');
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds')); return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
} }
@@ -78,43 +144,89 @@ class CatController extends Controller
{ {
Gate::authorize('check-access', ['cats', 'create']); Gate::authorize('check-access', ['cats', 'create']);
// 1. Validasi
$validated = $this->validateCat($request); $validated = $this->validateCat($request);
return DB::transaction(function () use ($request, $validated) { return DB::transaction(function () use ($request, $validated) {
// 2. Handling Upload Foto (Fix: Encoding Intervention v3)
if ($request->hasFile('foto')) { if ($request->hasFile('foto')) {
$validated['foto'] = $this->handleImageUpload($request->file('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;
} }
// 4. Handling Status Otomatis
if (empty($validated['status'])) { if (empty($validated['status'])) {
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active'; $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();
$cat = Cat::create($validated); $cat = Cat::create($validated);
$this->logAction('CREATED', $cat, "Mendaftarkan pasien baru: {$cat->nama_kucing}", 'Cat');
// 6. Logging (Pastikan Trait LogsActivity & Tabel Log sudah punya kolom 'model')
try {
$this->logAction('CREATED', $cat, "Mendaftarkan kucing baru: {$cat->nama_kucing}", 'Cat');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning("Log gagal dicatat: " . $e->getMessage());
}
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan."); 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
{ {
// 1. Otorisasi (TETAP ADA)
Gate::authorize('check-access', ['cats', 'read']); Gate::authorize('check-access', ['cats', 'read']);
// 2. Eager Loading (DITAMBAHKAN medicalRecords & performer)
// Kita memuat medicalRecords agar tidak terjadi query berulang di dalam Loop Blade (N+1 Problem)
$cat->load([ $cat->load([
'treatments' => fn($q) => $q->latest(), 'treatments' => fn($q) => $q->latest(),
'sire', 'dam', 'pic', 'medicalRecords' => fn($q) => $q->latest(), // Baru: Riwayat Vaksin/Obat Cacing
'weightHistories' => fn($q) => $q->latest()->limit(12) 'medicalRecords.performer', // Baru: Siapa yang melakukan tindakan
'sire',
'dam',
'pic',
'weightHistories' => fn($q) => $q->latest()->limit(12),
'registrations.association',
'registrations.documents'
]); ]);
return view('pages.cats.show', compact('cat'));
// 3. Ambil Data Inventori (Baru: Untuk Datalist di Modal Input)
// Menampilkan semua obat yang stoknya masih tersedia
$availableMedicines = Medicine::where('qty', '>', 0)
->orderBy('nama_obat', 'asc')
->get(['nama_obat', 'qty', 'unit']);
// 4. Return View dengan Compact (DITAMBAHKAN availableMedicines)
return view('pages.cats.show', compact('cat', 'availableMedicines'));
} }
public function edit(Cat $cat): View public function edit(Cat $cat): View
@@ -131,27 +243,43 @@ class CatController extends Controller
public function update(Request $request, Cat $cat): RedirectResponse public function update(Request $request, Cat $cat): RedirectResponse
{ {
// 1. Otorisasi Akses
Gate::authorize('check-access', ['cats', 'update']); Gate::authorize('check-access', ['cats', 'update']);
// 2. Validasi (Mengirimkan $cat->id agar Rule:unique mengabaikan record ini sendiri)
$validated = $this->validateCat($request, $cat->id); $validated = $this->validateCat($request, $cat->id);
return DB::transaction(function () use ($request, $validated, $cat) { return DB::transaction(function () use ($request, $validated, $cat) {
// 3. Handling Foto
if ($request->hasFile('foto')) { if ($request->hasFile('foto')) {
if ($cat->foto) Storage::disk('public')->delete($cat->foto); // Hapus foto lama jika ada
if ($cat->foto && Storage::disk('public')->exists($cat->foto)) {
Storage::disk('public')->delete($cat->foto);
}
// Upload foto baru menggunakan helper yang sudah diperbaiki
$validated['foto'] = $this->handleImageUpload($request->file('foto')); $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']);
// 5. Update Data Utama
// Logika berat_badan sudah otomatis dihandle oleh Model (static::saving/updated)
$cat->update($validated); $cat->update($validated);
$this->logAction('UPDATED', $cat, "Update data medis/profil: {$cat->nama_kucing}", 'Cat');
return redirect()->route('cats.index')->with('success', 'Data berhasil diperbarui.'); // 6. Activity Log (Dibalut Try-Catch agar proses update tidak gagal hanya karena log)
try {
$this->logAction('UPDATED', $cat, "Memperbarui profil medis & identitas: {$cat->nama_kucing}", 'Cat');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning("Activity Log gagal dicatat: " . $e->getMessage());
}
return redirect()->route('cats.index')->with('success', "Data {$cat->nama_kucing} berhasil diperbarui.");
}); });
} }
@@ -189,71 +317,224 @@ 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']); Gate::authorize('check-access', ['cats', 'read']);
$cat->load(['pic', 'sire', 'dam']); $cat->load([
'medicalRecords' => fn($q) => $q->orderBy('date_given', 'desc'),
// FIX: Konversi SVG ke Base64 agar tampil di domPDF tanpa Imagick 'medicalRecords.performer',
$qrCodeRaw = QrCode::format('svg') 'treatments' => fn($q) => $q->orderBy('tanggal', 'desc'), // <--- Pastikan ini ada
->size(150) 'sire',
->margin(1) 'dam',
->generate(route('cats.show', $cat->id)); 'weightHistories' => fn($q) => $q->orderBy('created_at', 'desc')
$qrCode = base64_encode($qrCodeRaw);
$pdf = Pdf::loadView('pages.cats.pdf-card', [
'cat' => $cat,
'qrCode' => $qrCode, // Dikirim sebagai Base64
'printedAt' => Carbon::now()->translatedFormat('d F Y H:i')
]); ]);
return $pdf->setPaper([0, 0, 300, 500], 'portrait') $groupedHistory = $cat->medicalRecords->groupBy('type');
->stream('MEDICAL_PASS_' . strtoupper($cat->nama_kucing) . '.pdf');
$pdf = Pdf::loadView('pages.reports.medical_history', compact('cat', 'groupedHistory'))
->setPaper('a4', 'portrait');
$filename = "Medical_Report_" . Str::slug($cat->nama_kucing) . "_" . date('Ymd') . ".pdf";
return $pdf->download($filename);
}
public function bulkWeightPage(): View
{
Gate::authorize('check-access', ['cats', 'update']);
return view('pages.cats.bulk-weight');
}
public function downloadWeightTemplate(): BinaryFileResponse
{
Gate::authorize('check-access', ['cats', 'update']);
// Menambahkan Jam & Menit agar nama file lebih spesifik
$fileName = "Template_Bulk_BB_" . date('Ymd_Hi') . ".xlsx";
return Excel::download(new CatWeightTemplateExport, $fileName);
}
public function uploadWeight(Request $request): RedirectResponse
{
Gate::authorize('check-access', ['cats', 'update']);
$request->validate([
// Tambahkan rule 'file' untuk memastikan inputan benar-benar file
'file_excel' => 'required|file|mimes:xlsx,xls,csv|max:5120',
], [
'file_excel.required' => 'File Excel wajib diunggah.',
'file_excel.mimes' => 'Format file harus berupa Excel (.xlsx, .xls) atau CSV.',
'file_excel.max' => 'Ukuran file maksimal adalah 5MB.',
]);
try {
// 1. Masukkan class import ke dalam variabel
$importHelper = new CatWeightImport();
// 2. Jalankan proses import menggunakan variabel tersebut
Excel::import($importHelper, $request->file('file_excel'));
// 3. Ambil jumlah data yang berhasil diproses
$totalBerhasil = $importHelper->getImportedCount();
// 4. Sisipkan jumlahnya ke dalam pesan sukses
return redirect()->route('cats.index')
->with('success', "Data berat badan massal berhasil diproses! Sebanyak {$totalBerhasil} record berhasil disinkronisasi ke dalam database.");
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
return back()->with('error', 'Tipe file tidak dikenali. Pastikan file tidak rusak (corrupt).');
} catch (\Exception $e) {
// MENANGKAP ERROR SISTEM / DATABASE
return back()->with('error', 'Gagal memproses file Excel. Pastikan format kolom sesuai template. Error: ' . $e->getMessage());
}
}
/**
* Simpan/Update Sertifikat Pedigree
*/
public function uploadPedigree(Request $request, Cat $cat)
{
Gate::authorize('check-access', ['cats', 'update']);
$request->validate(['sertifikat_ped_file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240']);
$registration = $cat->registrations()->where('is_primary', true)->first() ?: $cat->registrations()->first();
if (!$registration) {
return redirect()->route('cats.pedigree', $cat)->with('error', 'Tambahkan registrasi asosiasi terlebih dahulu sebelum mengunggah sertifikat.');
}
$path = $request->file('sertifikat_ped_file')->store('cat-registrations/' . $cat->id, 'public');
$registration->documents()->create([
'label' => 'Sertifikat Pedigree',
'document_type' => 'pedigree',
'file_path' => $path,
]);
return redirect()->route('cats.pedigree', $cat)->with('success', 'Sertifikat pedigree berhasil ditambahkan.');
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* HELPERS (TETAP UTUH) */ /* HELPERS (TETAP UTUH) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
private function validateCat(Request $request, $id = null) private function validateCat(Request $request, $id = null)
{ {
return $request->validate([ return $request->validate([
// Identitas Dasar
'nama_kucing' => 'required|string|max:255', 'nama_kucing' => 'required|string|max:255',
'ras' => 'required|string', 'ras' => 'required|string|max:100',
'jenis_kelamin' => 'required|in:Jantan,Betina', 'jenis_kelamin' => 'required|in:Jantan,Betina',
'tanggal_lahir' => 'nullable|date', 'tanggal_lahir' => 'nullable|date|before_or_equal:today',
// Media
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072', 'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
// Registry & Legalitas (Ditambahkan NON-PED & Unik)
'registry' => 'nullable|array', 'registry' => 'nullable|array',
'registry.*' => 'string', 'registry.*' => ['string', \Illuminate\Validation\Rule::in(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'])],
'pedigree' => 'nullable|string|max:255', 'pedigree' => [
'microchip' => 'nullable|string|max:255', 'nullable', 'string', 'max:255',
'kode_ems' => 'nullable|string|max:255', Rule::unique('cats', 'pedigree')->ignore($id)
'status' => 'nullable|string', ],
'induk_jantan_id' => 'nullable|exists:cats,id', 'microchip' => [
'induk_betina_id' => 'nullable|exists:cats,id', 'nullable', 'string', 'max:255',
'lokasi_kandang' => 'nullable|string', Rule::unique('cats', 'microchip')->ignore($id)
],
'kode_ems' => 'nullable|string|max:50',
// Status & Lokasi
'status' => 'nullable|string|max:50',
'lokasi_kandang'=> 'nullable|string|max:255',
'pic_id' => 'nullable|exists:users,id', 'pic_id' => 'nullable|exists:users,id',
'berat_badan' => 'nullable|numeric|min:0',
// Silsilah (Validasi Induk harus sesuai Gender)
'induk_jantan_id' => [
'nullable',
Rule::exists('cats', 'id')->where(function ($query) {
$query->where('jenis_kelamin', 'Jantan');
}),
],
'induk_betina_id' => [
'nullable',
Rule::exists('cats', 'id')->where(function ($query) {
$query->where('jenis_kelamin', 'Betina');
}),
],
// Medis & Monitoring
'berat_badan' => 'nullable|numeric|min:0|max:20',
'riwayat_sakit_terakhir' => 'nullable|string', 'riwayat_sakit_terakhir' => 'nullable|string',
'jadwal_vaksin' => 'nullable|date', 'jadwal_vaksin' => 'nullable|date',
'jadwal_obat_cacing' => 'nullable|date', 'jadwal_obat_cacing' => 'nullable|date',
'jadwal_obat_kutu' => 'nullable|date', 'jadwal_obat_kutu' => 'nullable|date',
'jadwal_grooming' => 'nullable|date', 'jadwal_grooming' => 'nullable|date',
], [
// Custom Message (Opsional agar user paham kenapa gagal)
'induk_jantan_id.exists' => 'Induk jantan harus kucing dengan jenis kelamin Jantan.',
'induk_betina_id.exists' => 'Induk betina harus kucing dengan jenis kelamin Betina.',
'microchip.unique' => 'Nomor Microchip sudah terdaftar di sistem.',
'tanggal_lahir.before_or_equal' => 'Tanggal lahir tidak boleh melebihi hari ini.',
]); ]);
} }
private function handleImageUpload($file): string public function completeTreatment(Request $request, Cat $cat)
{ {
$filename = 'cat_' . uniqid() . '.png'; Gate::authorize('check-access', ['cats', 'update']);
$path = 'cats/' . $filename;
$img = Image::read($file)->cover(500, 500, 'center')->toPng(); $request->validate([
Storage::disk('public')->put($path, (string) $img); 'treatment_id' => 'required|exists:treatments,id',
return $path; 'remark_pasca_sakit' => 'required|string|max:500',
]);
// ========================================================
// 1. EKSEKUSI PAKSA TABEL TREATMENTS (JALUR KERAS)
// DB::table akan menembus langsung ke database MySQL Anda.
// ========================================================
DB::table('treatments')
->where('id', $request->treatment_id)
->update([
'status_perawatan' => 2,
'remark_pasca_sakit' => $request->remark_pasca_sakit,
'updated_at' => now(),
]);
// Ambil nama keluhan untuk dimunculkan di notifikasi
$namaKeluhan = DB::table('treatments')->where('id', $request->treatment_id)->value('keluhan');
// ========================================================
// 2. CEK SISA PENYAKIT (Adakah penyakit lain di kucing ini yang masih = 1?)
// ========================================================
$sisaPenyakitAktif = DB::table('treatments')
->where('cat_id', $cat->id)
->where('status_perawatan', 1)
->exists();
// ========================================================
// 3. EKSEKUSI TABEL CATS BERDASARKAN HASIL CEK
// ========================================================
if (!$sisaPenyakitAktif) {
// JIKA SUDAH TIDAK ADA PENYAKIT LAIN -> Kucing Full Sehat (2)
$cat->treatment_status = 2;
$cat->save();
return redirect()->back()->with('success', "Penyakit '{$namaKeluhan}' disembuhkan. Kucing sekarang berstatus SEHAT sepenuhnya!");
}
// JIKA MASIH ADA PENYAKIT LAIN -> Pastikan kucing TETAP berstatus Treatment (1)
$cat->treatment_status = 1;
$cat->save();
return redirect()->back()->with('warning', "Penyakit '{$namaKeluhan}' disembuhkan, namun kucing MASIH DALAM PERAWATAN untuk keluhan lainnya.");
}
public function pedigree(Cat $cat)
{
return view('pages.cats.pedigree', compact('cat'));
} }
} }
@@ -0,0 +1,139 @@
<?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 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'),
]);
}
}
+35 -7
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,11 +19,39 @@ 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'
));
} }
/** /**
@@ -47,9 +76,8 @@ class CategoryController extends Controller
*/ */
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'));
} }
+416 -49
View File
@@ -3,86 +3,453 @@
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::count(),
'total_staff' => \App\Models\User::count(), 'total_kittens' => Kitten::count(),
'sick_cats' => Cat::whereNotNull('riwayat_sakit_terakhir')->count(), 'sick_cats' => Cat::where('treatment_status', 1)->count(),
'total_mating' => Matting::where('status', 'proses')->count(), 'total_mating' => Matting::where('status', 'proses')->count(),
]; ];
// 2. Birth Watch: Persalinan 7 Hari ke Depan (Rumus: H+63) $upcomingBirths = Matting::with(['dam', 'sire'])
$upcomingBirths = Matting::with(['dam', 'sire', 'pic'])
->where('status', 'proses') ->where('status', 'proses')
->whereNotNull('tanggal_kawin') ->whereNotNull('tgl_kawin_awal')
->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::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::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'flea']),
],
'deworming' => [
'count' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'deworming']),
],
'grooming' => [
'count' => Cat::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)) ->whereIn('weight_histories.id', function ($query) use ($selectedMonth, $selectedYear) {
->groupBy('month', 'month_num') $query->select(DB::raw('MAX(id)'))
->orderBy('month_num') ->from('weight_histories')
->get(); ->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')
->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();
// 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',
'individualWeightData',
'weightSummary',
'weightTrendLabels',
'weightTrendSeries',
'categoryAverageTrend',
'weightCategorySummary',
'weightKpis',
'weightLatestTable',
'weightTableCategory',
'weightTrendCategory',
'weightTrendMonths',
'lowStock', 'lowStock',
'totalLowStock', 'totalLowStock',
'chartData', 'expiringItems',
'weightTrends', 'selectedMonth',
'selectedYear',
'selectedGender',
'selectedStatus',
'topDiseasesWeek',
'topDiseasesMonth',
'recentTreatments' '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';
}
} }
+56 -4
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
{ {
@@ -19,16 +20,67 @@ class FoodController extends Controller
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
// Proteksi Akses
Gate::authorize('check-access', ['foods', 'read']);
$now = now();
$query = Food::with('category'); $query = Food::with('category');
// 1. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
if ($request->filled('filter')) {
$filter = $request->get('filter');
if ($filter === 'expired') {
// Menampilkan makanan yang sudah kadaluwarsa
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
} elseif ($filter === 'expiring_soon') {
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
$query->whereNotNull('expired_at')
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
} elseif ($filter === 'low_stock') {
// Menampilkan stok kritis
$query->where('qty', '<=', 5);
}
}
// 2. SEARCH GLOBAL
if ($request->filled('search')) { if ($request->filled('search')) {
$query->where('nama_makanan', 'like', '%' . $request->search . '%'); $search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_makanan', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%");
});
} }
$foods = $query->latest()->paginate(10); // 3. SORTING DINAMIS (Menambahkan kembali expired_at)
return view('pages.inventory.foods.index', compact('foods')); $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
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'); $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)
}); });
} }
@@ -66,7 +73,7 @@ $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'];
@@ -74,7 +81,6 @@ $mattings = $query->latest()->paginate(15)->withQueryString();
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)
@@ -85,28 +91,28 @@ $mattings = $query->latest()->paginate(15)->withQueryString();
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 (untuk logo) // Penyempurnaan PDF: Tambahkan opsi font dan akses file lokal
$pdf = Pdf::loadView('pages.reports.matting.pdf', [ $pdf = Pdf::loadView('pages.reports.matting.pdf', [
'data' => $data, 'data' => $data,
// Kirim data setting jika ingin menampilkan logo/branding dinamis di PDF 'setting' => Setting::pluck('value', 'key')
'setting' => \App\Models\Setting::pluck('value', 'key')
]); ]);
return $pdf->setPaper('a4', 'landscape') return $pdf->setPaper('a4', 'landscape')
->setOption([ ->setOption([
'tempDir' => public_path(), 'tempDir' => public_path(),
'chroot' => public_path(), 'chroot' => public_path(),
'isHtml5ParserEnabled' => true,
'isRemoteEnabled' => true, // Penting agar gambar dari storage muncul di PDF
]) ])
->setWarnings(false) // Menghindari PDF korup karena error kecil di blade ->setWarnings(false)
->download("{$fileName}.pdf"); ->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'));
}
}
+61 -4
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
{ {
@@ -21,17 +22,73 @@ class MedicineController extends Controller
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
// Proteksi Akses
Gate::authorize('check-access', ['medicines', 'read']);
$now = now();
$query = Medicine::query(); $query = Medicine::query();
// 1. FILTER KHUSUS (DARI DASHBOARD)
if ($request->filled('filter')) {
$filter = $request->get('filter');
if ($filter === 'expired') {
// Menampilkan yang sudah lewat tanggal kadaluwarsa
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
} elseif ($filter === 'expiring_soon') {
// Menampilkan yang akan kadaluwarsa dalam 7 hari kedepan
$query->whereNotNull('expired_at')
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
} elseif ($filter === 'low_stock') {
// Menampilkan stok kritis (<= 5)
$query->where('qty', '<=', 5);
}
}
// 2. SEARCH GLOBAL
if ($request->filled('search')) { if ($request->filled('search')) {
$query->where('nama_obat', 'like', '%' . $request->search . '%'); $search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_obat', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%");
});
} }
$medicines = $query->latest()->paginate(10); // 3. FILTER KATEGORI (Opsional jika ada dropdown di UI)
if ($request->filled('category')) {
return view('pages.inventory.medicines.index', compact('medicines')); $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.
*/ */
+13 -4
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');
@@ -78,6 +76,18 @@ class ReportController extends Controller
{ {
$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')) {
$dateCol = $this->getDateColumn($module); $dateCol = $this->getDateColumn($module);
@@ -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) {
@@ -9,6 +9,7 @@ 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
@@ -28,6 +29,7 @@ class SettingController extends Controller
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
@@ -49,6 +51,7 @@ class SettingController extends Controller
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',
+87 -12
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
{ {
@@ -19,14 +21,70 @@ class SupplyController extends Controller
*/ */
public function index(Request $request): View public function index(Request $request): View
{ {
// 1. Proteksi Akses (Sesuai Matriks Role Management)
Gate::authorize('check-access', ['supplies', 'read']);
$now = now();
$query = Supply::with('category'); $query = Supply::with('category');
if ($request->filled('search')) { // 2. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
$query->where('nama_perlengkapan', 'like', '%' . $request->search . '%'); 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); // 3. SEARCH GLOBAL (Mencari di Nama, Kategori, dan Unit)
return view('pages.inventory.supplies.index', compact('supplies')); if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_perlengkapan', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%")
->orWhereHas('category', function($sub) use ($search) {
$sub->where('name', 'like', "%{$search}%");
});
});
}
// 4. LOGIKA SORTING DINAMIS (Menambahkan expired_at dalam daftar sort)
$allowedSorts = ['nama_perlengkapan', 'qty', 'kategori', 'expired_at', 'created_at'];
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_perlengkapan';
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
// Eksekusi Query dengan Pagination
$supplies = $query->orderBy($sort, $direction)
->paginate(15)
->withQueryString();
// 5. STATISTIK RINGKAS (Komprehensif)
$totalStock = Supply::sum('qty');
$lowStockCount = Supply::where('qty', '<=', 5)->count();
// Hitung item yang expired (Wajib ada kolom expired_at di database)
$expiredCount = Supply::whereNotNull('expired_at')
->where('expired_at', '<', $now)
->count();
$totalItems = Supply::count();
return view('pages.inventory.supplies.index', compact(
'supplies',
'totalStock',
'lowStockCount',
'expiredCount', // Variabel statistik baru
'totalItems'
));
} }
/** /**
@@ -105,22 +163,39 @@ class SupplyController extends Controller
*/ */
public function destroy(Supply $supply): RedirectResponse 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.');
}
} }
} }
+220 -72
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,19 +19,55 @@ 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',
'details.medicine:id,nama_obat,unit',
'user:id,name' 'user:id,name'
]) ])
->latest('tanggal') ->whereHas('cat', function($q) {
->paginate(15); $q->where('treatment_status', 1);
});
return view('pages.treatments.index', compact('treatments')); // 2. LOGIKA FILTERING DINAMIS
if ($request->filled('search')) {
$query->whereHas('cat', function($q) use ($request) {
$q->where('nama_kucing', 'like', '%' . $request->search . '%');
});
}
if ($request->filled('pic_id')) {
$query->where('user_id', $request->pic_id);
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('tanggal', [$request->start_date, $request->end_date]);
}
// 3. LOGIKA EXPORT (Pastikan blok IF ditutup dengan benar)
if ($request->filled('export')) {
$exportData = new \App\Exports\TreatmentExport($request);
$fileName = 'Report_Active_Treatment_' . now()->format('Ymd_His');
if ($request->export === 'excel') {
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.xlsx");
}
if ($request->export === 'pdf') {
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.pdf", \Maatwebsite\Excel\Excel::DOMPDF);
}
} // <--- Pastikan kurung ini menutup blok export, bukan menutup fungsi index
// 4. EKSEKUSI DATA UNTUK VIEW (Line 67)
$treatments = $query->latest('tanggal')->paginate(20)->withQueryString();
$pics = \App\Models\User::select('id', 'name')->orderBy('name')->get();
return view('pages.treatments.index', compact('treatments', 'pics'));
} }
/** /**
@@ -38,147 +75,258 @@ class TreatmentController extends Controller
*/ */
public function create(Request $request): View public function create(Request $request): View
{ {
$selectedCat = $request->filled('cat_id') ? Cat::findOrFail($request->cat_id) : null; $selectedCat = null;
if ($request->filled('cat_id')) {
$selectedCat = Cat::select('id', 'nama_kucing', 'ras')->findOrFail($request->cat_id);
$cats = collect([$selectedCat]);
} else {
$cats = Cat::select('id', 'nama_kucing', 'ras')->get(); $cats = Cat::select('id', 'nama_kucing', 'ras')->get();
$medicines = Medicine::where('qty', '>', 0)->get(); }
// Menampilkan semua obat (termasuk stok 0 untuk kebutuhan edit/view,
// namun di filter di frontend untuk input baru)
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
return view('pages.treatments.create', compact('selectedCat', 'cats', 'medicines')); 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);
try { $fotoPath = null;
DB::transaction(function () use ($request, &$validated) {
$medicine = Medicine::findOrFail($request->medicine_id);
// Validasi kecukupan stok desimal
if ($medicine->qty < $request->qty_used) {
throw new Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty} {$medicine->unit}).");
}
if ($request->hasFile('foto_bukti')) { if ($request->hasFile('foto_bukti')) {
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public'); $fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
} }
// Otomatisasi data sistem try {
$validated['user_id'] = auth()->id(); return \Illuminate\Support\Facades\DB::transaction(function () use ($request, $fotoPath) {
$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) return redirect()->route('cats.show', $request->cat_id)
->with('success', 'Rekam medis berhasil dicatat dengan presisi.'); ->with('success', 'Rekam medis klinis berhasil dicatat dan stok dipotong otomatis.');
});
} catch (Exception $e) { } catch (\Exception $e) {
if (isset($validated['foto_bukti'])) { if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
Storage::disk('public')->delete($validated['foto_bukti']); \Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
} }
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());
} }
} }
/**
* Helper Validasi: Terpusat untuk Clean Code & Mendukung Desimal.
*/
private function validateTreatment(Request $request) 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.',
]); ]);
} }
} }
+5 -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);
@@ -194,7 +196,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->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);
}
}
+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); }
}
+101 -39
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,38 @@ 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'
]; ];
/**
* 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,7 +66,7 @@ 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',
]; ];
@@ -48,9 +76,9 @@ 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 +117,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 +166,79 @@ 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);
}
} }
+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); }
}
+45 -12
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,15 +70,13 @@ 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();
@@ -66,10 +85,24 @@ class Food extends Model
$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>';
} }
); );
} }
+5 -7
View File
@@ -25,7 +25,7 @@ class Matting extends Model
} }
/** /**
* 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 +34,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 +44,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 +72,7 @@ class Matting extends Model
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* ACCESSORS (UI & Breeding Intelligence) */ /* ACCESSORS (UI & BREEDING) */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/** /**
@@ -114,7 +112,7 @@ 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">Dipantau</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>',
@@ -131,7 +129,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');
}
}
+61 -18
View File
@@ -4,23 +4,40 @@ 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
*/ */
@@ -29,34 +46,36 @@ class Medicine extends Model
static::saving(function ($medicine) { static::saving(function ($medicine) {
$name = strtolower($medicine->nama_obat); $name = strtolower($medicine->nama_obat);
// Ambil semua kategori dari database // 1. OTOMATISASI KATEGORI BERDASARKAN KEYWORDS
$categories = \App\Models\Category::all(); $categories = \App\Models\Category::all();
$matched = false;
foreach ($categories as $cat) { foreach ($categories as $cat) {
// Pecah kata kunci berdasarkan koma
$keywords = explode(',', strtolower($cat->keywords)); $keywords = explode(',', strtolower($cat->keywords));
foreach ($keywords as $keyword) { foreach ($keywords as $keyword) {
if (str_contains($name, trim($keyword))) { if (str_contains($name, trim($keyword))) {
$medicine->category_id = $cat->id; $medicine->category_id = $cat->id;
$medicine->kategori = $cat->name; // Sinkronisasi teks kategori $medicine->kategori = $cat->name;
return; $matched = true;
break 2;
} }
} }
} }
$medicine->kategori = 'Umum'; if (!$matched) {
$medicine->kategori = $medicine->kategori ?: 'Umum';
}
// 2. SINKRONISASI TANGGAL EXPIRED (Double Guard)
// Jika expired_at kosong tapi expired_date ada, samakan. Begitu juga sebaliknya.
if ($medicine->expired_date && !$medicine->expired_at) {
$medicine->expired_at = $medicine->expired_date;
} elseif ($medicine->expired_at && !$medicine->expired_date) {
$medicine->expired_date = $medicine->expired_at;
}
}); });
} }
/**
* Casting tipe data untuk presisi medis.
*/
protected $casts = [
'expired_date' => 'date', // Memperbaiki error isPast() di Blade
'qty' => 'float', // Mendukung dosis desimal seperti 2.5 ml
];
/** /**
* Accessor: Mempermudah pemanggilan foto produk dengan fallback image. * 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);
}
} }
+62 -19
View File
@@ -9,48 +9,75 @@ 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();
@@ -59,9 +86,25 @@ class Supply extends Model
$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;
}
} }
+59 -16
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,13 +68,30 @@ class Treatment extends Model
} }
/** /**
* Relasi ke Inventori Obat. * PENTING: Relasi ke Medicine tunggal (Legacy Support).
* Ini yang memperbaiki error 'RelationNotFoundException' pada data lama.
*/ */
public function medicine(): BelongsTo 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();
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -84,7 +100,6 @@ class Treatment extends Model
/** /**
* Mengambil URL foto bukti. * Mengambil URL foto bukti.
* Cara akses di Blade: {{ $treatment->foto_url }}
*/ */
public function getFotoUrlAttribute(): string public function getFotoUrlAttribute(): string
{ {
@@ -95,6 +110,24 @@ 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);
}
}
+15 -9
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.
* *
@@ -55,23 +59,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) // $permission berisi 'can_read', 'can_update', dll ->where($permission, 1)
->exists(); ->exists() ?? false;
} }
} }
+23 -1
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);
}
} }
+21 -12
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);
@@ -53,26 +58,30 @@ class AppServiceProvider extends ServiceProvider
// 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;
}
}); });
} }
+12 -8
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();
} }
} }
+17 -6
View File
@@ -4,27 +4,38 @@ 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;
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,
]); ]);
// 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
+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');
}
};
-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"
}, },
+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>
+26 -97
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 class="p-6 sm:p-10 lg:p-12">
<div class="mb-8">
<h2 class="text-2xl font-bold text-slate-900">Masuk ke ILYCats</h2>
<p class="mt-2 text-sm text-slate-500">Gunakan email dan kata sandi akun Anda.</p>
</div> </div>
{{-- SISI KANAN: FORM LOGIN (Dibuat Lebih Center & Proporsional) --}}
<div class="flex-1 flex items-center justify-center p-6 sm:p-12 lg:p-16 bg-slate-50/50">
{{-- max-w-md: Lebar standar form agar tidak terlihat meluber --}}
<div class="w-full max-w-md space-y-8">
<div class="space-y-2 text-center lg:text-left">
{{-- Logo Mobile: Center di layar kecil --}}
<div class="lg:hidden flex justify-center">
@if(isset($setting) && isset($setting->app_logo))
<img src="{{ asset('storage/' . $setting->app_logo) }}" class="h-14 w-auto mb-6">
@endif
</div>
<h3 class="text-2xl lg:text-3xl font-black text-slate-900 tracking-tight">Selamat Datang.</h3>
<p class="text-slate-500 font-medium text-sm lg:text-base">Silakan masuk untuk mengelola cattery Anda.</p>
</div>
<x-auth-session-status class="mb-4" :status="session('status')" /> <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>
<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> </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)
? '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 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-medium text-slate-600 hover:bg-slate-50 hover:text-indigo-600 rounded-xl transition-all'; : 'group flex items-center rounded-xl px-2.5 py-2 text-sm font-medium text-slate-600 transition-all hover:bg-slate-50 hover:text-slate-900';
$iconClasses = ($active ?? false) $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>
+17 -153
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
// Optimasi: Gunakan try-catch agar jika tabel settings belum dimigrasi, app tidak crash $appName = \App\Models\Setting::where('key','app_name')->value('value') ?? config('app.name','ILY Cattery');
try { $favicon = \App\Models\Setting::where('key','app_favicon')->value('value');
$settings = \App\Models\Setting::whereIn('key', ['app_name', 'app_slogan', 'app_favicon', 'app_version'])
->pluck('value', 'key');
} catch (\Exception $e) {
$settings = collect();
}
$appName = $settings['app_name'] ?? config('app.name', 'Ily Cattery');
$appSlogan = $settings['app_slogan'] ?? 'Dedikasi untuk Kesehatan dan Kebahagiaan Anabul';
$appVersion = $settings['app_version'] ?? 'v1.0.0';
$favicon = $settings['app_favicon'] ?? null;
@endphp @endphp
<link rel="icon" type="image/x-icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}">
<title>{{ $appName }}</title> <title>{{ $appName }}</title>
<link rel="icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}">
{{-- Fonts & Icons --}}
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,800,900&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=inter:400,500,600,700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
{{-- Scripts & Styles --}}
@vite(['resources/css/app.css','resources/js/app.js']) @vite(['resources/css/app.css','resources/js/app.js'])
@stack('styles') @stack('styles')
<style>[x-cloak]{display:none!important}</style>
<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> </head>
<body class="font-sans antialiased text-slate-900 bg-slate-50 h-full overflow-hidden"> <body class="h-full bg-slate-50 font-sans text-slate-800 antialiased" x-data="{sidebarOpen: window.innerWidth >= 1024}" @resize.window="if(window.innerWidth>=1024) sidebarOpen=true">
<div class="flex min-h-screen">
<div class="flex h-screen overflow-hidden">
{{-- Sidebar Navigation --}}
@include('layouts.navigation-sidebar') @include('layouts.navigation-sidebar')
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex-1 flex flex-col min-w-0 bg-slate-50 relative">
{{-- Top Navigation --}}
@include('layouts.navigation') @include('layouts.navigation')
<main class="flex-1 overflow-y-auto">
{{-- Main Content Area --}} <div class="mx-auto w-full max-w-[1500px] p-4 md:p-6 lg:p-8">
<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()) @if($errors->any())
<div x-data="{ show: true }" x-show="show" x-cloak <div class="mb-5 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>
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 @endif
@if(session('success'))<div class="mb-5 border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700">{{ session('success') }}</div>@endif
{{-- Main Slot (Halaman Konten Anda) --}} @if(session('error'))<div class="mb-5 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 border border-amber-200 bg-amber-50 p-4 text-sm text-amber-700">{{ session('warning') }}</div>@endif
{{ $slot }} {{ $slot }}
</div> </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> </main>
</div> </div>
</div> </div>
{{-- SWEETALERT 2 INTEGRATION --}}
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script> <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { window.confirmDelete=function(target,itemName='data ini'){
Swal.fire({title:'Hapus data?',text:`${itemName} akan dihapus.`,icon:'warning',showCancelButton:true,confirmButtonText:'Hapus',cancelButtonText:'Batal',confirmButtonColor:'#e11d48'}).then(r=>{if(r.isConfirmed){if(typeof target==='string')document.getElementById(target)?.submit();else target.closest('form')?.submit();}})
// 1. KONFIGURASI TOAST
const IlyToast = Swal.mixin({
toast: true,
position: 'top-end',
showConfirmButton: false,
timer: 3500,
timerProgressBar: true,
didOpen: (toast) => {
toast.addEventListener('mouseenter', Swal.stopTimer)
toast.addEventListener('mouseleave', Swal.resumeTimer)
},
customClass: {
popup: 'rounded-2xl shadow-2xl border border-slate-50 p-4',
title: 'text-[11px] font-black uppercase tracking-widest ml-2'
} }
});
// 2. FUNGSI GLOBAL TOAST (Bisa dipanggil di file JS manapun)
window.showToast = function(message, icon = 'success') {
IlyToast.fire({
icon: icon,
title: message,
iconColor: icon === 'success' ? '#4f46e5' : '#e11d48',
});
}
// 3. AUTO-TRIGGER FLASH MESSAGE
@if(session('success'))
showToast("{{ session('success') }}", 'success');
@endif
@if(session('error'))
showToast("{{ session('error') }}", 'error');
@endif
// 4. FUNGSI GLOBAL KONFIRMASI DELETE (Universal)
// Mendukung pemanggilan via ID Form (confirmDelete('formId', 'Nama'))
// Atau pemanggilan via Element (confirmDelete(this, 'Nama'))
window.confirmDelete = function(target, itemName = 'Data ini') {
const finalName = itemName || 'Data terpilih';
Swal.fire({
title: 'HAPUS DATA?',
html: "<div class='text-sm text-slate-500 font-medium'>Anda akan menghapus:<br><b class='text-slate-800 uppercase tracking-tighter'>" + finalName + "</b><br><br><span class='text-rose-500 font-bold italic'>Peringatan: Data yang dihapus tidak dapat dipulihkan!</span></div>",
icon: 'warning',
iconColor: '#e11d48',
showCancelButton: true,
confirmButtonColor: '#e11d48',
cancelButtonColor: '#f1f5f9',
confirmButtonText: 'YA, HAPUS PERMANEN',
cancelButtonText: 'BATAL',
reverseButtons: true,
customClass: {
popup: 'rounded-[2.5rem] p-10 border-none shadow-2xl',
confirmButton: 'rounded-2xl font-black uppercase text-[10px] px-8 py-4 ml-3 tracking-widest shadow-lg shadow-rose-100',
cancelButton: 'rounded-2xl font-black uppercase text-[10px] px-8 py-4 text-slate-500 tracking-widest'
}
}).then((result) => {
if (result.isConfirmed) {
// Logic: Jika target adalah string, cari by ID. Jika object, cari closest form.
if (typeof target === 'string') {
const form = document.getElementById(target);
if(form) form.submit();
} else {
const form = target.closest('form');
if(form) form.submit();
}
}
});
}
});
</script> </script>
@stack('scripts') @stack('scripts')
</body> </body>
</html> </html>
@@ -1,165 +1,97 @@
<aside class="w-72 bg-white border-r border-slate-200 hidden lg:flex flex-col sticky top-0 h-screen transition-all shadow-sm">
@php @php
$appSettings = \Illuminate\Support\Facades\Cache::remember('app_sidebar_settings', 3600, function() { $appName = \App\Models\Setting::where('key', 'app_name')->value('value') ?? 'ILY Cattery';
return \App\Models\Setting::whereIn('key', ['app_logo', 'app_name'])->pluck('value', 'key'); $appLogo = \App\Models\Setting::where('key', 'app_logo')->value('value');
}); $lowObatsCount = \App\Models\Medicine::where('qty', '<', 5)->count();
$appName = $appSettings['app_name'] ?? 'Ily Cattery'; $lowMakananCount = \App\Models\Food::where('qty', '<', 5)->count();
// Hitung stok kritis untuk badge (TETAP UTUH)
$lowMedicinesCount = \App\Models\Medicine::where('qty', '<', 5)->count();
$lowFoodsCount = \App\Models\Food::where('qty', '<', 5)->count();
@endphp @endphp
{{-- Brand Header --}} <div x-show="sidebarOpen" x-cloak @click="sidebarOpen=false" class="fixed inset-0 z-30 bg-slate-900/30 lg:hidden"></div>
<div class="p-8 flex items-center gap-4"> <aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'"
<div class="w-12 h-12 bg-indigo-600 rounded-[1.25rem] flex items-center justify-center shadow-xl shadow-indigo-100"> class="fixed inset-y-0 left-0 z-40 flex w-64 shrink-0 flex-col border-r border-slate-200 bg-white transition-transform duration-200 lg:static lg:z-auto">
<i class="fas fa-cat text-white text-xl"></i> <div class="flex h-16 items-center gap-3 border-b border-slate-200 px-5">
</div> @if($appLogo)
<img src="{{ Storage::url($appLogo) }}" alt="Logo {{ $appName }}" class="h-9 w-9 rounded-lg object-contain">
@else
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-600 text-white"><i class="fas fa-cat"></i></div>
@endif
<div class="min-w-0"> <div class="min-w-0">
<h1 class="text-sm font-black text-slate-800 uppercase tracking-tighter truncate leading-none mb-1">{{ $appName }}</h1> <div class="truncate text-sm font-bold text-slate-900">{{ $appName }}</div>
<p class="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] leading-none">Management System</p> <div class="text-xs text-slate-500">Manajemen cattery</div>
</div> </div>
<button @click="sidebarOpen=false" class="ml-auto p-2 text-slate-400 lg:hidden" aria-label="Tutup menu"><i class="fas fa-times"></i></button>
</div> </div>
{{-- Navigation Links --}} <nav class="flex-1 overflow-y-auto px-3 py-4">
<nav class="flex-1 px-4 space-y-1 overflow-y-auto custom-scrollbar"> <div class="space-y-1">
<x-sidebar-link :href="route('dashboard')" :active="request()->routeIs('dashboard') && !request()->boolean('weight_focus')" icon="fa-house" tone="indigo">Beranda</x-sidebar-link>
{{-- Dashboard biasanya dapat diakses oleh semua user terautentikasi --}} <x-sidebar-link :href="route('dashboard', ['weight_focus' => 1]) . '#pemantauan-berat'" :active="request()->routeIs('dashboard') && request()->boolean('weight_focus')" icon="fa-weight-scale" tone="blue">Berat badan</x-sidebar-link>
<x-sidebar-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" icon="fa-th-large">
Dashboard
</x-sidebar-link>
{{-- OPERASIONAL --}}
@if(Gate::allows('check-access', ['cats', 'read']) || Gate::allows('check-access', ['matting', 'read']))
<div class="pt-6 pb-2 px-5 text-[9px] font-black text-slate-400 uppercase tracking-[0.3em] flex items-center gap-3">
<span>Operasional</span>
<div class="h-px bg-slate-100 flex-1"></div>
</div> </div>
@if(Gate::allows('check-access', ['cats','read']) || Gate::allows('check-access', ['matting','read']) || Gate::allows('check-access', ['treatments','read']))
<div class="mt-6">
<p class="px-3 pb-2 text-xs font-semibold text-slate-400">Operasional</p>
<div class="space-y-1">
@can('check-access', ['cats','read']) @can('check-access', ['cats','read'])
<x-sidebar-link :href="route('cats.index')" :active="request()->routeIs('cats.*')" icon="fa-paw"> <x-sidebar-link :href="route('cats.index')" :active="request()->routeIs('cats.*')" icon="fa-cat" tone="sky">Data kucing</x-sidebar-link>
Data Kucing
</x-sidebar-link>
@endcan @endcan
@can('check-access', ['matting','read']) @can('check-access', ['matting','read'])
<x-sidebar-link :href="route('matting.index')" :active="request()->routeIs('matting.*')" icon="fa-venus-mars"> <x-sidebar-link :href="route('matting.index')" :active="request()->routeIs('matting.*')" icon="fa-paw" tone="rose">Perkawinan & pembiakan</x-sidebar-link>
<span class="flex-1">Matting Cat</span>
<span class="text-[8px] bg-emerald-100 text-emerald-600 px-1.5 py-0.5 rounded font-black uppercase italic">Breeding</span>
</x-sidebar-link>
@endcan @endcan
@endif
{{-- KESEHATAN --}}
@if(Gate::allows('check-access', ['medical_insights', 'read']) || Gate::allows('check-access', ['treatments', 'read']))
<div class="pt-6 pb-2 px-5 text-[9px] font-black text-slate-400 uppercase tracking-[0.3em] flex items-center gap-3">
<span>Kesehatan</span>
<div class="h-px bg-slate-100 flex-1"></div>
</div>
@can('check-access', ['medical_insights', 'read'])
<x-sidebar-link :href="route('medical.dashboard')" :active="request()->routeIs('medical.dashboard')" icon="fa-chart-pie">
Medical Insight
</x-sidebar-link>
@endcan
@can('check-access', ['treatments','read']) @can('check-access', ['treatments','read'])
<x-sidebar-link :href="route('treatments.index')" :active="request()->routeIs('treatments.*')" icon="fa-notes-medical"> <x-sidebar-link :href="route('treatments.index')" :active="request()->routeIs('treatments.*')" icon="fa-notes-medical" tone="emerald">Rekam medis</x-sidebar-link>
Rekam Medis
</x-sidebar-link>
@endcan @endcan
@can('check-access', ['medical_insights','read'])
<x-sidebar-link :href="route('medical.dashboard')" :active="request()->routeIs('medical.dashboard')" icon="fa-heart-pulse" tone="rose">Ringkasan kesehatan</x-sidebar-link>
@endcan
</div>
</div>
@endif @endif
{{-- LOGISTIK --}}
@if(Gate::allows('check-access', ['medicines','read']) || Gate::allows('check-access', ['foods','read']) || Gate::allows('check-access', ['supplies','read']) || Gate::allows('check-access', ['categories','read'])) @if(Gate::allows('check-access', ['medicines','read']) || Gate::allows('check-access', ['foods','read']) || Gate::allows('check-access', ['supplies','read']) || Gate::allows('check-access', ['categories','read']))
<div class="pt-6 pb-2 px-5 text-[9px] font-black text-slate-400 uppercase tracking-[0.3em] flex items-center gap-3"> <div class="mt-6">
<span>Logistik</span> <div class="flex items-center justify-between px-3 pb-2">
<div class="h-px bg-slate-100 flex-1"></div> <p class="text-xs font-semibold text-slate-400">Inventori</p>
@if(($lowObatsCount+$lowMakananCount)>0)<span class="rounded bg-rose-50 px-1.5 py-0.5 text-xs font-semibold text-rose-600">{{ $lowObatsCount+$lowMakananCount }}</span>@endif
</div>
<div class="space-y-1">
@can('check-access', ['medicines','read'])<x-sidebar-link :href="route('inventory.medicines.index')" :active="request()->routeIs('inventory.medicines.*')" icon="fa-pills" tone="violet">Obat & vitamin</x-sidebar-link>@endcan
@can('check-access', ['foods','read'])<x-sidebar-link :href="route('inventory.foods.index')" :active="request()->routeIs('inventory.foods.*')" icon="fa-bowl-food" tone="amber">Makanan</x-sidebar-link>@endcan
@can('check-access', ['supplies','read'])<x-sidebar-link :href="route('inventory.supplies.index')" :active="request()->routeIs('inventory.supplies.*')" icon="fa-box-open" tone="cyan">Perlengkapan</x-sidebar-link>@endcan
@can('check-access', ['categories','read'])<x-sidebar-link :href="route('categories.index')" :active="request()->routeIs('categories.*')" icon="fa-tags" tone="slate">Kategori</x-sidebar-link>@endcan
</div>
</div> </div>
@can('check-access', ['medicines', 'read'])
<x-sidebar-link :href="route('inventory.medicines.index')" :active="request()->routeIs('inventory.medicines.*')" icon="fa-pills">
<span class="flex-1">Obat & Vitamin</span>
@if($lowMedicinesCount > 0)
<span class="bg-rose-500 text-white text-[9px] font-black px-2 py-0.5 rounded-lg animate-pulse">{{ $lowMedicinesCount }}</span>
@endif
</x-sidebar-link>
@endcan
@can('check-access', ['foods', 'read'])
<x-sidebar-link :href="route('inventory.foods.index')" :active="request()->routeIs('inventory.foods.*')" icon="fa-bowl-food">
<span class="flex-1">Stok Makanan</span>
@if($lowFoodsCount > 0)
<span class="bg-rose-500 text-white text-[9px] font-black px-2 py-0.5 rounded-lg">{{ $lowFoodsCount }}</span>
@endif
</x-sidebar-link>
@endcan
@can('check-access', ['supplies', 'read'])
<x-sidebar-link :href="route('inventory.supplies.index')" :active="request()->routeIs('inventory.supplies.*')" icon="fa-box-archive">
Perlengkapan
</x-sidebar-link>
@endcan
@can('check-access', ['categories', 'read'])
<x-sidebar-link :href="route('categories.index')" :active="request()->routeIs('categories.*')" icon="fa-tags">
Master Kategori
</x-sidebar-link>
@endcan
@endif @endif
{{-- INTELLIGENCE --}}
@if(Gate::allows('check-access', ['reports','read']) || Gate::allows('check-access', ['matting_reports','read'])) @if(Gate::allows('check-access', ['reports','read']) || Gate::allows('check-access', ['matting_reports','read']))
<div class="pt-6 pb-2 px-5 text-[9px] font-black text-indigo-500 uppercase tracking-[0.3em] flex items-center gap-3"> <div class="mt-6">
<span>Intelligence</span> <p class="px-3 pb-2 text-xs font-semibold text-slate-400">Laporan</p>
<div class="h-px bg-indigo-50 flex-1"></div> <div class="space-y-1">
@can('check-access', ['reports','read'])<x-sidebar-link :href="route('admin.reports.index')" :active="request()->routeIs('admin.reports.index')" icon="fa-chart-line" tone="blue">Pusat laporan</x-sidebar-link>@endcan
@can('check-access', ['matting_reports','read'])<x-sidebar-link :href="route('admin.reports.matting.index')" :active="request()->routeIs('admin.reports.matting.*')" icon="fa-file-lines" tone="rose">Laporan pembiakan</x-sidebar-link>@endcan
</div>
</div> </div>
@can('check-access', ['reports', 'read'])
<x-sidebar-link :href="route('admin.reports.index')" :active="request()->routeIs('admin.reports.index')" icon="fa-chart-line">
Pusat Laporan
</x-sidebar-link>
@endcan
@can('check-access', ['matting_reports', 'read'])
<x-sidebar-link :href="route('admin.reports.matting.index')" :active="request()->routeIs('admin.reports.matting')" icon="fa-file-medical">
<span class="flex-1">Laporan Breeding</span>
<span class="text-[8px] bg-rose-100 text-rose-600 px-1.5 py-0.5 rounded font-black uppercase shadow-sm">Special</span>
</x-sidebar-link>
@endcan
@endif @endif
{{-- KONFIGURASI (Hanya tampil untuk yang memiliki akses ke modul-modul ini) --}}
@if(Gate::allows('check-access', ['users','read']) || Gate::allows('check-access', ['roles','read']) || Gate::allows('check-access', ['logs','read']) || Gate::allows('check-access', ['settings','read'])) @if(Gate::allows('check-access', ['users','read']) || Gate::allows('check-access', ['roles','read']) || Gate::allows('check-access', ['logs','read']) || Gate::allows('check-access', ['settings','read']))
<div class="pt-8 pb-2 px-5 text-[9px] font-black text-rose-500 uppercase tracking-[0.3em] flex items-center gap-3"> <div class="mt-6">
<span>Konfigurasi</span> <p class="px-3 pb-2 text-xs font-semibold text-slate-400">Administrasi</p>
<div class="h-px bg-rose-50 flex-1"></div> <div class="space-y-1">
@can('check-access', ['users','read'])<x-sidebar-link :href="route('admin.users.index')" :active="request()->routeIs('admin.users.*')" icon="fa-user-group" tone="sky">Pengguna</x-sidebar-link>@endcan
@can('check-access', ['roles','read'])<x-sidebar-link :href="route('admin.roles.index')" :active="request()->routeIs('admin.roles.*')" icon="fa-shield-halved" tone="violet">Hak akses</x-sidebar-link>@endcan
@can('check-access', ['logs','read'])<x-sidebar-link :href="route('admin.logs.index')" :active="request()->routeIs('admin.logs.*')" icon="fa-clock-rotate-left" tone="amber">Jejak aktivitas</x-sidebar-link>@endcan
@can('check-access', ['settings','read'])<x-sidebar-link :href="route('admin.settings.index')" :active="request()->routeIs('admin.settings.*')" icon="fa-gear" tone="slate">Pengaturan</x-sidebar-link>@endcan
</div>
</div> </div>
@can('check-access', ['users', 'read'])
<x-sidebar-link :href="route('admin.users.index')" :active="request()->routeIs('admin.users.*')" icon="fa-users-cog">
Manajemen User
</x-sidebar-link>
@endcan
@can('check-access', ['roles', 'read'])
<x-sidebar-link :href="route('admin.roles.index')" :active="request()->routeIs('admin.roles.*')" icon="fa-user-shield">
Role Management
</x-sidebar-link>
@endcan
@can('check-access', ['logs', 'read'])
<x-sidebar-link :href="route('admin.logs.index')" :active="request()->routeIs('admin.logs.*')" icon="fa-history">
Log Audit
</x-sidebar-link>
@endcan
@can('check-access', ['settings', 'read'])
<x-sidebar-link :href="route('admin.settings.index')" :active="request()->routeIs('admin.settings.*')" icon="fa-sliders-h">
Pengaturan Sistem
</x-sidebar-link>
@endcan
@endif @endif
</nav> </nav>
<div class="border-t border-slate-200 p-4">
<div class="mb-3 flex items-center gap-3 px-1">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 text-xs font-bold text-slate-700">{{ strtoupper(substr(auth()->user()->name,0,1)) }}</div>
<div class="min-w-0"><p class="truncate text-sm font-semibold text-slate-800">{{ auth()->user()->name }}</p><p class="truncate text-xs text-slate-500">{{ auth()->user()->roleModel?->name ?? auth()->user()->getRawOriginal('role') ?? 'Pengguna' }}</p></div>
</div>
<form method="POST" action="{{ route('logout') }}">@csrf
<button class="flex w-full items-center gap-3 px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-rose-600"><i class="fas fa-right-from-bracket w-5"></i>Keluar</button>
</form>
</div>
</aside> </aside>
+9 -125
View File
@@ -1,128 +1,12 @@
<nav x-data="{ open: false }" class="bg-white/80 backdrop-blur-md border-b border-slate-100 sticky top-0 z-50 shadow-sm"> <header class="sticky top-0 z-20 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 md:px-6">
@php <div class="flex items-center gap-3">
// Mengambil data branding dari database <button @click="sidebarOpen=true" class="p-2 text-slate-600 lg:hidden" aria-label="Buka menu"><i class="fas fa-bars"></i></button>
$appLogo = \App\Models\Setting::where('key', 'app_logo')->value('value'); <div>
$appName = \App\Models\Setting::where('key', 'app_name')->value('value') ?? 'ILY Aregau'; <p class="text-sm font-semibold text-slate-900">{{ \App\Models\Setting::where('key','app_name')->value('value') ?? 'ILY Cattery' }}</p>
@endphp <p class="hidden text-xs text-slate-500 sm:block">Data cattery, pembiakan, kesehatan, dan persediaan</p>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-20"> <div class="flex">
<div class="shrink-0 flex items-center">
<a href="{{ route('dashboard') }}" class="flex items-center gap-4 group">
<div class="flex items-center justify-center transition-transform duration-300 group-hover:scale-105">
@if($appLogo)
<img src="{{ Storage::url($appLogo) }}"
alt="Logo {{ $appName }}"
class="h-16 w-auto max-w-[200px] object-contain object-left">
@else
<div class="w-12 h-12 bg-indigo-600 rounded-xl flex items-center justify-center text-white font-black text-sm shadow-lg">
{{ strtoupper(substr($appName, 0, 2)) }}
</div>
@endif
</div>
<div class="hidden md:flex flex-col border-l-2 border-slate-100 pl-4">
<span class="text-lg font-black text-slate-800 uppercase tracking-tighter leading-none">{{ $appName }}</span>
<span class="text-[10px] font-bold text-indigo-600 uppercase tracking-[0.2em] mt-1 italic">Cattery Management</span>
</div>
</a>
</div>
<div class="hidden space-x-2 sm:-my-px sm:ms-10 sm:flex items-center">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" class="text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-xl transition-all duration-200">
<i class="fas fa-th-large mr-2 opacity-50 group-hover:opacity-100"></i> {{ __('Dashboard') }}
</x-nav-link>
@if(Auth::user()->role === 'admin')
<x-nav-link :href="route('admin.settings.index')" :active="request()->routeIs('admin.settings.*')" class="text-[10px] font-black uppercase tracking-widest px-4 py-2 rounded-xl text-rose-500 hover:text-rose-600 transition-all duration-200">
<i class="fas fa-cog mr-2 opacity-50"></i> {{ __('Settings') }}
</x-nav-link>
@endif
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<div class="hidden sm:flex sm:items-center sm:ms-6"> <a href="{{ route('profile.edit') }}" class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-slate-900"><i class="fas fa-user-circle text-slate-400"></i><span class="hidden sm:inline">Profil</span></a>
<x-dropdown align="right" width="56">
<x-slot name="trigger">
<button class="inline-flex items-center gap-3 px-4 py-2 border-2 border-slate-50 text-xs leading-4 font-bold rounded-2xl text-slate-500 bg-slate-50/50 hover:bg-white hover:border-indigo-100 hover:text-indigo-600 focus:outline-none transition-all duration-200 shadow-sm active:scale-95">
<div class="w-7 h-7 bg-indigo-100 text-indigo-600 rounded-lg flex items-center justify-center font-black ring-2 ring-white">
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
</div> </div>
<div class="uppercase tracking-tighter">{{ Auth::user()->name }}</div> </header>
<i class="fas fa-chevron-down text-[10px] ml-1 opacity-30"></i>
</button>
</x-slot>
<x-slot name="content">
<div class="px-4 py-3 border-b border-slate-50">
<p class="text-[8px] font-black text-slate-400 uppercase tracking-widest">Signed in as</p>
<p class="text-xs font-bold text-slate-800 truncate">{{ Auth::user()->email }}</p>
</div>
<x-dropdown-link :href="route('profile.edit')" class="text-xs font-bold py-3 hover:bg-indigo-50 transition-colors">
<i class="fas fa-user-circle mr-2 opacity-40"></i> {{ __('My Profile') }}
</x-dropdown-link>
<div class="h-px bg-slate-50 my-1"></div>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-dropdown-link :href="route('logout')" class="text-xs font-bold py-3 text-rose-500 hover:bg-rose-50 transition-colors" onclick="event.preventDefault(); this.closest('form').submit();">
<i class="fas fa-sign-out-alt mr-2 opacity-40"></i> {{ __('Log Out') }}
</x-dropdown-link>
</form>
</x-slot>
</x-dropdown>
</div>
<div class="-me-2 flex items-center sm:hidden">
<button @click="open = ! open" class="inline-flex items-center justify-center p-2.5 rounded-xl text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all duration-200 active:scale-90">
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path :class="{'hidden': open, 'inline-flex': ! open }" class="inline-flex" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
<path :class="{'hidden': ! open, 'inline-flex': open }" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
class="hidden sm:hidden bg-white border-t border-slate-100">
<div class="pt-2 pb-3 space-y-1 px-4">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" class="rounded-xl font-black text-[10px] uppercase tracking-widest py-3">
<i class="fas fa-th-large mr-3"></i> {{ __('Dashboard') }}
</x-responsive-nav-link>
@if(Auth::user()->role === 'admin')
<x-responsive-nav-link :href="route('admin.settings.index')" :active="request()->routeIs('admin.settings.*')" class="rounded-xl font-black text-[10px] uppercase tracking-widest text-rose-500 py-3">
<i class="fas fa-cog mr-3"></i> {{ __('System Settings') }}
</x-responsive-nav-link>
@endif
</div>
<div class="pt-4 pb-4 border-t border-slate-100 bg-slate-50/50">
<div class="flex items-center px-6 gap-4">
<div class="w-12 h-12 bg-indigo-600 text-white rounded-2xl flex items-center justify-center font-black shadow-lg shadow-indigo-100 ring-4 ring-white">
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
</div>
<div class="overflow-hidden">
<div class="font-black text-sm text-slate-800 uppercase tracking-tighter">{{ Auth::user()->name }}</div>
<div class="font-bold text-[10px] text-slate-400 truncate">{{ Auth::user()->email }}</div>
</div>
</div>
<div class="mt-4 space-y-1 px-4">
<x-responsive-nav-link :href="route('profile.edit')" class="rounded-xl font-bold text-xs uppercase tracking-widest py-3">
<i class="fas fa-user-circle mr-3"></i> {{ __('Profile Settings') }}
</x-responsive-nav-link>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-responsive-nav-link :href="route('logout')" class="rounded-xl font-black text-xs uppercase tracking-widest text-rose-500 py-3" onclick="event.preventDefault(); this.closest('form').submit();">
<i class="fas fa-sign-out-alt mr-3"></i> {{ __('Sign Out') }}
</x-responsive-nav-link>
</form>
</div>
</div>
</div>
</nav>
+2 -2
View File
@@ -47,7 +47,7 @@
<button type="submit" class="px-8 py-3.5 bg-slate-900 text-white text-[10px] font-black rounded-xl uppercase hover:bg-black transition-all">Filter</button> <button type="submit" class="px-8 py-3.5 bg-slate-900 text-white text-[10px] font-black rounded-xl uppercase hover:bg-black transition-all">Filter</button>
@if(request()->anyFilled(['search', 'action'])) @if(request()->anyFilled(['search', 'action']))
<a href="{{ route('admin.logs') }}" class="px-4 py-3.5 text-slate-400 hover:text-rose-600 text-[10px] font-black flex items-center transition-all uppercase">Reset</a> <a href="{{ route('admin.logs.index') }}" class="px-4 py-3.5 text-slate-400 hover:text-rose-600 text-[10px] font-black flex items-center transition-all uppercase">Reset</a>
@endif @endif
</form> </form>
</div> </div>
@@ -80,7 +80,7 @@
{{ substr($log->user->name ?? 'S', 0, 1) }} {{ substr($log->user->name ?? 'S', 0, 1) }}
</div> </div>
<div> <div>
<p class="text-xs font-black text-slate-700">{{ $log->user->name ?? 'System' }}</p> <p class="text-xs font-black text-slate-700">{{ $log->user->name ?? 'Sistem' }}</p>
<p class="text-[9px] text-slate-400 font-bold uppercase tracking-widest">{{ $log->user->role ?? '-' }}</p> <p class="text-[9px] text-slate-400 font-bold uppercase tracking-widest">{{ $log->user->role ?? '-' }}</p>
</div> </div>
</div> </div>
@@ -6,7 +6,7 @@
{{-- Header --}} {{-- Header --}}
<div class="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50"> <div class="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<div> <div>
<h2 class="text-2xl font-black text-slate-900 tracking-tight">Tambah Role Baru</h2> <h2 class="text-2xl font-black text-slate-900 tracking-tight">Tambah Hak Akses Baru</h2>
<p class="text-slate-500 font-medium">Definisikan tingkat otoritas pengguna baru.</p> <p class="text-slate-500 font-medium">Definisikan tingkat otoritas pengguna baru.</p>
</div> </div>
<a href="{{ route('admin.roles.index') }}" class="text-xs font-bold text-slate-400 hover:text-slate-600 transition-colors uppercase tracking-widest italic">Batal</a> <a href="{{ route('admin.roles.index') }}" class="text-xs font-bold text-slate-400 hover:text-slate-600 transition-colors uppercase tracking-widest italic">Batal</a>
@@ -15,9 +15,9 @@
<form action="{{ route('admin.roles.store') }}" method="POST" class="p-8 space-y-6"> <form action="{{ route('admin.roles.store') }}" method="POST" class="p-8 space-y-6">
@csrf @csrf
{{-- Nama Role --}} {{-- Nama Hak Akses --}}
<div class="space-y-2"> <div class="space-y-2">
<label for="name" class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] ml-1">Nama Role</label> <label for="name" class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] ml-1">Nama Hak Akses</label>
<input type="text" name="name" id="name" required placeholder="Contoh: Manager Operasional" <input type="text" name="name" id="name" required placeholder="Contoh: Manager Operasional"
class="block w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl text-slate-900 font-semibold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/20 transition-all"> class="block w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl text-slate-900 font-semibold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/20 transition-all">
<x-input-error :messages="$errors->get('name')" class="mt-1" /> <x-input-error :messages="$errors->get('name')" class="mt-1" />
@@ -9,7 +9,7 @@
<div> <div>
<h2 class="text-2xl font-black text-slate-900 tracking-tight">Konfigurasi Hak Akses</h2> <h2 class="text-2xl font-black text-slate-900 tracking-tight">Konfigurasi Hak Akses</h2>
<p class="text-slate-500 font-medium text-sm"> <p class="text-slate-500 font-medium text-sm">
Role: <span class="text-indigo-600 font-bold uppercase tracking-widest">{{ $role->name }}</span> Hak akses: <span class="text-indigo-600 font-bold uppercase tracking-widest">{{ $role->name }}</span>
</p> </p>
</div> </div>
<a href="{{ route('admin.roles.index') }}" class="text-xs font-bold text-slate-400 hover:text-slate-600 transition-colors uppercase tracking-widest italic flex items-center"> <a href="{{ route('admin.roles.index') }}" class="text-xs font-bold text-slate-400 hover:text-slate-600 transition-colors uppercase tracking-widest italic flex items-center">
@@ -9,7 +9,7 @@
<i class="fas fa-shield-alt text-[10px]"></i> <i class="fas fa-shield-alt text-[10px]"></i>
<span class="text-[9px] font-black uppercase tracking-widest">Security Module</span> <span class="text-[9px] font-black uppercase tracking-widest">Security Module</span>
</div> </div>
<h2 class="text-3xl font-black text-slate-800 tracking-tighter uppercase leading-none">Role Management</h2> <h2 class="text-3xl font-black text-slate-800 tracking-tighter uppercase leading-none">Pengelolaan Hak Akses</h2>
<p class="text-sm text-slate-400 font-medium italic">Konfigurasi matriks perizinan dan otoritas sistem ilycats.</p> <p class="text-sm text-slate-400 font-medium italic">Konfigurasi matriks perizinan dan otoritas sistem ilycats.</p>
</div> </div>
@@ -29,7 +29,7 @@
<table class="w-full text-left border-collapse min-w-[800px]"> <table class="w-full text-left border-collapse min-w-[800px]">
<thead class="bg-slate-50 border-b border-slate-100"> <thead class="bg-slate-50 border-b border-slate-100">
<tr> <tr>
<th class="px-10 py-6 text-[10px] font-black text-slate-400 uppercase tracking-widest">Identitas Role</th> <th class="px-10 py-6 text-[10px] font-black text-slate-400 uppercase tracking-widest">Identitas Hak Akses</th>
<th class="px-10 py-6 text-[10px] font-black text-slate-400 uppercase tracking-widest">Slug Identitas</th> <th class="px-10 py-6 text-[10px] font-black text-slate-400 uppercase tracking-widest">Slug Identitas</th>
<th class="px-10 py-6 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">Otoritas Matriks</th> <th class="px-10 py-6 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">Otoritas Matriks</th>
</tr> </tr>
@@ -37,7 +37,7 @@
<tbody class="divide-y divide-slate-50"> <tbody class="divide-y divide-slate-50">
@foreach($roles as $role) @foreach($roles as $role)
<tr class="hover:bg-slate-50/50 transition-all group"> <tr class="hover:bg-slate-50/50 transition-all group">
{{-- Nama Role --}} {{-- Nama Hak Akses --}}
<td class="px-10 py-6"> <td class="px-10 py-6">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-2xl bg-slate-100 flex items-center justify-center text-slate-400 group-hover:bg-indigo-600 group-hover:text-white border border-slate-200 group-hover:border-indigo-500 transition-all duration-300 shadow-sm"> <div class="w-12 h-12 rounded-2xl bg-slate-100 flex items-center justify-center text-slate-400 group-hover:bg-indigo-600 group-hover:text-white border border-slate-200 group-hover:border-indigo-500 transition-all duration-300 shadow-sm">
@@ -45,7 +45,7 @@
</div> </div>
<div> <div>
<span class="text-sm font-black text-slate-800 uppercase tracking-tight block">{{ $role->name }}</span> <span class="text-sm font-black text-slate-800 uppercase tracking-tight block">{{ $role->name }}</span>
<span class="text-[9px] text-slate-400 font-bold uppercase tracking-tighter">System Authority Level</span> <span class="text-[9px] text-slate-400 font-bold uppercase tracking-tighter">Tingkat Hak Akses Sistem</span>
</div> </div>
</div> </div>
</td> </td>
@@ -113,7 +113,7 @@
<div class="md:col-span-1"> <div class="md:col-span-1">
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 ms-4">Slogan</label> <label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 ms-4">Slogan</label>
<input type="text" name="app_slogan" value="{{ $settings['app_slogan'] ?? '' }}" <input type="text" name="app_slogan" value="{{ $settings['app_slogan'] ?? '' }}"
class="w-full bg-slate-50 border-none rounded-full px-8 py-4 text-sm font-bold text-slate-700 focus:ring-2 focus:ring-indigo-100 shadow-inner" placeholder="E.g: Premium Quality"> class="w-full bg-slate-50 border-none rounded-full px-8 py-4 text-sm font-bold text-slate-700 focus:ring-2 focus:ring-indigo-100 shadow-inner" placeholder="E.g: Manajemen cattery terpercaya">
</div> </div>
</div> </div>
</div> </div>
@@ -145,7 +145,7 @@
<div> <div>
<h4 class="text-[10px] font-black text-slate-400 uppercase tracking-widest">SMTP Email</h4> <h4 class="text-[10px] font-black text-slate-400 uppercase tracking-widest">SMTP Email</h4>
<p class="text-xs font-black {{ $emailStatus['active'] ? 'text-indigo-700' : 'text-rose-700' }}"> <p class="text-xs font-black {{ $emailStatus['active'] ? 'text-indigo-700' : 'text-rose-700' }}">
{{ $emailStatus['active'] ? 'Service Online' : 'Konfigurasi Salah' }} {{ $emailStatus['active'] ? 'Layanan Aktif' : 'Konfigurasi Salah' }}
</p> </p>
</div> </div>
</div> </div>
@@ -0,0 +1,92 @@
<x-app-layout>
<div class="max-w-4xl mx-auto py-12 px-4">
{{-- Header & Back Button --}}
<div class="flex items-center gap-6 mb-10">
<a href="{{ route('cats.index') }}" class="p-4 bg-white border border-slate-200 rounded-2xl text-slate-400 hover:text-emerald-600 transition-all shadow-sm active:scale-90">
<i class="fas fa-arrow-left"></i>
</a>
<div>
<h2 class="text-2xl font-black text-slate-800 uppercase tracking-tight">Bulk Berat Update</h2>
<p class="text-sm text-slate-400 font-bold uppercase tracking-widest">Perbarui berat badan massal via Excel/CSV</p>
</div>
</div>
<div class="bg-white rounded-[3rem] shadow-xl border border-slate-100 p-10 md:p-14 overflow-hidden">
{{-- Langkah 1: Unduh --}}
<div class="flex flex-col md:flex-row items-start md:items-center gap-6 p-8 bg-slate-50 rounded-[2rem] border border-slate-100 mb-10">
<div class="w-16 h-16 bg-white text-indigo-600 rounded-2xl flex items-center justify-center shrink-0 shadow-sm border border-slate-100">
<i class="fas fa-file-csv text-3xl"></i>
</div>
<div class="flex-1">
<h4 class="font-black text-slate-800 uppercase text-sm mb-2">Langkah 1: Unduh Format CSV</h4>
<p class="text-xs text-slate-500 mb-0 leading-relaxed font-medium">
Sistem akan mengunduh daftar kucing saat ini beserta <b>Kode Kucing</b>-nya.<br>
Cukup isi angka pada kolom <b>Berat Badan (Kg)</b>. Anda juga dapat mengubah <b>Tanggal</b> untuk mencatat data timbangan di masa lalu (Backdate).
</p>
</div>
<div class="shrink-0 mt-4 md:mt-0 w-full md:w-auto">
<a href="{{ route('cats.weight-template') }}" class="flex items-center justify-center px-6 py-4 bg-indigo-50 border border-indigo-100 text-indigo-700 text-[10px] font-black rounded-2xl hover:bg-indigo-600 hover:text-white transition-all shadow-sm uppercase tracking-widest w-full">
<i class="fas fa-download mr-2 text-sm"></i> Unduh Template
</a>
</div>
</div>
{{-- Langkah 2: Unggah Form --}}
<form action="{{ route('cats.bulk-weight.post') }}" method="POST" enctype="multipart/form-data" class="space-y-8">
@csrf
<div class="space-y-4">
<label class="text-xs font-black text-slate-800 uppercase tracking-widest ml-2">Langkah 2: Unggah Data Terbaru</label>
<div class="relative group">
<input type="file" name="file_excel" id="file_excel" required accept=".csv, .xlsx, .xls" onchange="updateFileName(this)" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10">
<div id="upload-box" class="w-full p-16 border-2 border-dashed border-slate-300 rounded-[2.5rem] bg-white text-center group-hover:border-emerald-500 group-hover:bg-emerald-50 transition-all">
<i id="upload-icon" class="fas fa-cloud-upload-alt text-6xl text-slate-300 group-hover:text-emerald-500 mb-6 transition-colors"></i>
<p id="upload-text" class="text-xs font-black text-slate-600 uppercase tracking-widest">Klik atau Seret File Excel/CSV ke Sini</p>
<p id="file-name" class="text-sm font-bold text-emerald-600 mt-4 hidden bg-white px-4 py-2 rounded-lg inline-block shadow-sm border border-emerald-100"></p>
</div>
</div>
</div>
<div class="pt-4 border-t border-slate-100">
<button type="submit" class="w-full py-6 bg-emerald-600 text-white rounded-[2rem] font-black uppercase text-xs tracking-[0.3em] shadow-xl shadow-emerald-200 hover:bg-emerald-700 transition-all active:scale-95">
<i class="fas fa-check-circle mr-2 text-sm"></i> PROSES BULK UPDATE
</button>
</div>
</form>
</div>
</div>
@push('scripts')
<script>
// Script untuk mengubah tampilan UI saat file dipilih oleh user
function updateFileName(input) {
const fileName = input.files[0]?.name;
const textEl = document.getElementById('upload-text');
const nameEl = document.getElementById('file-name');
const iconEl = document.getElementById('upload-icon');
const boxEl = document.getElementById('upload-box');
if (fileName) {
textEl.innerText = "File Siap Diproses:";
nameEl.innerText = fileName;
nameEl.classList.remove('hidden');
iconEl.classList.replace('fa-cloud-upload-alt', 'fa-file-excel');
iconEl.classList.replace('text-slate-300', 'text-emerald-500');
boxEl.classList.replace('border-slate-300', 'border-emerald-500');
boxEl.classList.replace('bg-white', 'bg-emerald-50');
} else {
textEl.innerText = "Klik atau Seret File Excel/CSV ke Sini";
nameEl.classList.add('hidden');
iconEl.classList.replace('fa-file-excel', 'fa-cloud-upload-alt');
iconEl.classList.replace('text-emerald-500', 'text-slate-300');
boxEl.classList.replace('border-emerald-500', 'border-slate-300');
boxEl.classList.replace('bg-emerald-50', 'bg-white');
}
}
</script>
@endpush
</x-app-layout>
+94 -61
View File
@@ -6,8 +6,8 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</a> </a>
<div> <div>
<h2 class="text-2xl font-black text-slate-800 tracking-tight uppercase">Tambah Kucing Baru</h2> <h2 class="text-2xl font-black text-slate-800 tracking-tight uppercase leading-none mb-1">Tambah Kucing Baru</h2>
<p class="text-sm text-slate-500 font-medium italic tracking-tight">Registrasi identitas permanen & silsilah ke database Ilycats.</p> <p class="text-[10px] text-slate-400 font-bold uppercase tracking-[0.2em]">Registrasi Identitas Permanen • Ilycats Database</p>
</div> </div>
</div> </div>
@@ -30,31 +30,38 @@
@csrf @csrf
{{-- SECTION 1: IDENTITAS UTAMA --}} {{-- SECTION 1: IDENTITAS UTAMA --}}
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-6"> <div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-8">
<h4 class="text-xs font-black text-indigo-600 uppercase tracking-widest border-b border-slate-100 pb-4 mb-2 flex items-center gap-2 italic"> <div class="flex items-center gap-4 mb-2 border-b border-slate-100 pb-4">
<i class="fas fa-id-card"></i> Identitas & Dokumen Resmi <div class="w-8 h-8 bg-indigo-600 text-white rounded-xl flex items-center justify-center text-xs shadow-lg">
</h4> <i class="fas fa-id-card"></i>
</div>
<h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg">Identitas & Dokumen Resmi</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{{-- Nama Kucing --}} {{-- Nama Kucing --}}
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Nama Lengkap Kucing (Sesuai Pedigree)</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Nama Lengkap Kucing (Sesuai Pedigree)</label>
<input type="text" name="nama_kucing" value="{{ old('nama_kucing') }}" required placeholder="Contoh: Earrin Cats Moonlight" class="w-full px-4 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-slate-800"> <input type="text" name="nama_kucing" value="{{ old('nama_kucing') }}" required placeholder="Contoh: Earrin Cats Moonlight" class="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all text-sm font-bold text-slate-800">
</div> </div>
{{-- No. Pedigree, Microchip, & EMS (GRID 3 KOLOM) --}} {{-- GRID IDENTITAS --}}
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 border-y border-slate-50 py-6 my-2">
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Pedigree</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Pedigree</label>
<input type="text" name="pedigree" value="{{ old('pedigree') }}" placeholder="ICA.LO.xxxx" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-xs font-bold text-slate-800"> {{-- PERBAIKAN: Menghapus class 'uppercase' agar input sesuai ketikan --}}
<input type="text" name="pedigree" value="{{ old('pedigree') }}" placeholder="ICA.LO.xxxx" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-bold text-slate-800">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Microchip</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Microchip</label>
<input type="text" name="microchip" value="{{ old('microchip') }}" placeholder="90011xxxx" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-xs font-bold text-slate-800"> <input type="text" name="microchip" value="{{ old('microchip') }}" placeholder="90011xxxx" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-bold text-slate-800">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Kode EMS</label> <label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest ml-1 flex items-center gap-2">
<input type="text" name="kode_ems" value="{{ old('kode_ems') }}" placeholder="PER d 03" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 transition-all text-xs font-bold text-slate-800"> <i class="fas fa-dna text-[10px]"></i> Kode EMS
</label>
{{-- PERBAIKAN: Menghapus class 'uppercase' agar input sesuai ketikan --}}
<input type="text" name="kode_ems" value="{{ old('kode_ems') }}" placeholder="PER d 03" class="w-full px-4 py-3 bg-indigo-50/30 border border-indigo-100 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-black text-indigo-700">
</div> </div>
</div> </div>
@@ -62,9 +69,9 @@
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Ras / Breed</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Ras / Breed</label>
<select name="ras" required class="w-full px-4 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-slate-800"> <select name="ras" required class="w-full px-4 py-4 bg-slate-50 border border-slate-200 rounded-2xl focus:ring-2 focus:ring-indigo-500 transition-all text-sm font-bold text-slate-800">
<option value="Persian" {{ old('ras') == 'Persian' ? 'selected' : '' }}>Persian</option> @foreach(['Persian', 'British Shorthair', 'Scottish Fold', 'Maine Coon', 'Munchkin', 'Lainnya'] as $ras)
<option value="British Shorthair" {{ old('ras') == 'British Shorthair' ? 'selected' : '' }}>British Shorthair</option> <option value="{{ $ras }}" {{ old('ras') == $ras ? 'selected' : '' }}>{{ $ras }}</option>
<option value="Lainnya" {{ old('ras') == 'Lainnya' ? 'selected' : '' }}>Lainnya</option> @endforeach
</select> </select>
</div> </div>
@@ -77,11 +84,11 @@
{{-- Registry --}} {{-- Registry --}}
<div class="md:col-span-2 space-y-3"> <div class="md:col-span-2 space-y-3">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 block">Federasi / Registry</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 block">Federasi / Registry</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-5 gap-3">
@foreach(['ICA', 'CFA', 'WCF', 'FFF'] as $reg) @foreach(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'] as $reg)
<label class="block cursor-pointer group"> <label class="block cursor-pointer group">
<input type="checkbox" name="registry[]" value="{{ $reg }}" <input type="checkbox" name="registry[]" value="{{ $reg }}"
{{ is_array(old('registry')) && in_array($reg, old('registry')) ? 'checked' : '' }} {{ (is_array(old('registry')) && in_array($reg, old('registry'))) || ($reg === 'NON-PED' && !old('registry')) ? 'checked' : '' }}
class="sr-only peer"> class="sr-only peer">
<div class="py-4 text-center border-2 border-slate-100 rounded-2xl text-[10px] font-black text-slate-400 <div class="py-4 text-center border-2 border-slate-100 rounded-2xl text-[10px] font-black text-slate-400
peer-checked:bg-indigo-600 peer-checked:text-white peer-checked:border-indigo-600 peer-checked:bg-indigo-600 peer-checked:text-white peer-checked:border-indigo-600
@@ -97,15 +104,15 @@
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Jenis Kelamin</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Jenis Kelamin</label>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex-1 cursor-pointer"> <label class="flex-1 cursor-pointer group">
<input type="radio" name="jenis_kelamin" value="Jantan" {{ old('jenis_kelamin', 'Jantan') == 'Jantan' ? 'checked' : '' }} class="hidden peer"> <input type="radio" name="jenis_kelamin" value="Jantan" {{ old('jenis_kelamin', 'Jantan') == 'Jantan' ? 'checked' : '' }} class="sr-only peer">
<div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-blue-50 peer-checked:border-blue-500 peer-checked:text-blue-600 transition-all shadow-sm"> <div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-blue-50 peer-checked:border-blue-500 peer-checked:text-blue-600 transition-all shadow-sm group-hover:border-slate-200">
<i class="fas fa-mars mr-2"></i> JANTAN (SIRE) <i class="fas fa-mars mr-2"></i> JANTAN (SIRE)
</div> </div>
</label> </label>
<label class="flex-1 cursor-pointer"> <label class="flex-1 cursor-pointer group">
<input type="radio" name="jenis_kelamin" value="Betina" {{ old('jenis_kelamin') == 'Betina' ? 'checked' : '' }} class="hidden peer"> <input type="radio" name="jenis_kelamin" value="Betina" {{ old('jenis_kelamin') == 'Betina' ? 'checked' : '' }} class="sr-only peer">
<div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-pink-50 peer-checked:border-pink-500 peer-checked:text-pink-600 transition-all shadow-sm"> <div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-pink-50 peer-checked:border-pink-500 peer-checked:text-pink-600 transition-all shadow-sm group-hover:border-slate-200">
<i class="fas fa-venus mr-2"></i> BETINA (DAM) <i class="fas fa-venus mr-2"></i> BETINA (DAM)
</div> </div>
</label> </label>
@@ -117,42 +124,46 @@
{{-- SECTION 2: GENETIK & LOKASI --}} {{-- SECTION 2: GENETIK & LOKASI --}}
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-6"> <div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-6">
<h4 class="text-xs font-black text-indigo-600 uppercase tracking-widest border-b border-slate-100 pb-4 mb-2 flex items-center gap-2 italic"> <h4 class="text-xs font-black text-indigo-600 uppercase tracking-widest border-b border-slate-100 pb-4 mb-2 flex items-center gap-2 italic">
<i class="fas fa-dna"></i> Penempatan & Silsilah Induk <i class="fas fa-dna text-sm"></i> Penempatan & Silsilah Induk
</h4> </h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{{-- Searchable Sire --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Jantan</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Jantan (Sire)</label>
<select name="induk_jantan_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700"> <input type="text" list="sire_list" id="sire_search" placeholder="Ketik nama sire..." class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500" autocomplete="off">
<option value="">-- Tanpa Sire --</option> <datalist id="sire_list">
@foreach($sires as $sire) @foreach($sires as $sire)
<option value="{{ $sire->id }}" {{ old('induk_jantan_id') == $sire->id ? 'selected' : '' }}>{{ $sire->nama_kucing }}</option> <option data-id="{{ $sire->id }}" value="{{ $sire->nama_kucing }}">
@endforeach @endforeach
</select> </datalist>
<input type="hidden" name="induk_jantan_id" id="induk_jantan_id" value="{{ old('induk_jantan_id') }}">
</div> </div>
{{-- Searchable Dam --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Betina</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Betina (Dam)</label>
<select name="induk_betina_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700"> <input type="text" list="dam_list" id="dam_search" placeholder="Ketik nama dam..." class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500" autocomplete="off">
<option value="">-- Tanpa Dam --</option> <datalist id="dam_list">
@foreach($dams as $dam) @foreach($dams as $dam)
<option value="{{ $dam->id }}" {{ old('induk_betina_id') == $dam->id ? 'selected' : '' }}>{{ $dam->nama_kucing }}</option> <option data-id="{{ $dam->id }}" value="{{ $dam->nama_kucing }}">
@endforeach @endforeach
</select> </datalist>
<input type="hidden" name="induk_betina_id" id="induk_betina_id" value="{{ old('induk_betina_id') }}">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Lokasi Kandang</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Lokasi Kandang</label>
<select name="lokasi_kandang" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-black text-indigo-600 uppercase"> <select name="lokasi_kandang" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-black text-indigo-600 uppercase focus:ring-2 focus:ring-indigo-500">
@foreach(['A1', 'A2', 'Lorong', 'E1', 'E2'] as $loc) @foreach(['A1', 'A2', 'Lorong', 'E1', 'E2', 'Karantina'] as $loc)
<option value="{{ $loc }}" {{ old('lokasi_kandang') == $loc ? 'selected' : '' }}>{{ $loc }}</option> <option value="{{ $loc }}" {{ old('lokasi_kandang') == $loc ? 'selected' : '' }}>{{ $loc }}</option>
@endforeach @endforeach
</select> </select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">PIC (Penanggung Jawab)</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">PIC Bertugas</label>
<select name="pic_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700"> <select name="pic_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500">
@foreach($users as $user) @foreach($users as $user)
<option value="{{ $user->id }}" {{ old('pic_id', auth()->id()) == $user->id ? 'selected' : '' }}>{{ $user->name }}</option> <option value="{{ $user->id }}" {{ old('pic_id', auth()->id()) == $user->id ? 'selected' : '' }}>{{ $user->name }}</option>
@endforeach @endforeach
@@ -162,44 +173,43 @@
</div> </div>
{{-- SECTION 3: JADWAL & PERTUMBUHAN --}} {{-- SECTION 3: JADWAL & PERTUMBUHAN --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Jadwal Medis --}} <div class="lg:col-span-2 bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm">
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-4"> <div class="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<h4 class="text-xs font-black text-rose-600 uppercase tracking-widest border-b border-slate-100 pb-4 mb-2 flex items-center gap-2 italic"> <div class="w-7 h-7 bg-rose-50 text-rose-600 rounded-lg flex items-center justify-center text-[10px]">
<i class="fas fa-calendar-alt"></i> Jadwal Perawatan <i class="fas fa-calendar-alt"></i>
</h4> </div>
<h4 class="text-xs font-black text-rose-600 uppercase tracking-widest italic">Jadwal Perawatan</h4>
</div>
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
@foreach (['vaksin', 'obat_cacing', 'obat_kutu', 'grooming'] as $task) @foreach (['vaksin' => 'Vaksin', 'obat_cacing' => 'Obat Cacing', 'obat_kutu' => 'Obat Kutu', 'grooming' => 'Grooming'] as $task => $label)
<div class="space-y-1"> <div class="space-y-1">
<label class="text-[9px] font-bold text-slate-400 uppercase ml-1">{{ str_replace('_', ' ', $task) }}</label> <label class="text-[9px] font-bold text-slate-400 uppercase ml-1">{{ $label }}</label>
<input type="date" name="jadwal_{{ $task }}" value="{{ old('jadwal_'.$task) }}" class="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-[10px] font-black text-slate-700 focus:ring-rose-500"> <input type="date" name="jadwal_{{ $task }}" value="{{ old('jadwal_'.$task) }}" class="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-[10px] font-black text-slate-700 focus:ring-2 focus:ring-rose-200 transition-all">
</div> </div>
@endforeach @endforeach
</div> </div>
</div> </div>
{{-- Monitoring Berat --}} <div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm">
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-4"> <h4 class="text-xs font-black text-emerald-600 uppercase tracking-widest border-b border-slate-100 pb-4 mb-6 italic flex items-center gap-2">
<h4 class="text-xs font-black text-emerald-600 uppercase tracking-widest border-b border-slate-100 pb-4 mb-2 flex items-center gap-2 italic"> <i class="fas fa-weight-scale text-sm"></i> Vitalitas
<i class="fas fa-weight-scale"></i> Vitalitas & Berat
</h4> </h4>
<div class="space-y-4"> <div class="space-y-4">
<div>
<label class="text-[10px] font-black text-slate-400 uppercase ml-1">Berat Badan Awal</label> <label class="text-[10px] font-black text-slate-400 uppercase ml-1">Berat Badan Awal</label>
<div class="relative mt-2"> <div class="relative mt-2">
<input type="number" step="0.01" name="berat_badan" value="{{ old('berat_badan') }}" placeholder="0.00" class="w-full pl-6 pr-14 py-5 bg-slate-50 border border-slate-200 rounded-3xl text-2xl font-black text-slate-800 focus:ring-emerald-500"> <input type="number" step="0.01" name="berat_badan" value="{{ old('berat_badan') }}" placeholder="0.00" class="w-full pl-6 pr-14 py-5 bg-slate-50 border border-slate-200 rounded-3xl text-2xl font-black text-slate-800 focus:ring-4 focus:ring-emerald-500/10 focus:border-emerald-500 transition-all">
<span class="absolute right-6 top-1/2 -translate-y-1/2 text-xs font-black text-slate-400">KG</span> <span class="absolute right-6 top-1/2 -translate-y-1/2 text-xs font-black text-slate-400">KG</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
{{-- SECTION 4: FOTO --}} {{-- SECTION 4: FOTO --}}
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-4"> <div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-4">
<h4 class="text-xs font-black text-slate-800 uppercase tracking-widest ml-1 mb-2">Unggah Foto Utama</h4> <h4 class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2">Unggah Foto Utama</h4>
<div class="relative group"> <div class="relative group">
<input type="file" name="foto" id="foto" class="hidden" onchange="previewImage(event)"> <input type="file" name="foto" id="foto" class="hidden" onchange="previewImage(event)" accept="image/*">
<label for="foto" class="flex flex-col items-center justify-center border-4 border-dashed border-slate-100 rounded-[3rem] p-12 text-center cursor-pointer hover:border-indigo-200 hover:bg-indigo-50/20 transition-all duration-500 group"> <label for="foto" class="flex flex-col items-center justify-center border-4 border-dashed border-slate-100 rounded-[3rem] p-12 text-center cursor-pointer hover:border-indigo-200 hover:bg-indigo-50/20 transition-all duration-500 group">
<div id="preview-container" class="hidden mb-6"> <div id="preview-container" class="hidden mb-6">
<img id="image-preview" class="w-48 h-48 rounded-[2.5rem] object-cover shadow-2xl ring-8 ring-white"> <img id="image-preview" class="w-48 h-48 rounded-[2.5rem] object-cover shadow-2xl ring-8 ring-white">
@@ -210,7 +220,7 @@
</div> </div>
<div> <div>
<p class="text-xs text-slate-600 font-black tracking-widest uppercase">Pilih Foto Terbaik</p> <p class="text-xs text-slate-600 font-black tracking-widest uppercase">Pilih Foto Terbaik</p>
<p class="text-[10px] text-slate-400 mt-1 uppercase font-bold tracking-tighter italic">JPG, PNG, WEBP (Maks 2MB)</p> <p class="text-[10px] text-slate-400 mt-1 uppercase font-bold tracking-tighter italic">JPG, PNG, WEBP (Maks 3MB)</p>
</div> </div>
</div> </div>
</label> </label>
@@ -220,8 +230,8 @@
{{-- Action Button --}} {{-- Action Button --}}
<div class="pt-4"> <div class="pt-4">
<button type="submit" class="w-full py-6 bg-indigo-600 text-white text-xs font-black rounded-[2.5rem] shadow-2xl shadow-indigo-100 hover:bg-indigo-700 hover:-translate-y-1 transition-all active:scale-[0.98] flex items-center justify-center gap-4 group"> <button type="submit" class="w-full py-6 bg-indigo-600 text-white text-xs font-black rounded-[2.5rem] shadow-2xl shadow-indigo-100 hover:bg-indigo-700 hover:-translate-y-1 transition-all active:scale-[0.98] flex items-center justify-center gap-4 group">
<i class="fas fa-save text-xl"></i> <i class="fas fa-save text-xl group-hover:rotate-12 transition-transform"></i>
<span class="tracking-[0.2em] uppercase">Simpan Record Baru</span> <span class="tracking-[0.2em] uppercase">Simpan Record Baru Ilycats</span>
</button> </button>
</div> </div>
</form> </form>
@@ -233,6 +243,29 @@
</style> </style>
<script> <script>
// Logika Sinkronisasi Searchable Datalist untuk Sire/Dam
function handleSearchableDatalist(inputId, listId, hiddenId) {
const input = document.getElementById(inputId);
const list = document.getElementById(listId);
const hidden = document.getElementById(hiddenId);
input.addEventListener('input', function() {
const options = list.options;
hidden.value = ""; // Reset hidden ID setiap kali input berubah
for (let i = 0; i < options.length; i++) {
if (options[i].value === input.value) {
hidden.value = options[i].getAttribute('data-id');
break;
}
}
});
}
handleSearchableDatalist('sire_search', 'sire_list', 'induk_jantan_id');
handleSearchableDatalist('dam_search', 'dam_list', 'induk_betina_id');
// Preview Foto
function previewImage(event) { function previewImage(event) {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function() { reader.onload = function() {
+69 -36
View File
@@ -6,8 +6,8 @@
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</a> </a>
<div> <div>
<h2 class="text-2xl font-black text-slate-800 tracking-tight">Edit Profil: {{ $cat->nama_kucing }}</h2> <h2 class="text-2xl font-black text-slate-800 tracking-tight leading-none mb-1">Edit Profil: {{ $cat->nama_kucing }}</h2>
<p class="text-sm text-slate-500 font-medium italic uppercase tracking-tighter">ID Pasien #{{ str_pad($cat->id, 4, '0', STR_PAD_LEFT) }} • Update Identitas</p> <p class="text-[10px] text-slate-400 font-bold uppercase tracking-[0.2em]">Cattery Management • Pasien #{{ str_pad($cat->id, 4, '0', STR_PAD_LEFT) }}</p>
</div> </div>
</div> </div>
@@ -28,7 +28,7 @@
<form action="{{ route('cats.update', $cat->id) }}" method="POST" enctype="multipart/form-data" class="space-y-6"> <form action="{{ route('cats.update', $cat->id) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
@csrf @csrf
@method('PATCH') @method('PUT')
{{-- SECTION 1: IDENTITAS UTAMA & DOKUMEN --}} {{-- SECTION 1: IDENTITAS UTAMA & DOKUMEN --}}
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-8"> <div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-8">
@@ -39,15 +39,14 @@
<h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg">Identitas & Dokumen Resmi</h4> <h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg">Identitas & Dokumen Resmi</h4>
</div> </div>
<div class="flex flex-col md:flex-row gap-6 items-start"> <div class="flex flex-col md:flex-row gap-8 items-start">
{{-- FOTO SECTION --}}
{{-- FOTO SECTION (THUMBNAIL) --}}
<div class="relative group self-center md:self-start flex-shrink-0"> <div class="relative group self-center md:self-start flex-shrink-0">
<div class="w-24 h-24 rounded-3xl bg-slate-50 border-2 border-slate-200 overflow-hidden ring-4 ring-indigo-50 shadow-md flex items-center justify-center"> <div class="w-28 h-28 rounded-[2rem] bg-slate-50 border-2 border-slate-200 overflow-hidden ring-8 ring-indigo-50/50 shadow-md flex items-center justify-center">
<img id="image-preview" src="{{ $cat->foto_url }}" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"> <img id="image-preview" src="{{ $cat->foto_url }}" class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110">
</div> </div>
<label for="foto" class="absolute -bottom-2 -right-2 w-9 h-9 bg-indigo-600 text-white rounded-xl border-4 border-white cursor-pointer shadow-sm hover:scale-110 transition-transform active:scale-95 flex items-center justify-center"> <label for="foto" class="absolute -bottom-2 -right-2 w-10 h-10 bg-indigo-600 text-white rounded-2xl border-4 border-white cursor-pointer shadow-lg hover:bg-indigo-700 transition-all flex items-center justify-center">
<i class="fas fa-camera text-[10px]"></i> <i class="fas fa-camera text-xs"></i>
<input type="file" name="foto" id="foto" class="hidden" accept="image/*" onchange="previewImage(event)"> <input type="file" name="foto" id="foto" class="hidden" accept="image/*" onchange="previewImage(event)">
</label> </label>
</div> </div>
@@ -56,32 +55,30 @@
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 gap-6 w-full"> <div class="flex-1 grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Nama Lengkap Kucing</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Nama Lengkap Kucing</label>
<input type="text" name="nama_kucing" value="{{ old('nama_kucing', $cat->nama_kucing) }}" required class="w-full px-4 py-4 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-bold text-slate-800 focus:ring-2 focus:ring-indigo-500 transition-all"> <input type="text" name="nama_kucing" value="{{ old('nama_kucing', $cat->nama_kucing) }}" required class="w-full px-5 py-4 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-bold text-slate-800 focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500 transition-all">
</div> </div>
{{-- PERBAIKAN: GRID IDENTITAS (PEDIGREE, MICROCHIP, EMS) --}}
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 border-y border-slate-50 py-6 my-2"> <div class="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4 border-y border-slate-50 py-6 my-2">
{{-- Input Pedigree --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Pedigree</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Pedigree</label>
{{-- PERBAIKAN: Menghapus class uppercase --}}
<input type="text" name="pedigree" value="{{ old('pedigree', $cat->pedigree) }}" placeholder="ICA.LO.xxx" <input type="text" name="pedigree" value="{{ old('pedigree', $cat->pedigree) }}" placeholder="ICA.LO.xxx"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-bold text-slate-800"> class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-bold text-slate-800">
</div> </div>
{{-- Input Microchip --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Microchip</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">No. Microchip</label>
<input type="text" name="microchip" value="{{ old('microchip', $cat->microchip) }}" placeholder="9001xxxx" <input type="text" name="microchip" value="{{ old('microchip', $cat->microchip) }}" placeholder="9001xxxx"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-bold text-slate-800"> class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-bold text-slate-800">
</div> </div>
{{-- Input Kode EMS (Highlight Indigo) --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest ml-1 flex items-center gap-2"> <label class="text-[10px] font-black text-indigo-600 uppercase tracking-widest ml-1 flex items-center gap-2">
<i class="fas fa-dna text-[10px]"></i> Kode EMS <i class="fas fa-dna text-[10px]"></i> Kode EMS
</label> </label>
{{-- PERBAIKAN: Menghapus class uppercase --}}
<input type="text" name="kode_ems" value="{{ old('kode_ems', $cat->kode_ems) }}" placeholder="PER d 03" <input type="text" name="kode_ems" value="{{ old('kode_ems', $cat->kode_ems) }}" placeholder="PER d 03"
class="w-full px-4 py-3 bg-indigo-50/30 border border-indigo-100 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-black text-indigo-700 uppercase"> class="w-full px-4 py-3 bg-indigo-50/30 border border-indigo-100 rounded-xl focus:ring-2 focus:ring-indigo-500 text-xs font-black text-indigo-700">
</div> </div>
</div> </div>
@@ -99,18 +96,18 @@
<input type="date" name="tanggal_lahir" value="{{ old('tanggal_lahir', $cat->tanggal_lahir?->format('Y-m-d')) }}" class="w-full px-4 py-4 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-bold text-slate-800 focus:ring-2 focus:ring-indigo-500"> <input type="date" name="tanggal_lahir" value="{{ old('tanggal_lahir', $cat->tanggal_lahir?->format('Y-m-d')) }}" class="w-full px-4 py-4 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-bold text-slate-800 focus:ring-2 focus:ring-indigo-500">
</div> </div>
{{-- Registry / Federasi --}}
<div class="space-y-3 md:col-span-2"> <div class="space-y-3 md:col-span-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Registry / Federasi</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Registry / Federasi</label>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div class="grid grid-cols-2 md:grid-cols-5 gap-3">
@foreach(['ICA', 'CFA', 'WCF', 'FFF'] as $reg) @foreach(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'] as $reg)
@php @php
$isChecked = (is_array(old('registry')) && in_array($reg, old('registry'))) || $isChecked = old('registry')
(is_array($cat->registry) && in_array($reg, $cat->registry)); ? in_array($reg, old('registry'))
: (is_array($cat->registry) && in_array($reg, $cat->registry));
@endphp @endphp
<label class="block cursor-pointer group"> <label class="block cursor-pointer group">
<input type="checkbox" name="registry[]" value="{{ $reg }}" {{ $isChecked ? 'checked' : '' }} class="sr-only peer"> <input type="checkbox" name="registry[]" value="{{ $reg }}" {{ $isChecked ? 'checked' : '' }} class="sr-only peer">
<div class="py-4 text-center border-2 border-slate-100 rounded-2xl text-[10px] font-black text-slate-400 peer-checked:bg-indigo-600 peer-checked:text-white peer-checked:border-indigo-600 transition-all shadow-sm group-hover:border-slate-300"> <div class="py-4 text-center border-2 border-slate-100 rounded-2xl text-[10px] font-black text-slate-400 peer-checked:bg-indigo-600 peer-checked:text-white peer-checked:border-indigo-600 transition-all shadow-sm group-hover:border-slate-300 uppercase">
{{ $reg }} {{ $reg }}
</div> </div>
</label> </label>
@@ -118,20 +115,19 @@
</div> </div>
</div> </div>
{{-- Jenis Kelamin --}}
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Jenis Kelamin</label> <label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Jenis Kelamin</label>
<div class="flex gap-4"> <div class="flex gap-4">
<label class="flex-1 cursor-pointer group"> <label class="flex-1 cursor-pointer group">
<input type="radio" name="jenis_kelamin" value="Jantan" {{ old('jenis_kelamin', $cat->jenis_kelamin) == 'Jantan' ? 'checked' : '' }} class="sr-only peer"> <input type="radio" name="jenis_kelamin" value="Jantan" {{ old('jenis_kelamin', $cat->jenis_kelamin) == 'Jantan' ? 'checked' : '' }} class="sr-only peer">
<div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-blue-50 peer-checked:border-blue-500 peer-checked:text-blue-600 transition-all shadow-sm group-hover:border-slate-200"> <div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-blue-50 peer-checked:border-blue-500 peer-checked:text-blue-600 transition-all shadow-sm group-hover:border-slate-200">
<i class="fas fa-mars mr-2"></i> JANTAN <i class="fas fa-mars mr-2"></i> JANTAN (SIRE)
</div> </div>
</label> </label>
<label class="flex-1 cursor-pointer group"> <label class="flex-1 cursor-pointer group">
<input type="radio" name="jenis_kelamin" value="Betina" {{ old('jenis_kelamin', $cat->jenis_kelamin) == 'Betina' ? 'checked' : '' }} class="sr-only peer"> <input type="radio" name="jenis_kelamin" value="Betina" {{ old('jenis_kelamin', $cat->jenis_kelamin) == 'Betina' ? 'checked' : '' }} class="sr-only peer">
<div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-pink-50 peer-checked:border-pink-500 peer-checked:text-pink-600 transition-all shadow-sm group-hover:border-slate-200"> <div class="p-4 text-center border-2 border-slate-100 rounded-2xl text-xs font-black text-slate-400 peer-checked:bg-pink-50 peer-checked:border-pink-500 peer-checked:text-pink-600 transition-all shadow-sm group-hover:border-slate-200">
<i class="fas fa-venus mr-2"></i> BETINA <i class="fas fa-venus mr-2"></i> BETINA (DAM)
</div> </div>
</label> </label>
</div> </div>
@@ -147,24 +143,36 @@
</h4> </h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{{-- Searchable Sire --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Sire (Bapak)</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Sire (Bapak)</label>
<select name="induk_jantan_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500"> <input type="text" list="sire_list" id="sire_search"
<option value="">-- Tidak Diketahui --</option> value="{{ old('sire_search', $cat->sire?->nama_kucing) }}"
placeholder="Ketik nama sire..."
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500" autocomplete="off">
<datalist id="sire_list">
@foreach($sires as $sire) @foreach($sires as $sire)
<option value="{{ $sire->id }}" {{ old('induk_jantan_id', $cat->induk_jantan_id) == $sire->id ? 'selected' : '' }}>{{ $sire->nama_kucing }}</option> <option data-id="{{ $sire->id }}" value="{{ $sire->nama_kucing }}">
@endforeach @endforeach
</select> </datalist>
<input type="hidden" name="induk_jantan_id" id="induk_jantan_id" value="{{ old('induk_jantan_id', $cat->induk_jantan_id) }}">
</div> </div>
{{-- Searchable Dam --}}
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Dam (Ibu)</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Dam (Ibu)</label>
<select name="induk_betina_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500"> <input type="text" list="dam_list" id="dam_search"
<option value="">-- Tidak Diketahui --</option> value="{{ old('dam_search', $cat->dam?->nama_kucing) }}"
placeholder="Ketik nama dam..."
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500" autocomplete="off">
<datalist id="dam_list">
@foreach($dams as $dam) @foreach($dams as $dam)
<option value="{{ $dam->id }}" {{ old('induk_betina_id', $cat->induk_betina_id) == $dam->id ? 'selected' : '' }}>{{ $dam->nama_kucing }}</option> <option data-id="{{ $dam->id }}" value="{{ $dam->nama_kucing }}">
@endforeach @endforeach
</select> </datalist>
<input type="hidden" name="induk_betina_id" id="induk_betina_id" value="{{ old('induk_betina_id', $cat->induk_betina_id) }}">
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Lokasi Kandang</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Lokasi Kandang</label>
<select name="lokasi_kandang" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-black text-indigo-600 uppercase focus:ring-2 focus:ring-indigo-500"> <select name="lokasi_kandang" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-sm font-black text-indigo-600 uppercase focus:ring-2 focus:ring-indigo-500">
@@ -173,6 +181,7 @@
@endforeach @endforeach
</select> </select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">PIC Bertugas</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">PIC Bertugas</label>
<select name="pic_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-[11px] font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500"> <select name="pic_id" class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl text-[11px] font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500">
@@ -210,27 +219,51 @@
</h4> </h4>
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Berat Badan</label> <label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Berat Badan</label>
<div class="relative mt-2"> <div class="relative mt-2">
<input type="number" step="0.01" name="berat_badan" value="{{ old('berat_badan', $cat->berat_badan) }}" class="w-full pl-5 pr-14 py-4 bg-slate-50 border border-slate-200 rounded-[1.5rem] text-2xl font-black text-slate-800 focus:ring-2 focus:ring-emerald-500 transition-all"> <input type="number" step="0.01" name="berat_badan" value="{{ old('berat_badan', $cat->berat_badan) }}" class="w-full pl-5 pr-14 py-4 bg-slate-50 border border-slate-200 rounded-[1.5rem] text-2xl font-black text-slate-800 focus:ring-4 focus:ring-emerald-500/10 focus:border-emerald-500 transition-all">
<span class="absolute right-5 top-1/2 -translate-y-1/2 text-xs font-black text-slate-400">KG</span> <span class="absolute right-5 top-1/2 -translate-y-1/2 text-xs font-black text-slate-400">KG</span>
</div> </div>
</div> </div>
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm border-l-4 border-l-rose-500"> <div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm border-l-4 border-l-rose-500">
<label class="text-[10px] font-black text-rose-600 uppercase tracking-widest ml-1">Kondisi Kesehatan</label> <label class="text-[10px] font-black text-rose-600 uppercase tracking-widest ml-1">Kondisi Kesehatan</label>
<textarea name="riwayat_sakit_terakhir" class="w-full mt-2 p-4 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 h-24 focus:ring-2 focus:ring-rose-200 transition-all">{{ old('riwayat_sakit_terakhir', $cat->riwayat_sakit_terakhir) }}</textarea> <textarea name="riwayat_sakit_terakhir" placeholder="Ceritakan kondisi kesehatan..." class="w-full mt-2 p-4 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-700 h-24 focus:ring-2 focus:ring-rose-200 transition-all">{{ old('riwayat_sakit_terakhir', $cat->riwayat_sakit_terakhir) }}</textarea>
</div> </div>
</div> </div>
</div> </div>
{{-- Action Button --}} {{-- Action Button --}}
<button type="submit" class="w-full py-6 bg-indigo-600 text-white text-xs font-black rounded-[2.5rem] shadow-2xl hover:bg-indigo-700 transition-all active:scale-[0.98] flex items-center justify-center gap-4 group"> <button type="submit" class="w-full py-6 bg-indigo-600 text-white text-xs font-black rounded-[2.5rem] shadow-2xl hover:bg-indigo-700 hover:shadow-indigo-200 transition-all active:scale-[0.98] flex items-center justify-center gap-4 group">
<i class="fas fa-cloud-upload-alt text-lg group-hover:animate-bounce transition-transform"></i> <i class="fas fa-save text-lg group-hover:rotate-12 transition-transform"></i>
<span class="tracking-[0.2em] uppercase">Simpan Perubahan Data Ilycats</span> <span class="tracking-[0.2em] uppercase">Simpan Perubahan Data Ilycats</span>
</button> </button>
</form> </form>
</div> </div>
<script> <script>
// Logika Sinkronisasi Searchable Datalist untuk Sire/Dam
function handleSearchableDatalist(inputId, listId, hiddenId) {
const input = document.getElementById(inputId);
const list = document.getElementById(listId);
const hidden = document.getElementById(hiddenId);
input.addEventListener('input', function() {
const options = list.options;
const val = input.value;
hidden.value = ""; // Reset hidden ID jika input tidak cocok dengan opsi apa pun
for (let i = 0; i < options.length; i++) {
if (options[i].value === val) {
hidden.value = options[i].getAttribute('data-id');
break;
}
}
});
}
handleSearchableDatalist('sire_search', 'sire_list', 'induk_jantan_id');
handleSearchableDatalist('dam_search', 'dam_list', 'induk_betina_id');
// Preview Foto saat dipilih
function previewImage(event) { function previewImage(event) {
const reader = new FileReader(); const reader = new FileReader();
const preview = document.getElementById('image-preview'); const preview = document.getElementById('image-preview');
+36 -170
View File
@@ -1,185 +1,51 @@
<x-app-layout> <x-app-layout>
<div class="space-y-6 pb-8"> @php
{{-- 1. STATS OVERVIEW --}} $currentSort=request('sort','created_at'); $currentDir=request('direction','desc'); $nextDir=$currentDir==='asc'?'desc':'asc';
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> $sortIcon=function($field)use($currentSort,$currentDir){return $currentSort!==$field?'fa-sort text-slate-300':($currentDir==='asc'?'fa-sort-up text-indigo-600':'fa-sort-down text-indigo-600');};
<x-stats-card title="Total Kucing" :value="$totalCats" icon="fa-cat" color="indigo" /> @endphp
<x-stats-card title="Kucing Sehat" :value="$totalCats - $sickCats" icon="fa-heart" color="emerald" /> <div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<x-stats-card title="Dalam Perawatan" :value="$sickCats" icon="fa-stethoscope" color="rose" /> <div><h1 class="text-2xl font-bold text-slate-900">Data kucing</h1><p class="mt-1 text-sm text-slate-500">Profil, silsilah, pedigree, berat badan, dan jadwal perawatan.</p></div>
<div class="flex flex-wrap gap-2">@can('check-access',['cats','update'])<a href="{{ route('cats.bulk-weight') }}" class="border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"><i class="fas fa-weight-scale mr-2"></i>Input berat massal</a>@endcan @can('check-access',['cats','create'])<a href="{{ route('cats.create') }}" class="bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700"><i class="fas fa-plus mr-2"></i>Tambah kucing</a>@endcan</div>
</div> </div>
{{-- 2. ACTION HEADER --}} <div class="mb-5 grid gap-3 sm:grid-cols-3">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div class="border border-slate-200 bg-white p-4"><p class="text-xs text-slate-500">Total kucing</p><p class="mt-1 text-2xl font-bold text-slate-900">{{ $totalCats }}</p></div>
<div> <div class="border border-slate-200 bg-white p-4"><p class="text-xs text-slate-500">Sehat</p><p class="mt-1 text-2xl font-bold text-emerald-700">{{ max(0,$totalCats-$sickCats) }}</p></div>
<h2 class="text-2xl font-black text-slate-800 tracking-tight leading-none mb-1 uppercase">Manajemen Kucing</h2> <div class="border border-slate-200 bg-white p-4"><p class="text-xs text-slate-500">Dalam perawatan</p><p class="mt-1 text-2xl font-bold text-rose-600">{{ $sickCats }}</p></div>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-[0.2em]">Cattery Management System • Ilycats</p>
</div> </div>
{{-- PROTEKSI TOMBOL CREATE --}} <section class="border border-slate-200 bg-white">
@can('check-access', ['cats', 'create']) <div class="flex flex-col gap-3 border-b border-slate-200 p-4 lg:flex-row lg:items-center lg:justify-between">
<div class="flex items-center gap-3"> <form method="GET" action="{{ route('cats.index') }}" class="flex w-full max-w-lg gap-2">
<a href="{{ route('cats.create') }}" class="inline-flex items-center px-6 py-3 bg-indigo-600 text-white text-xs font-black rounded-2xl shadow-lg shadow-indigo-100 hover:bg-indigo-700 transition-all active:scale-95"> <input type="hidden" name="status" value="{{ request('status') }}"><input type="hidden" name="sort" value="{{ request('sort') }}"><input type="hidden" name="direction" value="{{ request('direction') }}">
<i class="fas fa-plus-circle mr-2"></i> REGISTRASI KUCING BARU <div class="relative flex-1"><i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-sm text-slate-400"></i><input name="search" value="{{ request('search') }}" placeholder="Cari nama, EMS, microchip, ras, kandang..." class="field-input pl-9"></div><button class="border border-slate-300 px-4 text-sm font-semibold text-slate-700 hover:bg-slate-50">Cari</button>
</a>
</div>
@endcan
</div>
{{-- 3. TABLE CONTAINER --}}
<div class="bg-white border border-slate-200 rounded-[2.5rem] overflow-hidden shadow-sm">
{{-- SEARCH & QUICK FILTER --}}
<div class="p-6 bg-slate-50/50 border-b border-slate-100 flex flex-col md:flex-row justify-between gap-4">
<form action="{{ route('cats.index') }}" method="GET" class="relative w-full md:w-1/3">
<i class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
<input type="text" name="search" value="{{ request('search') }}" placeholder="Cari nama, ras, atau microchip..."
class="w-full pl-12 pr-4 py-3 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-indigo-500 transition-all shadow-sm">
</form> </form>
<div class="flex flex-wrap gap-1 text-sm">@foreach([''=>'Semua','Dewasa'=>'Dewasa','Junior'=>'Junior','Kitten'=>'Kitten'] as $value=>$label)<a href="{{ route('cats.index',array_merge(request()->except('page','status'),$value?['status'=>$value]:[])) }}" class="px-3 py-2 {{ request('status','')===$value?'bg-slate-900 text-white':'text-slate-600 hover:bg-slate-100' }}">{{ $label }}</a>@endforeach</div>
<div class="flex items-center p-1 bg-slate-100 rounded-xl shrink-0">
<a href="{{ route('cats.index') }}" class="px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest {{ !request('status') ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700' }}">Semua</a>
<a href="{{ route('cats.index', ['status' => 'Active']) }}" class="px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest {{ request('status') == 'Active' ? 'bg-white text-emerald-600 shadow-sm' : 'text-slate-500 hover:text-slate-700' }}">Dewasa</a>
<a href="{{ route('cats.index', ['status' => 'Kitten']) }}" class="px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest {{ request('status') == 'Kitten' ? 'bg-white text-amber-600 shadow-sm' : 'text-slate-500 hover:text-slate-700' }}">Kitten</a>
</div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full text-left border-collapse"> <table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100"> <thead class="bg-slate-50 text-left text-xs font-semibold text-slate-500"><tr>
<tr> <th class="px-4 py-3"><a class="flex items-center gap-2" href="{{ request()->fullUrlWithQuery(['sort'=>'nama_kucing','direction'=>$nextDir]) }}">Kucing <i class="fas {{ $sortIcon('nama_kucing') }}"></i></a></th>
<th class="px-6 py-5">Informasi Kucing</th> <th class="px-4 py-3">Identitas & pedigree</th><th class="px-4 py-3">Silsilah</th>
<th class="px-6 py-5">Usia & Silsilah</th> <th class="px-4 py-3"><a class="flex items-center gap-2" href="{{ request()->fullUrlWithQuery(['sort'=>'berat_badan','direction'=>$nextDir]) }}">Berat <i class="fas {{ $sortIcon('berat_badan') }}"></i></a></th>
<th class="px-6 py-5 text-center">Lokasi Kandang</th> <th class="px-4 py-3"><a class="flex items-center gap-2" href="{{ request()->fullUrlWithQuery(['sort'=>'lokasi_kandang','direction'=>$nextDir]) }}">Kandang <i class="fas {{ $sortIcon('lokasi_kandang') }}"></i></a></th>
<th class="px-6 py-5 text-center">Penanggung Jawab</th> <th class="px-4 py-3 text-right">Aksi</th>
<th class="px-6 py-5 text-center">Monitoring Berat</th> </tr></thead>
<th class="px-6 py-5 text-right">Aksi</th> <tbody class="divide-y divide-slate-100">
</tr>
</thead>
<tbody class="divide-y divide-slate-100 font-medium text-slate-700">
@forelse($cats as $cat) @forelse($cats as $cat)
<tr class="hover:bg-slate-50/80 transition-all group"> <tr class="hover:bg-slate-50">
<td class="px-6 py-4"> <td class="px-4 py-3"><div class="flex min-w-52 items-center gap-3"><img src="{{ $cat->foto_url }}" alt="{{ $cat->nama_kucing }}" class="h-11 w-11 rounded-lg border border-slate-200 object-cover"><div><a href="{{ route('cats.show',$cat) }}" class="font-semibold text-slate-900 hover:text-indigo-600">{{ $cat->nama_kucing }}</a><p class="mt-0.5 text-xs text-slate-500">{{ $cat->jenis_kelamin }} · {{ $cat->ras }}</p></div></div></td>
<div class="flex items-center gap-4"> <td class="px-4 py-3"><div class="space-y-1"><p class="text-xs text-slate-600"><span class="text-slate-400">EMS:</span> {{ $cat->kode_ems ?: '-' }}</p><p class="text-xs text-slate-600"><span class="text-slate-400">Microchip:</span> {{ $cat->microchip ?: '-' }}</p><div class="flex flex-wrap gap-1">@forelse($cat->registrations as $reg)<span class="border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[11px] font-semibold text-slate-600">{{ $reg->association->code }}{{ $reg->is_primary?' · utama':'' }}</span>@empty<span class="text-xs text-slate-400">Belum ada registrasi</span>@endforelse</div></div></td>
<div class="relative shrink-0"> <td class="px-4 py-3"><p class="text-xs text-slate-600">{{ $cat->age }}</p><p class="mt-1 text-xs text-slate-500">Jantan: {{ $cat->sire->nama_kucing ?? '-' }}</p><p class="text-xs text-slate-500">Betina: {{ $cat->dam->nama_kucing ?? '-' }}</p></td>
<div class="w-14 h-14 rounded-2xl bg-slate-100 overflow-hidden ring-4 ring-white shadow-sm transition-transform group-hover:scale-105"> <td class="px-4 py-3"><p class="font-semibold text-slate-800">{{ number_format((float)($cat->berat_badan ?? $cat->berat_terakhir ?? 0),2) }} kg</p><p class="text-xs {{ $cat->treatment_status==1?'text-rose-600':'text-emerald-600' }}">{{ $cat->treatment_status==1?'Dalam perawatan':'Sehat' }}</p></td>
<img src="{{ $cat->foto_url }}" class="w-full h-full object-cover"> <td class="px-4 py-3"><p class="text-slate-700">{{ $cat->lokasi_kandang ?: '-' }}</p><p class="text-xs text-slate-500">{{ $cat->pic->name ?? '-' }}</p></td>
</div> <td class="px-4 py-3"><div class="flex justify-end gap-1"><a href="{{ route('cats.show',$cat) }}" class="p-2 text-slate-500 hover:bg-slate-100" title="Lihat"><i class="fas fa-eye"></i></a><a href="{{ route('cats.pedigree',$cat) }}" class="p-2 text-slate-500 hover:bg-slate-100" title="Pedigree"><i class="fas fa-file-lines"></i></a>@can('check-access',['cats','update'])<a href="{{ route('cats.edit',$cat) }}" class="p-2 text-slate-500 hover:bg-slate-100" title="Ubah"><i class="fas fa-pen"></i></a>@endcan @can('check-access',['cats','delete'])<form id="delete-cat-{{ $cat->id }}" method="POST" action="{{ route('cats.destroy',$cat) }}">@csrf @method('DELETE')<button type="button" onclick="confirmDelete('delete-cat-{{ $cat->id }}','{{ addslashes($cat->nama_kucing) }}')" class="p-2 text-rose-500 hover:bg-rose-50" title="Hapus"><i class="fas fa-trash"></i></button></form>@endcan</div></td>
@if($cat->status === 'Kitten')
<div class="absolute -top-1 -right-1 w-4 h-4 bg-amber-500 border-2 border-white rounded-full shadow-sm animate-pulse" title="Kitten"></div>
@endif
</div>
<div>
<div class="flex items-center gap-2 mb-1">
<a href="{{ route('cats.show', $cat->id) }}" class="text-sm font-black text-slate-800 hover:text-indigo-600 transition-colors uppercase tracking-tight">{{ $cat->nama_kucing }}</a>
{!! $cat->gender_icon !!}
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-1.5">
<span class="px-2 py-0.5 bg-indigo-50 text-indigo-500 rounded text-[9px] font-black uppercase">{{ $cat->ras }}</span>
<span class="text-slate-300">•</span>
{!! $cat->registry_badges !!}
</div>
</div>
</div>
</div>
</td>
<td class="px-6 py-4">
<div class="flex flex-col">
<span class="text-[11px] font-black text-slate-700">{{ $cat->age }}</span>
<div class="flex items-center gap-1 mt-0.5">
<i class="fas fa-birthday-cake text-[8px] text-slate-300"></i>
<span class="text-[9px] text-slate-400 font-bold italic">{{ $cat->tanggal_lahir?->format('d M Y') ?? 'N/A' }}</span>
</div>
@if($cat->induk_betina_id)
<span class="text-[8px] text-rose-400 font-bold uppercase mt-1 tracking-tighter">Dam: {{ $cat->dam->nama_kucing }}</span>
@endif
</div>
</td>
<td class="px-6 py-4 text-center">
<div class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50/50 text-indigo-600 rounded-xl text-[10px] font-black border border-indigo-100 uppercase italic">
<i class="fas fa-door-closed text-[9px]"></i>
{{ $cat->lokasi_kandang ?? 'Global' }}
</div>
</td>
<td class="px-6 py-4 text-center">
<div class="flex flex-col items-center">
<span class="text-[10px] font-black text-slate-600 uppercase tracking-tighter">{{ $cat->pic->name ?? 'System' }}</span>
<span class="text-[8px] text-slate-400 font-bold uppercase tracking-widest">In Charge</span>
</div>
</td>
<td class="px-6 py-4 text-center">
@php $isOverdue = $cat->updated_at->diffInDays(now()) >= 3; @endphp
<div class="flex flex-col items-center">
<div class="flex items-baseline gap-0.5 {{ $isOverdue ? 'text-rose-600' : 'text-emerald-600' }}">
<span class="text-sm font-black">{{ number_format($cat->berat_badan, 2) }}</span>
<span class="text-[9px] font-bold">KG</span>
</div>
@if($isOverdue)
<div class="flex items-center gap-1 mt-1 text-rose-400 animate-pulse">
<i class="fas fa-exclamation-triangle text-[8px]"></i>
<span class="text-[7px] font-black uppercase tracking-widest">Update!</span>
</div>
@endif
</div>
</td>
<td class="px-6 py-4 text-right">
<div class="flex justify-end items-center gap-2">
{{-- PROTEKSI TOMBOL READ --}}
@can('check-access', ['cats', 'read'])
<a href="{{ route('cats.show', $cat->id) }}"
class="w-9 h-9 flex items-center justify-center bg-indigo-50 text-indigo-600 rounded-xl hover:bg-indigo-600 hover:text-white transition-all shadow-sm group-hover:shadow-indigo-100"
title="Detail Medis">
<i class="fas fa-heartbeat text-xs"></i>
</a>
@endcan
{{-- PROTEKSI TOMBOL EDIT/UPDATE --}}
@can('check-access', ['cats', 'update'])
<a href="{{ route('cats.edit', $cat->id) }}"
class="w-9 h-9 flex items-center justify-center bg-amber-50 text-amber-600 rounded-xl hover:bg-amber-600 hover:text-white transition-all shadow-sm"
title="Edit Profil">
<i class="fas fa-pencil-alt text-xs"></i>
</a>
@endcan
{{-- PROTEKSI TOMBOL DELETE --}}
@can('check-access', ['cats', 'delete'])
<button type="button"
onclick="confirmDelete('delete-form-{{ $cat->id }}', '{{ $cat->nama_kucing }}')"
class="w-9 h-9 flex items-center justify-center bg-rose-50 text-rose-600 rounded-xl hover:bg-rose-500 hover:text-white transition-all shadow-sm"
title="Hapus">
<i class="fas fa-trash text-xs"></i>
</button>
<form id="delete-form-{{ $cat->id }}" action="{{ route('cats.destroy', $cat->id) }}" method="POST" class="hidden">
@csrf @method('DELETE')
</form>
@endcan
</div>
</td>
</tr> </tr>
@empty @empty<tr><td colspan="6" class="px-4 py-12 text-center text-sm text-slate-500">Data kucing tidak ditemukan.</td></tr>@endforelse
<tr>
<td colspan="6" class="px-6 py-32 text-center bg-slate-50/30">
<div class="flex flex-col items-center">
<i class="fas fa-cat text-5xl text-slate-100 mb-4 animate-bounce"></i>
<h3 class="text-slate-400 font-black uppercase text-xs tracking-[0.4em]">Belum Ada Data Kucing</h3>
<p class="text-slate-300 text-[10px] font-bold mt-2 uppercase">Silakan klik tombol registrasi untuk memulai</p>
</div>
</td>
</tr>
@endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="border-t border-slate-200 p-4">{{ $cats->links() }}</div>
<div class="p-8 border-t border-slate-100 bg-slate-50/30"> </section>
{{ $cats->links() }}
</div>
</div>
</div>
</x-app-layout> </x-app-layout>
@@ -0,0 +1,335 @@
<x-app-layout>
<div class="max-w-[1600px] mx-auto space-y-8 pb-12">
{{-- Header Section --}}
<div class="flex items-center gap-4 px-2">
<a href="{{ route('cats.index') }}" class="p-3 bg-white border border-slate-200 rounded-2xl text-slate-400 hover:text-indigo-600 transition-all shadow-sm active:scale-90">
<i class="fas fa-arrow-left"></i>
</a>
<div>
<h2 class="text-3xl font-black text-slate-800 tracking-tight leading-none">Bulk Perawatan Manager</h2>
<p class="text-sm text-slate-500 font-medium italic uppercase tracking-widest mt-1">Manajemen Tindakan Medis Masal • Ily Cattery</p>
</div>
</div>
<form action="{{ route('medical-records.bulk.store') }}" method="POST">
@csrf
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8 px-2">
{{-- KIRI: Pemilihan Kucing --}}
<div class="lg:col-span-5 space-y-6">
<div x-data="catSelector()" class="bg-white border border-slate-200 rounded-[3rem] p-8 shadow-sm h-full flex flex-col relative overflow-hidden">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg">Pilih Kucing</h4>
<div x-show="selectedCount > 0" x-transition x-cloak class="flex items-center">
<span class="bg-indigo-100 text-indigo-700 text-[10px] font-black px-2.5 py-1 rounded-lg uppercase tracking-widest border border-indigo-200 shadow-sm">
<span x-text="selectedCount"></span> Terpilih
</span>
</div>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="select_all" x-model="selectAll" @change="toggleAll()" class="w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 cursor-pointer">
<label for="select_all" class="text-[10px] font-black text-slate-400 uppercase tracking-widest cursor-pointer hover:text-indigo-600 transition-colors">Pilih Semua</label>
</div>
</div>
{{-- Search Filter Kucing --}}
<div class="relative mb-6">
<i class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
<input type="text" x-model="search" @input="resetPage()" placeholder="Cari nama atau kode kucing..." class="w-full pl-11 pr-4 py-3.5 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold focus:ring-4 focus:ring-indigo-500/10 outline-none transition-all placeholder:text-slate-400">
</div>
{{-- List Kucing (Paginated) --}}
<div class="flex-1 overflow-y-auto custom-scrollbar pr-2 space-y-3" style="max-height: 450px;">
<template x-for="cat in paginatedCats" :key="cat.id">
<label class="flex items-center gap-4 p-4 rounded-3xl border transition-all cursor-pointer group"
:class="selectedCats.includes(cat.id.toString()) ? 'border-indigo-400 bg-indigo-50/80 ring-1 ring-indigo-400 shadow-md' : 'border-slate-100 bg-slate-50/50 hover:bg-indigo-50 hover:border-indigo-100'">
<input type="checkbox" name="cat_ids[]" :value="cat.id" x-model="selectedCats" @change="updateSelectAll()" class="cat-checkbox w-5 h-5 text-indigo-600 border-slate-300 rounded-lg focus:ring-indigo-500 transition-all cursor-pointer">
<div class="w-12 h-12 rounded-2xl overflow-hidden border-2 shadow-sm shrink-0" :class="selectedCats.includes(cat.id.toString()) ? 'border-indigo-200' : 'border-white'">
<img :src="cat.foto_url" class="w-full h-full object-cover">
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-black uppercase truncate leading-none mb-1 transition-colors" :class="selectedCats.includes(cat.id.toString()) ? 'text-indigo-900' : 'text-slate-800'" x-text="cat.nama_kucing"></p>
<span class="text-[8px] font-black text-slate-400 uppercase tracking-widest" x-text="`${cat.ras} • ${cat.lokasi_kandang || 'Global'}`"></span>
</div>
<div class="text-right">
<span class="px-2 py-1 bg-white border border-slate-100 rounded-lg text-[8px] font-black text-slate-400 uppercase" x-text="`#${cat.id}`"></span>
</div>
</label>
</template>
<div x-show="paginatedCats.length === 0" x-cloak class="py-10 text-center text-slate-400">
<i class="fas fa-folder-open text-3xl mb-3 opacity-50"></i>
<p class="text-xs font-bold uppercase tracking-widest">Tidak ada kucing ditemukan</p>
</div>
</div>
{{-- Pagination Controls --}}
<div class="mt-6 pt-4 border-t border-slate-100 flex items-center justify-between" x-show="totalPages > 1" x-cloak>
<button type="button" @click="prevPage()" :disabled="currentPage === 1" class="p-2 bg-slate-50 text-slate-400 rounded-xl hover:bg-indigo-50 hover:text-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all">
<i class="fas fa-chevron-left text-xs"></i>
</button>
<span class="text-[10px] font-black text-slate-400 uppercase tracking-widest">
Halaman <span class="text-indigo-600" x-text="currentPage"></span> dari <span x-text="totalPages"></span>
</span>
<button type="button" @click="nextPage()" :disabled="currentPage === totalPages" class="p-2 bg-slate-50 text-slate-400 rounded-xl hover:bg-indigo-50 hover:text-indigo-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all">
<i class="fas fa-chevron-right text-xs"></i>
</button>
</div>
</div>
</div>
{{-- KANAN: Konfigurasi Tindakan --}}
<div class="lg:col-span-7 space-y-6">
<div class="bg-indigo-600 rounded-[3rem] p-10 text-white shadow-xl shadow-indigo-100 relative overflow-hidden h-full flex flex-col justify-between">
<div class="relative z-10 space-y-8">
<div>
<h3 class="text-2xl font-black uppercase tracking-tight">Perawatan Configuration</h3>
<p class="text-indigo-100 text-xs font-bold italic tracking-tighter uppercase">Terapkan tindakan & potong stok otomatis</p>
</div>
<div class="space-y-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-200 uppercase ml-1 tracking-widest">Jenis Tindakan</label>
<select name="type" id="type_selector" onchange="adjustBulkForm()" required class="w-full px-5 py-4 bg-white/10 border border-white/20 rounded-2xl text-xs font-black text-white focus:ring-4 focus:ring-white/10 outline-none appearance-none cursor-pointer">
<option value="Vaccine" class="text-slate-800">Vaksinasi</option>
<option value="Deworming" class="text-slate-800">Obat Cacing</option>
<option value="Flea Perawatan" class="text-slate-800">Obat Kutu</option>
<option value="Grooming" class="text-slate-800">Grooming (Tanpa Obat)</option>
<option value="Medis Checkup" class="text-slate-800">Medis Checkup (Multi-Obat)</option>
</select>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-200 uppercase ml-1 tracking-widest">Tanggal Pemberian</label>
<input type="date" name="date_given" value="{{ date('Y-m-d') }}" required class="w-full px-5 py-4 bg-white/10 border border-white/20 rounded-2xl text-xs font-black text-white">
</div>
</div>
{{-- Container Obat Dinamis --}}
<div id="medicine_container" class="space-y-4">
<div class="medicine-row grid grid-cols-1 md:grid-cols-7 gap-4 p-6 bg-white/5 rounded-[2.5rem] border border-white/10 relative">
<div class="md:col-span-3 space-y-2">
<label class="text-[9px] font-black text-indigo-200 uppercase">Nama Produk</label>
{{-- PERBAIKAN: Tambahkan onchange event ke select --}}
<select name="medicines[0][name]" onchange="updateObatDetails(this)" required class="medicine-name-input w-full px-4 py-3 bg-white text-slate-800 border-none rounded-xl text-xs font-black cursor-pointer focus:ring-4 focus:ring-indigo-500/30 outline-none">
<option value="" disabled selected>-- Pilih Obat / Vitamin --</option>
@foreach($availableObats as $med)
<option value="{{ $med->nama_obat }}">{{ $med->nama_obat }} (Sisa: {{ (float)$med->qty }} {{ $med->unit }})</option>
@endforeach
</select>
</div>
<div class="md:col-span-2 space-y-2">
<label class="text-[9px] font-black text-indigo-200 uppercase">Dosis (Per Kucing)</label>
{{-- Tambahkan class 'dosage-input' --}}
<input type="text" name="medicines[0][dosage]" placeholder="Cth: 1 atau 1/2" required class="dosage-input w-full px-4 py-3 bg-white text-slate-800 border-none rounded-xl text-xs font-black">
</div>
<div class="md:col-span-2 space-y-2">
<label class="text-[9px] font-black text-indigo-200 uppercase">Satuan</label>
<input type="text" name="medicines[0][unit]" placeholder="-" readonly class="unit-display w-full px-4 py-3 bg-indigo-500/30 text-indigo-100 border border-white/10 rounded-xl text-[10px] font-black uppercase">
</div>
</div>
</div>
<button type="button" id="add_med_btn" onclick="addBulkObatRow()" class="hidden items-center gap-2 text-[10px] font-black text-white uppercase tracking-widest hover:text-indigo-200 transition-all cursor-pointer">
<i class="fas fa-plus-circle"></i> Tambah Obat Lain
</button>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-200 uppercase ml-1 tracking-widest">Rencana Selanjutnya</label>
<input type="date" name="next_schedule" class="w-full px-5 py-4 bg-white/10 border border-white/20 rounded-2xl text-xs font-black text-white">
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-indigo-200 uppercase ml-1 tracking-widest">Catatan (Notes)</label>
<textarea name="notes" placeholder="Berlaku untuk semua kucing yang dipilih..." class="w-full px-5 py-4 bg-white/10 border border-white/20 rounded-2xl text-xs font-black text-white h-14 outline-none focus:ring-4 focus:ring-white/10"></textarea>
</div>
</div>
</div>
</div>
<div class="relative z-10 mt-8">
<button type="submit" class="w-full py-6 bg-white text-indigo-600 font-black rounded-[2rem] text-xs uppercase tracking-[0.4em] shadow-2xl hover:scale-[1.02] transition-all active:scale-95">
APPLY TO SELECTED CATS
</button>
</div>
<i class="fas fa-heartbeat absolute -bottom-10 -left-10 text-[15rem] text-white/5"></i>
</div>
</div>
</div>
</form>
</div>
@push('scripts')
@php
// Mengolah data kucing agar aman untuk dibaca oleh AlpineJS
$catData = $cats->map(function($cat) {
return [
'id' => $cat->id,
'nama_kucing' => $cat->nama_kucing,
'ras' => $cat->ras,
'lokasi_kandang' => $cat->lokasi_kandang,
'foto_url' => $cat->foto_url
];
})->values()->toArray();
@endphp
<script>
// Data Global untuk Javascript
const allCatsData = @json($catData);
const availableMeds = @json($availableObats);
// ALPINE JS COMPONENT: Penanganan Pilih Kucing & Pagination
document.addEventListener('alpine:init', () => {
Alpine.data('catSelector', () => ({
search: '',
currentPage: 1,
itemsPerPage: 10,
selectedCats: [],
selectAll: false,
allCats: allCatsData,
get filteredCats() {
if (this.search === '') return this.allCats;
const term = this.search.toLowerCase();
return this.allCats.filter(cat =>
cat.nama_kucing.toLowerCase().includes(term) ||
(cat.lokasi_kandang && cat.lokasi_kandang.toLowerCase().includes(term))
);
},
get totalPages() { return Math.ceil(this.filteredCats.length / this.itemsPerPage) || 1; },
get paginatedCats() {
const start = (this.currentPage - 1) * this.itemsPerPage;
return this.filteredCats.slice(start, start + this.itemsPerPage);
},
get selectedCount() { return this.selectedCats.length; },
nextPage() { if (this.currentPage < this.totalPages) this.currentPage++; },
prevPage() { if (this.currentPage > 1) this.currentPage--; },
resetPage() {
this.currentPage = 1;
this.updateSelectAll();
},
toggleAll() {
if (this.selectAll) {
const currentVisibleIds = this.filteredCats.map(c => c.id.toString());
currentVisibleIds.forEach(id => {
if (!this.selectedCats.includes(id)) { this.selectedCats.push(id); }
});
} else {
const currentVisibleIds = this.filteredCats.map(c => c.id.toString());
this.selectedCats = this.selectedCats.filter(id => !currentVisibleIds.includes(id));
}
},
updateSelectAll() {
if (this.filteredCats.length === 0) {
this.selectAll = false;
return;
}
const currentVisibleIds = this.filteredCats.map(c => c.id.toString());
this.selectAll = currentVisibleIds.every(id => this.selectedCats.includes(id));
}
}));
});
// ---------------------------------------------------------
// LOGIKA FORM OBAT (AUTO-READ SATUAN DARI DATABASE)
// ---------------------------------------------------------
let medRowCount = 1;
// HTML untuk opsi select baru (Tambah Obat Lain)
const medOptionsHtml = `<option value="" disabled selected>-- Pilih Obat / Vitamin --</option>` +
availableMeds.map(med => `<option value="${med.nama_obat}">${med.nama_obat} (Sisa: ${parseFloat(med.qty)} ${med.unit})</option>`).join('');
// 1. FUNGSI MENARIK SATUAN OTOMATIS SAAT OBAT DIPILIH
function updateObatDetails(selectElement) {
const row = selectElement.closest('.medicine-row');
const unitInput = row.querySelector('.unit-display');
const selectedName = selectElement.value;
// Cari data obat dari array database
const medicine = availableMeds.find(m => m.nama_obat === selectedName);
if (medicine) {
// Tuliskan langsung satuannya (Misal: Botol, ML, PCS)
unitInput.value = medicine.unit;
} else {
unitInput.value = '-';
}
}
// 2. FUNGSI TAMPIL / SEMBUNYI FORM
function adjustBulkForm() {
const type = document.getElementById('type_selector').value;
const container = document.getElementById('medicine_container');
const addBtn = document.getElementById('add_med_btn');
const medInputs = document.querySelectorAll('.medicine-name-input');
container.classList.remove('hidden');
addBtn.classList.add('hidden');
if (type === 'Grooming') {
container.classList.add('hidden');
medInputs.forEach(i => i.required = false);
} else {
container.classList.remove('hidden');
medInputs.forEach(i => i.required = true);
if (type === 'Medis Checkup') {
addBtn.classList.remove('hidden');
}
}
}
function updateBulkUnits(val) {
document.querySelectorAll('.unit-display').forEach(u => u.value = val);
}
// 3. FUNGSI TAMBAH BARIS OBAT BARU
function addBulkObatRow() {
const container = document.getElementById('medicine_container');
const row = document.createElement('div');
row.className = 'medicine-row grid grid-cols-1 md:grid-cols-7 gap-4 p-6 bg-white/10 rounded-[2.5rem] border border-white/5 mt-4 relative';
row.innerHTML = `
<div class="md:col-span-3 space-y-2">
<select name="medicines[${medRowCount}][name]" onchange="updateObatDetails(this)" required class="medicine-name-input w-full px-4 py-3 bg-white text-slate-800 border-none rounded-xl text-xs font-black cursor-pointer focus:ring-4 focus:ring-indigo-500/30 outline-none">
${medOptionsHtml}
</select>
</div>
<div class="md:col-span-2 space-y-2">
<input type="text" name="medicines[${medRowCount}][dosage]" placeholder="Cth: 1 atau 1/2" required class="w-full px-4 py-3 bg-white text-slate-800 border-none rounded-xl text-xs font-black">
</div>
<div class="md:col-span-2 flex items-center gap-2">
<input type="text" name="medicines[${medRowCount}][unit]" placeholder="-" value="" readonly class="unit-display w-full px-4 py-3 bg-indigo-500/30 text-indigo-100 border border-white/10 rounded-xl text-[10px] font-black uppercase">
<button type="button" onclick="this.parentElement.parentElement.remove()" class="text-white hover:text-rose-200 transition-colors"><i class="fas fa-times-circle text-lg"></i></button>
</div>
`;
container.appendChild(row);
medRowCount++;
}
// Saat halaman pertama kali dimuat
document.addEventListener('DOMContentLoaded', () => {
adjustBulkForm();
// Kosongkan semua satuan default bawaan browser jika ada
document.querySelectorAll('.unit-display').forEach(u => u.value = '');
});
</script>
@endpush
</x-app-layout>
@@ -0,0 +1,152 @@
<x-app-layout>
<div class="max-w-3xl mx-auto py-12 px-4">
{{-- Header --}}
<div class="flex items-center gap-6 mb-10">
<a href="{{ route('cats.show', $cat->id) }}" class="p-4 bg-white border border-slate-200 rounded-2xl text-slate-400 hover:text-indigo-600 transition-all shadow-sm">
<i class="fas fa-arrow-left"></i>
</a>
<div>
<h2 class="text-2xl font-black text-slate-800 uppercase tracking-tight">Add Routine Ledger</h2>
<p class="text-sm text-slate-400 font-bold uppercase tracking-widest">Logging for: {{ $cat->nama_kucing }}</p>
</div>
</div>
{{-- Form Card --}}
<div class="bg-white rounded-[3rem] shadow-xl border border-slate-100 overflow-hidden">
<div class="p-10">
<form action="{{ route('medical-records.store') }}" method="POST" class="space-y-8">
@csrf
<input type="hidden" name="cat_id" value="{{ $cat->id }}">
{{-- Jenis Tindakan (Tanpa Pengobatan Umum) --}}
<div class="space-y-3">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-2">Jenis Tindakan</label>
<select name="type" id="type_selector" onchange="adjustMedisForm()" class="w-full px-6 py-5 bg-slate-50 border-none rounded-3xl text-sm font-black text-slate-800 focus:ring-4 focus:ring-indigo-500/10 transition-all cursor-pointer">
<option value="Vaccine">Vaksinasi</option>
<option value="Deworming">Obat Cacing</option>
<option value="Flea Perawatan">Obat Kutu</option>
<option value="Grooming">Grooming</option>
</select>
</div>
{{-- Obat Container --}}
<div id="medicine_container" class="space-y-4">
<div class="medicine-row p-6 bg-slate-50 rounded-[2.5rem] border border-slate-100 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 medicine-row-inner">
<div class="space-y-2">
<label class="text-[9px] font-black text-slate-400 uppercase ml-2">Produk/Obat</label>
{{-- oninput dan onchange digunakan agar auto-fill langsung jalan --}}
<input type="text" name="medicines[0][name]" id="med_name_input" list="medicine-list" required
oninput="autoFillObat(this)"
onchange="autoFillObat(this)"
class="medicine-name-input w-full px-5 py-4 border-none rounded-2xl text-xs font-black bg-white shadow-sm focus:ring-4 focus:ring-indigo-500/10" placeholder="Ketik atau pilih obat...">
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[9px] font-black text-slate-400 uppercase ml-2">Dosis</label>
<input type="text" name="medicines[0][dosage]" id="med_dosage_input" placeholder="e.g. 1/2 atau 1" required
class="dosage-input w-full px-5 py-4 border-none rounded-2xl text-xs font-black bg-white shadow-sm focus:ring-4 focus:ring-indigo-500/10">
</div>
<div class="space-y-2">
<label class="text-[9px] font-black text-slate-400 uppercase ml-2">Satuan</label>
<input type="text" name="medicines[0][unit]" id="med_unit_input" readonly
class="unit-display w-full px-5 py-4 bg-slate-100 border-none rounded-2xl text-[10px] font-black text-slate-400 cursor-not-allowed outline-none">
</div>
</div>
</div>
</div>
</div>
{{-- Tanggal --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div class="space-y-3">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-2">Tanggal Tindakan</label>
<input type="date" name="date_given" value="{{ date('Y-m-d') }}" required class="w-full px-6 py-5 bg-slate-50 border-none rounded-3xl text-sm font-black focus:ring-4 focus:ring-indigo-500/10">
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-2">Jadwal Berikutnya</label>
<input type="date" name="next_schedule" class="w-full px-6 py-5 bg-slate-50 border-none rounded-3xl text-sm font-black focus:ring-4 focus:ring-indigo-500/10">
</div>
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-2">Notes</label>
<textarea name="notes" placeholder="Catatan opsional..." class="w-full px-6 py-5 bg-slate-50 border-none rounded-3xl text-sm font-bold h-32 focus:ring-4 focus:ring-indigo-500/10"></textarea>
</div>
<button type="submit" class="w-full py-6 bg-indigo-600 text-white rounded-[2rem] font-black uppercase text-xs tracking-[0.3em] shadow-2xl shadow-indigo-200 hover:bg-indigo-700 transition-all transform active:scale-95">
Confirm & Log Routine Ledger
</button>
</form>
</div>
</div>
</div>
{{-- Datalist dibersihkan agar hanya muncul Nama Obat --}}
<datalist id="medicine-list">
@foreach($availableObats as $med)
<option value="{{ $med->nama_obat }}"></option>
@endforeach
</datalist>
@push('scripts')
<script>
// 1. Tarik Data Master Obat dari Database ke JavaScript
const masterObatData = {!! json_encode($availableObats) !!};
// 2. Tampilkan/Sembunyikan Form Obat berdasarkan "Jenis Tindakan"
function adjustMedisForm() {
const type = document.getElementById('type_selector').value;
const container = document.getElementById('medicine_container');
const nameInput = document.getElementById('med_name_input');
const dosageInput = document.getElementById('med_dosage_input');
const unitInput = document.getElementById('med_unit_input');
if (type === 'Grooming') {
// Grooming tidak butuh obat
container.style.display = 'none';
nameInput.required = false;
dosageInput.required = false;
} else {
container.style.display = 'block';
nameInput.required = true;
dosageInput.required = true;
// Jika nama obat masih kosong, set satuan default berdasarkan jenis tindakan
const fallbackUnit = { 'Vaccine': 'PCS', 'Deworming': 'TABLET', 'Flea Perawatan': 'ML' };
if (nameInput.value === '') {
unitInput.value = fallbackUnit[type] || 'PCS';
}
}
}
// 3. Fungsi Cerdas: Auto-Fill Dosis dan Satuan
function autoFillObat(inputElement) {
const row = inputElement.closest('.medicine-row-inner');
const unitInput = row.querySelector('.unit-display');
const dosageInput = row.querySelector('.dosage-input');
// Cari kecocokan nama obat dengan data master (Tidak sensitif huruf besar/kecil)
const selectedObat = masterObatData.find(med => med.nama_obat.toLowerCase() === inputElement.value.toLowerCase());
if (selectedObat) {
// Jika obat terdaftar di database, ambil satuannya!
unitInput.value = selectedObat.unit;
// Beri nilai otomatis "1" pada dosis jika masih kosong untuk mempercepat input
if (!dosageInput.value) {
dosageInput.value = '1';
}
} else {
// Jika obat diketik manual (tidak ada di database), kembalikan ke satuan default
const type = document.getElementById('type_selector').value;
const fallbackUnit = { 'Vaccine': 'PCS', 'Deworming': 'TABLET', 'Flea Perawatan': 'ML' };
unitInput.value = fallbackUnit[type] || 'PCS';
}
}
// Jalankan saat halaman pertama kali dimuat
document.addEventListener('DOMContentLoaded', adjustMedisForm);
</script>
@endpush
</x-app-layout>
@@ -139,11 +139,11 @@
{{-- FIX: Base64 SVG yang sudah Anda buat di Controller --}} {{-- FIX: Base64 SVG yang sudah Anda buat di Controller --}}
<img src="data:image/svg+xml;base64,{{ $qrCode }}" width="90"> <img src="data:image/svg+xml;base64,{{ $qrCode }}" width="90">
</div> </div>
<div class="qr-text">Scan Medical History</div> <div class="qr-text">Scan Medis History</div>
</div> </div>
<div class="footer"> <div class="footer">
Generated by ILY Cattery Management System<br> Generated by ILY Cattery Management Sistem<br>
{{ now()->format('d M Y H:i') }} {{ now()->format('d M Y H:i') }}
</div> </div>
</div> </div>
@@ -0,0 +1,51 @@
<x-app-layout>
<div class="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div><a href="{{ route('cats.show',$cat) }}" class="mb-2 inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-800"><i class="fas fa-arrow-left"></i>Kembali ke profil</a><h1 class="text-2xl font-bold text-slate-900">Registrasi & pedigree</h1><p class="mt-1 text-sm text-slate-500">{{ $cat->nama_kucing }} dapat memiliki registrasi dan sertifikat dari lebih dari satu asosiasi.</p></div>
</div>
<div class="grid gap-6 xl:grid-cols-[1fr_360px]">
<div class="space-y-4">
@forelse($cat->registrations as $registration)
<section class="border border-slate-200 bg-white">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-slate-200 px-5 py-4">
<div class="flex items-center gap-3"><div class="flex h-10 min-w-10 items-center justify-center bg-slate-100 px-2 text-sm font-bold text-slate-700">{{ $registration->association->code }}</div><div><h2 class="font-semibold text-slate-900">{{ $registration->association->name }}</h2><p class="text-xs text-slate-500">{{ $registration->is_primary ? 'Pedigree utama' : 'Registrasi tambahan' }}</p></div></div>
<form action="{{ route('cats.registrations.destroy',[$cat,$registration]) }}" method="POST" onsubmit="return confirm('Hapus registrasi ini beserta dokumennya?')">@csrf @method('DELETE')<button class="text-sm font-medium text-rose-600 hover:text-rose-700">Hapus</button></form>
</div>
<form action="{{ route('cats.registrations.update',[$cat,$registration]) }}" method="POST" class="grid gap-4 p-5 sm:grid-cols-2 lg:grid-cols-3">@csrf @method('PUT')
<div><label class="field-label">Nomor registrasi</label><input name="registration_number" value="{{ $registration->registration_number }}" class="field-input"></div>
<div><label class="field-label">Nomor pedigree</label><input name="pedigree_number" value="{{ $registration->pedigree_number }}" class="field-input"></div>
<div><label class="field-label">Nomor sertifikat</label><input name="certificate_number" value="{{ $registration->certificate_number }}" class="field-input"></div>
<div><label class="field-label">Tanggal terbit</label><input type="date" name="issue_date" value="{{ $registration->issue_date?->format('Y-m-d') }}" class="field-input"></div>
<div><label class="field-label">Gelar / title</label><input name="title" value="{{ $registration->title }}" class="field-input" placeholder="Contoh: CH, GCH"></div>
<div><label class="field-label">Status kepemilikan</label><select name="ownership_status" class="field-input"><option value="">-</option>@foreach(['milik_sendiri'=>'Milik sendiri','co_own'=>'Co-own','transfer'=>'Transfer','lainnya'=>'Lainnya'] as $v=>$l)<option value="{{ $v }}" @selected($registration->ownership_status===$v)>{{ $l }}</option>@endforeach</select></div>
<div class="sm:col-span-2 lg:col-span-3"><label class="field-label">Catatan</label><textarea name="notes" rows="2" class="field-input">{{ $registration->notes }}</textarea></div>
<label class="flex items-center gap-2 text-sm text-slate-700"><input type="hidden" name="is_primary" value="0"><input type="checkbox" name="is_primary" value="1" @checked($registration->is_primary) class="rounded border-slate-300 text-indigo-600">Jadikan pedigree utama</label>
<div class="sm:col-span-2 flex justify-end"><button class="border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50">Simpan perubahan</button></div>
</form>
<div class="border-t border-slate-200 p-5">
<div class="mb-3 flex items-center justify-between"><h3 class="text-sm font-semibold text-slate-800">Dokumen</h3><span class="text-xs text-slate-500">PDF/JPG/PNG, maks. 10 MB</span></div>
<div class="mb-4 grid gap-2">@forelse($registration->documents as $document)<div class="flex items-center justify-between gap-3 border border-slate-200 p-3"><div class="min-w-0"><a href="{{ Storage::url($document->file_path) }}" target="_blank" class="truncate text-sm font-medium text-indigo-600 hover:underline">{{ $document->label }}</a><p class="text-xs text-slate-500">{{ ucfirst($document->document_type) }}{{ $document->issued_at ? ' · '.$document->issued_at->format('d-m-Y') : '' }}</p></div><form action="{{ route('cats.registrations.documents.destroy',[$cat,$registration,$document]) }}" method="POST">@csrf @method('DELETE')<button class="text-xs font-medium text-rose-600">Hapus</button></form></div>@empty<p class="text-sm text-slate-500">Belum ada dokumen pada registrasi ini.</p>@endforelse</div>
<form action="{{ route('cats.registrations.documents.store',[$cat,$registration]) }}" method="POST" enctype="multipart/form-data" class="grid gap-3 md:grid-cols-[1fr_180px_180px_auto]">@csrf<input type="file" name="document" required class="field-input py-2"><select name="document_type" class="field-input"><option value="pedigree">Pedigree</option><option value="registration">Registrasi</option><option value="ownership">Kepemilikan</option><option value="title">Gelar</option><option value="other">Lainnya</option></select><input name="label" class="field-input" placeholder="Nama dokumen"><button class="bg-slate-800 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-900">Tambah dokumen</button></form>
</div>
</section>
@empty
<div class="border border-dashed border-slate-300 bg-white p-10 text-center"><i class="fas fa-file-lines mb-3 text-3xl text-slate-300"></i><p class="font-medium text-slate-700">Belum ada registrasi pedigree</p><p class="mt-1 text-sm text-slate-500">Tambahkan asosiasi dari formulir di sebelah kanan.</p></div>
@endforelse
</div>
<aside class="h-fit border border-slate-200 bg-white p-5" x-data="{custom:false}">
<h2 class="font-semibold text-slate-900">Tambah registrasi</h2><p class="mt-1 text-sm text-slate-500">Satu asosiasi dapat ditambahkan satu kali untuk setiap kucing.</p>
<form action="{{ route('cats.registrations.store',$cat) }}" method="POST" enctype="multipart/form-data" class="mt-5 space-y-4">@csrf
<div><label class="field-label">Asosiasi</label><select name="association_id" required class="field-input" @change="custom=$event.target.value==='custom'"><option value="">Pilih asosiasi</option>@foreach($associations as $association)<option value="{{ $association->id }}">{{ $association->code }} - {{ $association->name }}</option>@endforeach<option value="custom">Asosiasi lain…</option></select></div>
<template x-if="custom"><div class="space-y-3"><div><label class="field-label">Kode asosiasi</label><input name="association_code" class="field-input" placeholder="Contoh: TICA"></div><div><label class="field-label">Nama asosiasi</label><input name="association_name" class="field-input"></div></div></template>
<div><label class="field-label">Nomor registrasi</label><input name="registration_number" class="field-input"></div>
<div><label class="field-label">Nomor pedigree</label><input name="pedigree_number" class="field-input"></div>
<div><label class="field-label">Nomor sertifikat</label><input name="certificate_number" class="field-input"></div>
<div class="grid grid-cols-2 gap-3"><div><label class="field-label">Tanggal terbit</label><input type="date" name="issue_date" class="field-input"></div><div><label class="field-label">Gelar</label><input name="title" class="field-input"></div></div>
<div><label class="field-label">File sertifikat</label><input type="file" name="certificate_file" accept=".pdf,.jpg,.jpeg,.png" class="field-input py-2"></div>
<label class="flex items-center gap-2 text-sm text-slate-700"><input type="hidden" name="is_primary" value="0"><input type="checkbox" name="is_primary" value="1" class="rounded border-slate-300 text-indigo-600">Jadikan pedigree utama</label>
<button class="w-full bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700">Simpan registrasi</button>
</form>
</aside>
</div>
</x-app-layout>
+215 -165
View File
@@ -2,34 +2,110 @@
@push('styles') @push('styles')
<style> <style>
canvas { max-width: 100% !important; } canvas { max-width: 100% !important; }
.custom-scrollbar::-webkit-scrollbar { width: 4px; } .custom-scrollbar::-webkit-scrollbar { width: 5px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; } .custom-scrollbar::-webkit-scrollbar-track { background: #f1f5f9; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; } .custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
.animate-modal-enter { animation: modalFadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
@keyframes modalFadeIn {
from { opacity: 0; transform: scale(0.95) translateY(20px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
</style> </style>
@endpush @endpush
@php
// Mengambil daftar penyakit yang MASIH AKTIF untuk dropdown
$activePerawatans = $cat->treatments->where('status_perawatan', 1);
@endphp
<div class="max-w-[1600px] mx-auto space-y-8 pb-12"> <div class="max-w-[1600px] mx-auto space-y-8 pb-12">
{{-- 1. Header Section --}} {{-- 1. Header Section --}}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 px-2">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a href="{{ route('cats.index') }}" class="p-3 bg-white border border-slate-200 rounded-2xl text-slate-400 hover:text-indigo-600 transition-all shadow-sm active:scale-90"> <a href="{{ route('cats.index') }}" class="p-3 bg-white border border-slate-200 rounded-2xl text-slate-400 hover:text-indigo-600 transition-all shadow-sm active:scale-90">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
</a> </a>
<div> <div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<h2 class="text-3xl font-black text-slate-800 tracking-tight leading-none">{{ $cat->nama_kucing }}</h2> <h2 class="text-3xl font-black text-slate-800 tracking-tight leading-none uppercase">{{ $cat->nama_kucing }}</h2>
{!! $cat->gender_icon !!} {!! $cat->gender_icon !!}
</div> </div>
<p class="text-sm text-slate-500 font-medium italic uppercase tracking-widest mt-1">Medical Record #FE-{{ str_pad($cat->id, 5, '0', STR_PAD_LEFT) }}</p> <p class="text-sm text-slate-500 font-medium italic uppercase tracking-widest mt-1">Medis Record #FE-{{ str_pad($cat->id, 5, '0', STR_PAD_LEFT) }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<a href="{{ route('cats.pdf', $cat->id) }}" target="_blank" class="inline-flex items-center px-6 py-3.5 bg-rose-600 text-white text-[10px] font-black rounded-2xl hover:bg-rose-700 transition-all shadow-lg shadow-rose-100 active:scale-95"> <a href="{{ route('medical-records.bulk.create') }}" class="inline-flex items-center px-6 py-3.5 bg-indigo-50 text-indigo-600 border border-indigo-100 text-[10px] font-black rounded-2xl hover:bg-indigo-600 hover:text-white transition-all shadow-sm active:scale-95">
<i class="fas fa-file-pdf mr-2 text-sm"></i> CETAK MEDICAL PASS <i class="fas fa-layer-group mr-2 text-sm"></i> BULK TREATMENT
</a>
<a href="{{ route('cats.medical-report', $cat->id) }}" class="inline-flex items-center px-6 py-3.5 bg-rose-600 text-white text-[10px] font-black rounded-2xl hover:bg-rose-700 transition-all shadow-lg shadow-rose-100 active:scale-95">
<i class="fas fa-file-medical mr-2 text-sm"></i> UNDUH HEALTH LEDGER
</a> </a>
{{-- PROTEKSI TOMBOL UPDATE: Hanya muncul jika memiliki izin Update ATAU Delete --}} {{-- ========================================== --}}
{{-- TOMBOL & MODAL HANYA MUNCUL JIKA ADA PENYAKIT AKTIF --}}
{{-- ========================================== --}}
@if($activePerawatans->count() > 0)
<div x-data="{ openModal: false }" class="inline-block">
<button @click="openModal = true" type="button"
class="inline-flex items-center px-6 py-3.5 bg-emerald-600 text-white text-[10px] font-black rounded-2xl hover:bg-emerald-700 transition-all shadow-lg shadow-emerald-100 active:scale-95 animate-pulse">
<i class="fas fa-notes-medical mr-2 text-sm"></i> RESOLVE CLINICAL CASE
</button>
{{-- Modal Backdrop --}}
<div x-show="openModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/60 backdrop-blur-sm flex items-center justify-center p-4"
x-cloak>
{{-- Modal Content --}}
<div @click.away="openModal = false" class="bg-white rounded-[2.5rem] border border-slate-100 shadow-2xl max-w-md w-full p-8 transform transition-all space-y-6">
<div class="flex justify-between items-start">
<div>
<h3 class="text-lg font-black text-slate-800 uppercase tracking-tight">Resolusi Penyakit</h3>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-wider mt-1">Pilih kasus yang sudah sembuh</p>
</div>
<button @click="openModal = false" type="button" class="text-slate-400 hover:text-rose-600 transition-colors">
<i class="fas fa-times-circle text-xl"></i>
</button>
</div>
<form action="{{ route('cats.complete-treatment', $cat->id) }}" method="POST" class="space-y-4">
@csrf
@method('PATCH')
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Pilih Keluhan / Penyakit</label>
<select name="treatment_id" required class="w-full bg-slate-50 border-none rounded-2xl text-xs font-bold p-4 text-slate-700 focus:ring-4 focus:ring-indigo-500/20 outline-none">
<option value="" disabled selected>-- Pilih Penyakit yang Sembuh --</option>
@foreach($activePerawatans as $tr)
<option value="{{ $tr->id }}">{{ $tr->keluhan }} (Tanggal: {{ $tr->tanggal->format('d/m/Y') }})</option>
@endforeach
</select>
</div>
<div class="space-y-2">
<label class="block text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Kondisi Pasca Sakit / Remark</label>
<textarea name="remark_pasca_sakit" rows="4" required placeholder="Catat perkembangan... (Contoh: Jamur sembuh total)" class="w-full bg-slate-50 border-none rounded-2xl text-xs font-bold p-4 text-slate-700 focus:ring-4 focus:ring-indigo-500/20 outline-none"></textarea>
</div>
<div class="grid grid-cols-2 gap-3 pt-2">
<button @click="openModal = false" type="button" class="w-full bg-slate-100 text-slate-500 py-3.5 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-slate-200 transition-all">Batal</button>
<button type="submit" class="w-full bg-emerald-600 text-white py-3.5 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-emerald-700 shadow-lg shadow-emerald-100 transition-all">Simpan Resolusi</button>
</div>
</form>
</div>
</div>
</div>
@endif
{{-- ========================================== --}}
@if(Gate::allows('check-access', ['cats', 'update']) || Gate::allows('check-access', ['cats', 'delete'])) @if(Gate::allows('check-access', ['cats', 'update']) || Gate::allows('check-access', ['cats', 'delete']))
<a href="{{ route('cats.edit', $cat->id) }}" class="inline-flex items-center px-6 py-3.5 bg-white border border-slate-200 text-slate-600 text-[10px] font-black rounded-2xl hover:bg-slate-50 transition-all shadow-sm active:scale-95"> <a href="{{ route('cats.edit', $cat->id) }}" class="inline-flex items-center px-6 py-3.5 bg-white border border-slate-200 text-slate-600 text-[10px] font-black rounded-2xl hover:bg-slate-50 transition-all shadow-sm active:scale-95">
<i class="fas fa-edit mr-2 text-indigo-500 text-sm"></i> UPDATE DATA <i class="fas fa-edit mr-2 text-indigo-500 text-sm"></i> UPDATE DATA
@@ -38,26 +114,19 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-8 px-2">
{{-- Column 1: Profile & Core Identity --}} {{-- Column 1: Profile & Core Identity --}}
<div class="space-y-6"> <div class="space-y-6">
<div class="bg-white border border-slate-200 rounded-[3rem] p-8 shadow-sm text-center relative overflow-hidden group"> <div class="bg-white border border-slate-200 rounded-[3rem] p-8 shadow-sm text-center relative overflow-hidden group">
<div class="absolute top-0 left-0 p-6"> <div class="absolute top-0 left-0 p-6 z-10">{!! $cat->status_badge !!}</div>
{!! $cat->status_badge !!}
</div>
<div class="w-48 h-48 mx-auto rounded-[3rem] bg-slate-50 overflow-hidden ring-8 ring-slate-50 shadow-xl mb-6 transition-transform group-hover:scale-105 duration-500"> <div class="w-48 h-48 mx-auto rounded-[3rem] bg-slate-50 overflow-hidden ring-8 ring-slate-50 shadow-xl mb-6 transition-transform group-hover:scale-105 duration-500">
<img src="{{ $cat->foto_url }}" class="w-full h-full object-cover"> <img src="{{ $cat->foto_url }}" class="w-full h-full object-cover">
</div> </div>
<h3 class="text-2xl font-black text-slate-800 mb-6 tracking-tighter uppercase">{{ $cat->nama_kucing }}</h3> <h3 class="text-2xl font-black text-slate-800 mb-6 tracking-tighter uppercase">{{ $cat->nama_kucing }}</h3>
<div class="grid grid-cols-2 gap-4 text-left border-t border-slate-100 pt-6"> <div class="grid grid-cols-2 gap-4 text-left border-t border-slate-100 pt-6">
<div class="bg-slate-50/50 p-4 rounded-2xl border border-slate-100"> <div class="bg-slate-50/50 p-4 rounded-2xl border border-slate-100">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-2">Registry</p> <p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-2">Registry</p>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">{!! $cat->registry_badges !!}</div>
{!! $cat->registry_badges !!}
</div>
</div> </div>
<div class="bg-slate-50/50 p-4 rounded-2xl border border-slate-100 text-center"> <div class="bg-slate-50/50 p-4 rounded-2xl border border-slate-100 text-center">
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">Umur Saat Ini</p> <p class="text-[9px] font-black text-slate-400 uppercase tracking-widest mb-1">Umur Saat Ini</p>
@@ -77,25 +146,22 @@
<span class="text-xs font-bold text-slate-400 uppercase">Pedigree No.</span> <span class="text-xs font-bold text-slate-400 uppercase">Pedigree No.</span>
<span class="text-xs font-black text-slate-700">{{ $cat->pedigree ?? '-' }}</span> <span class="text-xs font-black text-slate-700">{{ $cat->pedigree ?? '-' }}</span>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center pt-3 border-t border-slate-50 mt-1">
<span class="text-xs font-bold text-slate-400 uppercase">Lokasi Kandang</span> <span class="text-xs font-bold text-slate-400 uppercase">Sertifikat Ped</span>
<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[10px] font-black uppercase tracking-widest">{{ $cat->lokasi_kandang ?? 'Global' }}</span> <div class="flex items-center gap-2">
@if($cat->sertifikat_ped_file)
<span class="text-[9px] font-black text-emerald-600 bg-emerald-50 px-2 py-1 rounded-md uppercase"><i class="fas fa-check-circle mr-1"></i> Tersedia</span>
@else
<span class="text-[9px] font-black text-rose-500 bg-rose-50 px-2 py-1 rounded-md uppercase"><i class="fas fa-times-circle mr-1"></i> Kosong</span>
@endif
<a href="{{ route('cats.pedigree', $cat->id) }}" class="flex items-center justify-center px-4 py-2.5 bg-indigo-50 text-indigo-600 rounded-xl hover:bg-indigo-600 hover:text-white transition-all font-black text-[10px] uppercase tracking-widest shadow-sm">
<i class="fas fa-folder-open mr-2"></i> Buka
</a>
</div>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-xs font-bold text-slate-400 uppercase">PIC Bertugas</span> <span class="text-xs font-bold text-slate-400 uppercase">PIC Bertugas</span>
<span class="text-xs font-black text-slate-700">{{ $cat->pic?->name ?? 'System' }}</span> <span class="text-xs font-black text-slate-700">{{ $cat->pic?->name ?? 'Sistem' }}</span>
</div>
</div>
@php $isOverdue = $cat->updated_at->diffInDays(now()) >= 3; @endphp
<div class="mt-6 p-5 {{ $isOverdue ? 'bg-rose-50 border-rose-100' : 'bg-emerald-50 border-emerald-100' }} border rounded-3xl">
<div class="flex justify-between items-center mb-1">
<span class="text-[9px] font-black uppercase {{ $isOverdue ? 'text-rose-600' : 'text-emerald-600' }}">Monitoring Berat</span>
<span class="text-[9px] font-bold text-slate-400 uppercase italic">{{ $cat->updated_at->diffForHumans() }}</span>
</div>
<div class="flex items-baseline gap-1">
<span class="text-2xl font-black text-slate-800">{{ number_format($cat->berat_badan, 2) }}</span>
<span class="text-xs font-black text-slate-400 uppercase">Kg</span>
</div> </div>
</div> </div>
</div> </div>
@@ -104,33 +170,29 @@
{{-- Column 2: Lineage & Health Routine --}} {{-- Column 2: Lineage & Health Routine --}}
<div class="space-y-8"> <div class="space-y-8">
<div class="bg-white border border-slate-200 rounded-[3rem] p-8 shadow-sm"> <div class="bg-white border border-slate-200 rounded-[3rem] p-8 shadow-sm">
<h4 class="text-xs font-black text-slate-800 uppercase tracking-widest border-b border-slate-100 pb-4 mb-6">Parental Intelligence</h4> <h4 class="text-xs font-black text-slate-800 uppercase tracking-widest border-b border-slate-100 pb-4 mb-6">Data Induk</h4>
<div class="space-y-4"> <div class="space-y-4">
{{-- SIRE --}} <div @if($cat->sire_id) onclick="window.location.href='{{ route('cats.show', $cat->sire_id) }}'" @endif
<div class="p-4 bg-indigo-50/30 border border-indigo-100 rounded-3xl flex items-center gap-4 hover:bg-indigo-50 transition-colors cursor-default"> class="p-4 bg-indigo-50/30 border border-indigo-100 rounded-3xl flex items-center gap-4 hover:bg-indigo-50 transition-colors {{ $cat->sire_id ? 'cursor-pointer' : 'cursor-default' }}">
<div class="w-12 h-12 bg-white border border-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center shadow-sm"> <div class="w-12 h-12 bg-white border border-indigo-100 text-indigo-600 rounded-2xl flex items-center justify-center shadow-sm"><i class="fas fa-mars text-lg"></i></div>
<i class="fas fa-mars text-lg"></i>
</div>
<div class="flex-1"> <div class="flex-1">
<p class="text-[9px] font-black text-indigo-400 uppercase tracking-widest">Sire (Pejantan)</p> <p class="text-[9px] font-black text-indigo-400 uppercase tracking-widest">Sire (Pejantan)</p>
<p class="text-sm font-black text-slate-700 uppercase leading-none mt-1">{{ $cat->sire->nama_kucing }}</p> <p class="text-sm font-black text-slate-700 uppercase leading-none mt-1">{{ $cat->sire->nama_kucing ?? 'Unknown' }}</p>
</div> </div>
</div> </div>
{{-- DAM --}} <div @if($cat->dam_id) onclick="window.location.href='{{ route('cats.show', $cat->dam_id) }}'" @endif
<div class="p-4 bg-rose-50/30 border border-rose-100 rounded-3xl flex items-center gap-4 hover:bg-rose-50 transition-colors cursor-default"> class="p-4 bg-rose-50/30 border border-rose-100 rounded-3xl flex items-center gap-4 hover:bg-rose-50 transition-colors {{ $cat->dam_id ? 'cursor-pointer' : 'cursor-default' }}">
<div class="w-12 h-12 bg-white border border-rose-100 text-rose-600 rounded-2xl flex items-center justify-center shadow-sm"> <div class="w-12 h-12 bg-white border border-rose-100 text-rose-600 rounded-2xl flex items-center justify-center shadow-sm"><i class="fas fa-venus text-lg"></i></div>
<i class="fas fa-venus text-lg"></i>
</div>
<div class="flex-1"> <div class="flex-1">
<p class="text-[9px] font-black text-rose-400 uppercase tracking-widest">Dam (Betina)</p> <p class="text-[9px] font-black text-rose-400 uppercase tracking-widest">Dam (Betina)</p>
<p class="text-sm font-black text-slate-700 uppercase leading-none mt-1">{{ $cat->dam->nama_kucing }}</p> <p class="text-sm font-black text-slate-700 uppercase leading-none mt-1">{{ $cat->dam->nama_kucing ?? 'Unknown' }}</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="bg-slate-900 rounded-[3rem] p-8 text-white shadow-2xl relative overflow-hidden"> <div class="bg-slate-900 rounded-[3rem] p-8 text-white shadow-2xl relative overflow-hidden">
<h4 class="text-xs font-black uppercase tracking-widest border-b border-white/10 pb-4 mb-6 italic">Routine Medical Calendar</h4> <h4 class="text-xs font-black uppercase tracking-widest border-b border-white/10 pb-4 mb-6 italic">Current Health Reminders</h4>
<div class="grid grid-cols-1 gap-4 relative z-10"> <div class="grid grid-cols-1 gap-4 relative z-10">
@foreach([ @foreach([
['icon' => 'fa-syringe', 'label' => 'Vaksin', 'field' => 'jadwal_vaksin'], ['icon' => 'fa-syringe', 'label' => 'Vaksin', 'field' => 'jadwal_vaksin'],
@@ -161,121 +223,143 @@
<h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg leading-none">Analisis Berat</h4> <h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg leading-none">Analisis Berat</h4>
<p class="text-[10px] text-slate-400 font-black uppercase mt-1">Growth Index (Kg)</p> <p class="text-[10px] text-slate-400 font-black uppercase mt-1">Growth Index (Kg)</p>
</div> </div>
<div class="w-12 h-12 bg-emerald-50 text-emerald-500 rounded-2xl flex items-center justify-center"> <div class="w-12 h-12 bg-emerald-50 text-emerald-500 rounded-2xl flex items-center justify-center"><i class="fas fa-chart-line text-lg"></i></div>
<i class="fas fa-chart-line text-lg"></i>
</div>
</div>
<div class="relative h-[250px]">
<canvas id="weightChart"></canvas>
</div> </div>
<div class="relative h-[250px]"><canvas id="weightChart"></canvas></div>
</div> </div>
{{-- PROTEKSI QUICK ACTION: Hanya jika bisa Create Treatment --}} @can('check-access', ['cats', 'update'])
@can('check-access', ['treatments', 'create']) <div class="bg-indigo-600 rounded-[3rem] p-8 text-white shadow-xl shadow-indigo-100 flex flex-col justify-between h-[300px] group overflow-hidden relative">
<div class="bg-indigo-600 rounded-[3rem] p-8 text-white shadow-xl shadow-indigo-100 flex flex-col justify-between h-[250px] group overflow-hidden relative">
<div class="relative z-10"> <div class="relative z-10">
<h4 class="text-xs font-black uppercase tracking-widest mb-2 border-b border-white/20 pb-3">Quick Action</h4> <h4 class="text-xs font-black uppercase tracking-widest mb-2 border-b border-white/20 pb-3">Tindakan medis</h4>
<p class="text-[10px] text-indigo-100/60 leading-relaxed italic uppercase font-bold tracking-tighter">Gunakan menu ini untuk mencatat tindakan medis segera.</p> <p class="text-[10px] text-indigo-100/60 leading-relaxed italic uppercase font-bold tracking-tighter">Log tindakan kesehatan cepat atau pengobatan klinis.</p>
</div> </div>
<a href="{{ route('treatments.create', ['cat_id' => $cat->id]) }}" class="relative z-10 flex items-center justify-center gap-3 p-5 bg-white text-indigo-600 rounded-3xl font-black uppercase text-xs shadow-xl hover:scale-105 transition-all active:scale-95">
<i class="fas fa-file-medical-alt text-lg"></i> Tambah Rekam Medis <div class="flex flex-col gap-3 relative z-10">
<a href="{{ route('medical-records.create', $cat->id) }}" class="w-full flex items-center justify-center gap-3 p-4 bg-white text-indigo-600 rounded-2xl font-black uppercase text-[10px] shadow-lg hover:bg-indigo-50 transition-all active:scale-95">
<i class="fas fa-calendar-check"></i> LOG ROUTINE LEDGER
</a> </a>
<a href="{{ route('treatments.create', ['cat_id' => $cat->id]) }}" class="w-full flex items-center justify-center gap-3 p-4 bg-indigo-500 text-white border border-indigo-400 rounded-2xl font-black uppercase text-[10px] shadow-lg hover:bg-indigo-400 transition-all active:scale-95">
<i class="fas fa-briefcase-medical"></i> LOG CLINICAL CASE
</a>
</div>
<i class="fas fa-heartbeat absolute -bottom-10 -left-10 text-[10rem] text-white/5"></i> <i class="fas fa-heartbeat absolute -bottom-10 -left-10 text-[10rem] text-white/5"></i>
</div> </div>
@endcan @endcan
</div> </div>
</div> </div>
{{-- Log Section: Medical History Timeline --}} {{-- Comprehensive Health History --}}
<div class="bg-white border border-slate-200 rounded-[3rem] overflow-hidden shadow-sm"> <div class="bg-white border border-slate-200 rounded-[3.5rem] overflow-hidden shadow-sm mx-2">
<div class="p-10 border-b border-slate-100 bg-slate-50/50 flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-indigo-600 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-indigo-100">
<i class="fas fa-book-medical text-xl"></i>
</div>
<div>
<h4 class="font-black text-slate-800 uppercase tracking-tighter text-xl leading-none">Comprehensive Health History</h4>
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-[0.2em] mt-2">Jejak medis dari pertama hingga rencana mendatang</p>
</div>
</div>
<span class="text-[10px] font-black text-indigo-600 bg-white border border-indigo-100 px-6 py-2.5 rounded-full shadow-sm">
{{ $cat->medicalRecords->count() }} TOTAL RECORDS
</span>
</div>
<div class="p-10 max-h-[800px] overflow-y-auto custom-scrollbar bg-white">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@forelse($cat->medicalRecords->sortByDesc('date_given') as $record)
<div class="p-8 rounded-[2.5rem] border border-slate-100 bg-slate-50/30 hover:bg-white hover:border-indigo-100 hover:shadow-xl transition-all duration-500 group/item relative overflow-hidden">
<i class="fas {{ $record->type === 'Vaccine' ? 'fa-syringe' : ($record->type === 'Grooming' ? 'fa-bath' : 'fa-pills') }} absolute -bottom-4 -right-4 text-7xl text-slate-100/50 group-hover/item:text-indigo-500/10 transition-colors"></i>
<div class="flex items-start justify-between mb-6 relative z-10">
<div class="w-12 h-12 rounded-2xl flex items-center justify-center shadow-sm
{{ $record->type === 'Vaccine' ? 'bg-blue-600 text-white' :
($record->type === 'Deworming' ? 'bg-emerald-500 text-white' :
($record->type === 'Flea Perawatan' ? 'bg-amber-500 text-white' : 'bg-pink-500 text-white')) }}">
<i class="fas {{ $record->type === 'Vaccine' ? 'fa-syringe' : ($record->type === 'Grooming' ? 'fa-bath' : 'fa-pills') }} text-sm"></i>
</div>
<span class="text-[10px] font-black text-slate-800 uppercase tracking-widest">{{ $record->date_given->format('d M Y') }}</span>
</div>
<div class="relative z-10">
<h5 class="text-base font-black text-slate-800 uppercase tracking-tight mb-1">{{ $record->treatment_name }}</h5>
<div class="flex items-center gap-2 mb-4">
<span class="text-[8px] font-black text-indigo-600 bg-indigo-50 px-2.5 py-1 rounded-lg border border-indigo-100 uppercase italic">{{ $record->type }}</span>
@if($record->dosage && $record->type !== 'Grooming')
<span class="text-[8px] font-black text-emerald-600 bg-emerald-50 px-2.5 py-1 rounded-lg border border-emerald-100 uppercase tracking-widest">
DOSIS: {{ $record->dosage }} {{ $record->unit }}
</span>
@endif
</div>
<p class="text-[11px] text-slate-500 font-medium italic mb-6 leading-relaxed bg-white/50 p-3 rounded-xl border border-dashed border-slate-200">
"{{ $record->notes ?? 'Pemberian rutin tanpa keluhan klinis.' }}"
</p>
</div>
</div>
@empty
<div class="col-span-full py-20 text-center text-slate-400 font-black uppercase text-xs">No Records Found</div>
@endforelse
</div>
</div>
</div>
{{-- Klinis Case History --}}
<div class="bg-white border border-slate-200 rounded-[3rem] overflow-hidden shadow-sm mx-2">
<div class="p-8 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center"> <div class="p-8 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<i class="fas fa-history text-slate-400"></i> <i class="fas fa-history text-slate-400"></i>
<h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg">Historical Medical Log</h4> <h4 class="font-black text-slate-800 uppercase tracking-tighter text-lg">Klinis Case History (Non-Routine)</h4>
</div> </div>
<span class="text-[10px] font-black text-indigo-600 bg-white border border-indigo-100 px-5 py-2 rounded-full shadow-sm">{{ $cat->treatments->count() }} REKAMAN</span>
</div> </div>
<div class="p-10 max-h-[600px] overflow-y-auto custom-scrollbar">
<div class="p-10 max-h-[800px] overflow-y-auto custom-scrollbar">
<div class="relative space-y-12"> <div class="relative space-y-12">
<div class="absolute left-[11px] top-2 bottom-2 w-0.5 bg-slate-100"></div> <div class="absolute left-[11px] top-2 bottom-2 w-0.5 bg-slate-100"></div>
@forelse($cat->treatments->sortByDesc('tanggal') as $treatment) @forelse($cat->treatments->sortByDesc('tanggal') as $treatment)
<div class="relative flex items-start gap-8 group"> <div class="relative flex items-start gap-8 group">
<div class="z-10 w-6 h-6 rounded-full bg-white border-4 border-indigo-500 ring-8 ring-indigo-50 shadow-md"></div> {{-- Dot Timeline dinamis sesuai status_perawatan --}}
<div class="z-10 w-6 h-6 rounded-full border-4 ring-8 shadow-md
{{ $treatment->status_perawatan == 2 ? 'bg-emerald-500 border-white ring-emerald-50' : 'bg-white border-rose-500 ring-rose-50' }}">
</div>
<div class="flex-1 space-y-4"> <div class="flex-1 space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-3"> <p class="text-xs font-black text-indigo-600 uppercase tracking-widest bg-indigo-50 px-3 py-1 rounded-lg border border-indigo-100">{{ $treatment->tanggal->format('d F Y') }}</p>
<p class="text-xs font-black text-indigo-600 uppercase tracking-widest bg-indigo-50 px-3 py-1 rounded-lg border border-indigo-100">
{{ $treatment->tanggal->format('d F Y') }} {{-- LABEL STATUS PADA KARTU HISTORY --}}
</p> @if($treatment->status_perawatan == 2)
<span class="text-[10px] text-slate-400 font-bold italic">{{ $treatment->tanggal->diffForHumans() }}</span> <span class="text-[9px] font-black text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full uppercase tracking-widest border border-emerald-100"><i class="fas fa-check-circle mr-1"></i> SEMBUH</span>
@else
<span class="text-[9px] font-black text-rose-600 bg-rose-50 px-3 py-1 rounded-full uppercase tracking-widest border border-rose-100 animate-pulse"><i class="fas fa-procedures mr-1"></i> AKTIF</span>
@endif
</div> </div>
{{-- PROTEKSI EDIT LOG: Hanya jika bisa Update Treatment --}} <div class="bg-white rounded-[2.5rem] p-8 border {{ $treatment->status_perawatan == 2 ? 'border-emerald-100' : 'border-rose-100' }} shadow-sm transition-all duration-300">
@can('check-access', ['treatments', 'update'])
<a href="{{ route('treatments.edit', $treatment->id) }}" class="opacity-0 group-hover:opacity-100 p-2.5 bg-slate-50 text-slate-400 hover:text-amber-600 hover:bg-amber-50 rounded-xl transition-all" title="Edit Log">
<i class="fas fa-pen text-xs"></i>
</a>
@endcan
</div>
<div class="bg-white rounded-[2.5rem] p-8 border border-slate-100 shadow-sm group-hover:border-indigo-200 transition-all duration-300">
<div class="flex flex-col md:flex-row gap-10"> <div class="flex flex-col md:flex-row gap-10">
<div class="flex-1"> <div class="flex-1">
<h5 class="text-lg font-black text-slate-800 mb-2 uppercase tracking-tight">{{ $treatment->keluhan }}</h5> <h5 class="text-lg font-black text-slate-800 mb-2 uppercase tracking-tight">{{ $treatment->keluhan }}</h5>
<p class="text-sm text-slate-500 font-medium leading-relaxed italic border-l-4 border-indigo-100 pl-4 mb-6">Diagnosa: {{ $treatment->diagnosa }}</p> <p class="text-sm text-slate-500 font-medium leading-relaxed italic border-l-4 {{ $treatment->status_perawatan == 2 ? 'border-emerald-200' : 'border-rose-200' }} pl-4 mb-6">Diagnosa: {{ $treatment->diagnosa }}</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="flex items-center gap-4 p-4 bg-slate-50/50 rounded-2xl mb-4">
<div class="flex items-center gap-4 p-4 bg-slate-50/50 rounded-2xl"> <div class="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-500"><i class="fas fa-capsules"></i></div>
<div class="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center text-rose-500"> <div><p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">Therapy</p><p class="text-xs font-black text-slate-800 uppercase">{{ $treatment->nama_obat }} ({{ $treatment->dosis }})</p></div>
<i class="fas fa-capsules"></i>
</div>
<div>
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">Therapy</p>
<p class="text-xs font-black text-slate-800 uppercase">{{ $treatment->nama_obat }} ({{ $treatment->dosis }})</p>
</div>
</div>
<div class="flex items-center gap-4 p-4 bg-slate-50/50 rounded-2xl">
<div class="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-500">
<i class="fas fa-user-md"></i>
</div>
<div>
<p class="text-[9px] font-black text-slate-400 uppercase tracking-widest">Medical Staff</p>
<p class="text-xs font-black text-slate-800 uppercase">{{ $treatment->user?->name ?? 'System' }}</p>
</div>
</div>
</div>
</div> </div>
{{-- TAMPILKAN REMARK JIKA SUDAH SEMBUH --}}
@if($treatment->status_perawatan == 2 && $treatment->remark_pasca_sakit)
<div class="bg-emerald-50/50 p-4 rounded-2xl border border-emerald-100/50 mt-4">
<p class="text-[9px] font-black text-emerald-600 uppercase tracking-widest mb-1"><i class="fas fa-clipboard-check mr-1"></i> Catatan Resolusi Sembuh</p>
<p class="text-xs font-bold text-slate-600 italic">"{{ $treatment->remark_pasca_sakit }}"</p>
</div>
@endif
</div>
@if($treatment->foto_bukti) @if($treatment->foto_bukti)
<div class="shrink-0"> <div class="shrink-0"><div class="w-44 h-44 rounded-[2.5rem] overflow-hidden ring-4 ring-slate-100 shadow-lg"><img src="{{ asset('storage/' . $treatment->foto_bukti) }}" class="w-full h-full object-cover"></div></div>
<div class="w-44 h-44 rounded-[2.5rem] overflow-hidden ring-4 ring-slate-100 shadow-lg group/img relative">
<img src="{{ asset('storage/' . $treatment->foto_bukti) }}" class="w-full h-full object-cover">
<div class="absolute inset-0 bg-indigo-600/20 opacity-0 group-hover/img:opacity-100 transition-opacity flex items-center justify-center cursor-zoom-in">
<i class="fas fa-search-plus text-white text-2xl"></i>
</div>
</div>
</div>
@endif @endif
</div> </div>
@if($treatment->keterangan)
<div class="mt-8 p-5 bg-indigo-50/30 rounded-2xl border-l-4 border-indigo-200">
<p class="text-[11px] text-slate-600 font-bold italic">
<i class="fas fa-quote-left mr-3 text-indigo-200"></i> {{ $treatment->keterangan }}
</p>
</div>
@endif
</div> </div>
</div> </div>
</div> </div>
@empty @empty
<div class="text-center py-32 bg-slate-50/30 rounded-[4rem] border-4 border-dashed border-slate-100"> <div class="text-center py-10 text-slate-300 uppercase font-black text-xs">No Klinis Cases</div>
<i class="fas fa-notes-medical text-slate-200 text-5xl mb-4"></i>
<p class="text-slate-400 font-black uppercase text-xs tracking-[0.3em]">No Historical Medical Logs Recorded</p>
</div>
@endforelse @endforelse
</div> </div>
</div> </div>
@@ -287,65 +371,31 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const ctx = document.getElementById('weightChart'); const ctx = document.getElementById('weightChart');
{{-- Data diurutkan dari yang terlama ke terbaru untuk Chart --}}
const weightHistories = {!! json_encode($cat->weightHistories->sortBy('created_at')->values() ?? []) !!}; const weightHistories = {!! json_encode($cat->weightHistories->sortBy('created_at')->values() ?? []) !!};
if(ctx && weightHistories && weightHistories.length > 0) {
if(!weightHistories || weightHistories.length === 0) { const labels = weightHistories.map(d => new Date(d.created_at).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }));
ctx.parentNode.innerHTML = '<div class="h-full flex flex-col items-center justify-center text-slate-300 italic text-xs gap-3"><i class="fas fa-weight-hanging text-4xl opacity-20"></i>No growth data available yet.</div>';
return;
}
const labels = weightHistories.map(d => {
const date = new Date(d.created_at);
return date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short' });
});
const data = weightHistories.map(d => d.berat); const data = weightHistories.map(d => d.berat);
new Chart(ctx, { new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: labels, labels: labels,
datasets: [{ datasets: [{
label: 'Weight (Kg)', label: 'Berat (Kg)',
data: data, data: data,
borderColor: '#10b981', borderColor: '#10b981',
backgroundColor: (context) => { backgroundColor: 'rgba(16, 185, 129, 0.1)',
const chart = context.chart;
const {ctx, chartArea} = chart;
if (!chartArea) return null;
const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
gradient.addColorStop(0, 'rgba(16, 185, 129, 0)');
gradient.addColorStop(1, 'rgba(16, 185, 129, 0.1)');
return gradient;
},
borderWidth: 5, borderWidth: 5,
tension: 0.4, tension: 0.4,
fill: true, fill: true,
pointBackgroundColor: '#fff', pointBackgroundColor: '#fff',
pointBorderColor: '#10b981', pointBorderColor: '#10b981',
pointBorderWidth: 3, pointBorderWidth: 3,
pointRadius: 4, pointRadius: 4
pointHoverRadius: 8
}] }]
}, },
options: { options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: false }, x: { grid: { display: false } } } }
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: false,
grid: { borderDash: [5, 5], color: '#f1f5f9' },
ticks: { font: { size: 10, weight: 'bold' }, color: '#94a3b8' }
},
x: {
grid: { display: false },
ticks: { font: { size: 10, weight: 'bold' }, color: '#94a3b8' }
}
}
}
}); });
}
}); });
</script> </script>
@endpush @endpush
+503 -96
View File
@@ -1,140 +1,547 @@
<x-app-layout> <x-app-layout>
<div class="max-w-[1600px] mx-auto p-4 sm:px-6 lg:px-8 py-8 space-y-8"> <div class="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
{{-- Header --}}
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 bg-white p-8 rounded-[3rem] border border-slate-100 shadow-sm">
<div> <div>
<h2 class="text-4xl font-black text-slate-800 tracking-tighter uppercase leading-none"> <h1 class="text-2xl font-bold text-slate-900">Beranda</h1>
Intelligence <span class="text-indigo-600">Dashboard</span> <p class="mt-1 text-sm text-slate-500">Ringkasan operasional cattery dan pemantauan kondisi kucing.</p>
</h2>
<p class="text-slate-400 font-bold uppercase text-[10px] tracking-[0.3em] mt-2 italic">
<i class="fas fa-calendar-alt mr-2"></i> Operasional Earrin Cats: {{ date('d F Y') }}
</p>
</div> </div>
<div class="flex gap-3"> <div class="flex flex-wrap gap-2">
<a href="{{ route('admin.reports.index') }}" class="px-6 py-3 bg-slate-100 text-slate-600 text-[10px] font-black uppercase rounded-2xl hover:bg-slate-200 transition-all tracking-widest">Laporan Lengkap</a> <a href="{{ route('cats.create') }}" class="bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700">
<a href="{{ route('cats.create') }}" class="px-6 py-3 bg-indigo-600 text-white text-[10px] font-black uppercase rounded-2xl shadow-xl shadow-indigo-100 hover:bg-indigo-700 transition-all tracking-widest flex items-center"> <i class="fas fa-plus mr-2"></i>Tambah kucing
<i class="fas fa-plus mr-2"></i> Tambah Kucing </a>
<a href="{{ route('matting.create') }}" class="border border-slate-300 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-50">
Catat perkawinan
</a> </a>
</div> </div>
</div> </div>
{{-- Birth Watch Alert --}} <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
@if($upcomingBirths->count() > 0) @foreach([
<div class="bg-white rounded-[3rem] border-2 border-rose-100 shadow-xl shadow-rose-50 overflow-hidden"> ['Total kucing',$stats['total_cats'] ?? 0,'fa-cat','cats.index'],
<div class="bg-rose-600 px-8 py-4 flex justify-between items-center"> ['Dalam perawatan',$stats['sick_cats'] ?? 0,'fa-heart-pulse','cats.index'],
<div class="flex items-center gap-3"> ['Pembiakan aktif',$stats['total_mating'] ?? 0,'fa-venus-mars','matting.index'],
<i class="fas fa-baby-carriage text-white animate-bounce"></i> ['Anakan tercatat',$stats['total_kittens'] ?? 0,'fa-paw','matting.index']
<h3 class="text-[10px] font-black text-white uppercase tracking-[0.3em]">Birth Watch: Persalinan Mendatang</h3> ] as $i=>$card)
<a href="{{ $i===1 ? route($card[3],['filter_health'=>'treatment']) : route($card[3]) }}" class="border border-slate-200 bg-white p-5 hover:border-slate-300">
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-slate-500">{{ $card[0] }}</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ $card[1] }}</p>
</div> </div>
<i class="fas {{ $card[2] }} mt-1 text-slate-300"></i>
</div> </div>
<div class="p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> </a>
@foreach($upcomingBirths as $birth)
<div class="bg-rose-50/50 border border-rose-100 p-6 rounded-[2rem] relative group">
<div class="absolute -top-3 -right-3 bg-rose-600 text-white w-10 h-10 rounded-xl flex items-center justify-center font-black text-sm shadow-lg transform rotate-12">
{{ $birth->days_to_go }}d
</div>
<div class="text-xs font-black text-rose-700 uppercase mb-1">{{ $birth->dam->nama_kucing }}</div>
<div class="text-[9px] font-bold text-rose-400 uppercase tracking-tighter mb-3">Sire: {{ $birth->sire->nama_kucing }}</div>
<div class="text-[10px] font-bold text-slate-600">Estimasi: {{ $birth->edd->format('d M Y') }}</div>
</div>
@endforeach @endforeach
</div> </div>
</div>
@endif
{{-- Stats Cards --}} {{-- PEMANTAUAN BERAT BADAN - REVISION 3 --}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> <section id="pemantauan-berat" class="mt-6 scroll-mt-6 rounded-2xl border border-slate-200 bg-white shadow-sm">
<x-stats-card title="Total Pasien" :value="$stats['total_cats']" icon="fa-cat" color="indigo" /> <div class="flex flex-col gap-5 border-b border-slate-100 px-5 py-5 lg:flex-row lg:items-end lg:justify-between">
<x-stats-card title="Indukan Hamil" :value="$stats['total_mating']" icon="fa-venus" color="rose" /> <div class="flex items-start gap-3">
<x-stats-card title="Sedang Sakit" :value="$stats['sick_cats']" icon="fa-stethoscope" color="amber" /> <div class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
<x-stats-card title="Stok Kritis" :value="$totalLowStock" icon="fa-exclamation-triangle" color="rose" /> <i class="fas fa-weight-scale text-lg"></i>
</div> <span class="absolute -bottom-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full border-2 border-white bg-white text-[8px] text-blue-500 shadow-sm">
<i class="fas fa-paw"></i>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8"> </span>
{{-- Left Content: Records & Stock --}}
<div class="lg:col-span-2 space-y-8">
<div class="bg-white rounded-[3rem] border border-slate-100 shadow-sm overflow-hidden">
<div class="p-8 border-b border-slate-50 flex justify-between items-center">
<h3 class="text-xs font-black text-slate-800 uppercase tracking-widest">Rekam Medis Terbaru</h3>
<a href="{{ route('treatments.index') }}" class="text-[10px] font-black text-indigo-600 uppercase hover:underline">Lihat Semua</a>
</div>
<table class="w-full text-left">
<tbody>
@foreach($recentTreatments as $t)
<tr class="hover:bg-slate-50/50">
<td class="px-8 py-5 flex items-center gap-4">
<div class="w-10 h-10 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600 font-black text-xs">
{{ substr($t->cat->nama_kucing, 0, 1) }}
</div> </div>
<div> <div>
<div class="text-xs font-black text-slate-800 uppercase leading-none">{{ $t->cat->nama_kucing }}</div> <h2 class="text-xl font-bold text-slate-900">Pemantauan Berat Badan</h2>
<div class="text-[9px] text-slate-400 font-bold mt-1">{{ $t->created_at->diffForHumans() }}</div> <p class="mt-1 text-sm text-slate-500">Pantau perkembangan berat badan kucing secara ringkas dan mudah dibaca.</p>
</div>
</div>
<form method="GET" action="{{ route('dashboard') }}#pemantauan-berat" class="grid gap-3 sm:grid-cols-2 lg:min-w-[470px]">
<input type="hidden" name="weight_focus" value="1">
<input type="hidden" name="trend_category" value="{{ $weightTrendCategory ?? 'Kitten' }}">
<div>
<label for="weight_months" class="mb-1.5 block text-xs font-semibold text-slate-500">Periode</label>
<div class="relative">
<i class="fas fa-calendar-days pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-slate-400"></i>
<select id="weight_months" name="weight_months" class="w-full rounded-xl border-slate-200 bg-white py-2.5 pl-9 pr-8 text-sm text-slate-700 shadow-sm focus:border-blue-400 focus:ring-blue-400" onchange="this.form.submit()">
@foreach([3=>'3 bulan',6=>'6 bulan',12=>'12 bulan',24=>'24 bulan'] as $months=>$label)
<option value="{{ $months }}" @selected(($weightTrendMonths ?? 6) == $months)>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label for="weight_category" class="mb-1.5 block text-xs font-semibold text-slate-500">Kategori</label>
<div class="relative">
<i class="fas fa-cat pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-slate-400"></i>
<select id="weight_category" name="weight_category" class="w-full rounded-xl border-slate-200 bg-white py-2.5 pl-9 pr-8 text-sm text-slate-700 shadow-sm focus:border-blue-400 focus:ring-blue-400" onchange="this.form.submit()">
@foreach(['Semua'=>'Semua Kategori','Kitten'=>'Kitten','Junior'=>'Junior','Dewasa'=>'Dewasa'] as $value=>$label)
<option value="{{ $value }}" @selected(($weightTableCategory ?? 'Semua') === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
</div>
</form>
</div>
@php
$weightCards = [
[
'label' => 'Total Ditimbang Bulan Ini',
'value' => (int) data_get($weightKpis ?? [], 'weighed.value', 0),
'suffix' => 'kucing',
'change' => (float) data_get($weightKpis ?? [], 'weighed.change', 0),
'hasComparison' => true,
'icon' => 'fa-paw',
'box' => 'bg-blue-50/70 border-blue-100',
'iconBox' => 'bg-blue-100 text-blue-600',
],
[
'label' => 'Rata-rata Kitten',
'value' => (float) data_get($weightKpis ?? [], 'categories.Kitten.average', 0),
'suffix' => 'kg',
'change' => (float) data_get($weightKpis ?? [], 'categories.Kitten.change', 0),
'hasComparison' => (bool) data_get($weightKpis ?? [], 'categories.Kitten.has_comparison', false),
'icon' => 'fa-cat',
'box' => 'bg-indigo-50/70 border-indigo-100',
'iconBox' => 'bg-indigo-100 text-indigo-600',
],
[
'label' => 'Rata-rata Junior',
'value' => (float) data_get($weightKpis ?? [], 'categories.Junior.average', 0),
'suffix' => 'kg',
'change' => (float) data_get($weightKpis ?? [], 'categories.Junior.change', 0),
'hasComparison' => (bool) data_get($weightKpis ?? [], 'categories.Junior.has_comparison', false),
'icon' => 'fa-cat',
'box' => 'bg-rose-50/70 border-rose-100',
'iconBox' => 'bg-rose-100 text-rose-600',
],
[
'label' => 'Rata-rata Dewasa',
'value' => (float) data_get($weightKpis ?? [], 'categories.Dewasa.average', 0),
'suffix' => 'kg',
'change' => (float) data_get($weightKpis ?? [], 'categories.Dewasa.change', 0),
'hasComparison' => (bool) data_get($weightKpis ?? [], 'categories.Dewasa.has_comparison', false),
'icon' => 'fa-cat',
'box' => 'bg-emerald-50/70 border-emerald-100',
'iconBox' => 'bg-emerald-100 text-emerald-600',
],
];
@endphp
<div class="grid gap-3 p-5 sm:grid-cols-2 xl:grid-cols-4">
@foreach($weightCards as $card)
@php
$isCount = $card['suffix'] === 'kucing';
$change = $card['change'];
$positive = $change >= 0;
@endphp
<div class="rounded-2xl border p-4 {{ $card['box'] }}">
<div class="flex items-start gap-3">
<div class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl {{ $card['iconBox'] }}">
<i class="fas {{ $card['icon'] }}"></i>
<span class="absolute -bottom-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full border-2 border-white bg-white text-[6px] opacity-90"><i class="fas fa-paw"></i></span>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-slate-700">{{ $card['label'] }}</p>
<div class="mt-1 flex items-end gap-1.5">
<p class="text-3xl font-bold tracking-tight text-slate-900">
{{ $isCount ? number_format($card['value'], 0) : number_format($card['value'], 1, ',', '.') }}
</p>
<span class="mb-1 text-sm font-medium text-slate-500">{{ $card['suffix'] }}</span>
</div>
@if($card['hasComparison'])
<p class="mt-1 text-xs {{ $positive ? 'text-emerald-600' : 'text-rose-600' }}">
<i class="fas {{ $positive ? 'fa-arrow-up' : 'fa-arrow-down' }} mr-1"></i>{{ $positive ? '+' : '' }}{{ number_format($change, 1, ',', '.') }}%
<span class="text-slate-400">dari bulan lalu</span>
</p>
@else
<p class="mt-1 text-xs text-slate-400">Belum ada pembanding bulan lalu</p>
@endif
</div>
</div>
</div>
@endforeach
</div>
<div class="grid gap-4 px-5 pb-5 xl:grid-cols-2">
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<div class="mb-4">
<h3 class="text-base font-bold text-slate-900">Rata-rata per kategori</h3>
<p class="mt-0.5 text-xs text-slate-500">Rata-rata berat terakhir setiap kucing pada masing-masing bulan.</p>
</div>
<div class="h-72 sm:h-80">
<canvas id="weightCategoryAverageChart"></canvas>
</div>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 class="text-base font-bold text-slate-900">Tren kategori terpilih</h3>
<p class="mt-0.5 text-xs text-slate-500">Maksimal 6 kucing dengan data terlengkap agar grafik tetap mudah dibaca.</p>
</div>
<div class="inline-flex rounded-xl bg-slate-100 p-1">
@foreach(['Kitten','Junior','Dewasa'] as $category)
@php
$tabQuery = array_merge(request()->except('page', 'trend_category'), [
'weight_focus' => 1,
'trend_category' => $category,
]);
@endphp
<a href="{{ route('dashboard', $tabQuery) }}#pemantauan-berat"
class="rounded-lg px-3 py-1.5 text-xs font-semibold transition {{ ($weightTrendCategory ?? 'Kitten') === $category ? 'bg-blue-600 text-white shadow-sm' : 'text-slate-600 hover:bg-white hover:text-slate-900' }}">
{{ $category }}
</a>
@endforeach
</div>
</div>
<div class="h-72 sm:h-80">
@if(count($weightTrendSeries ?? []) > 0)
<canvas id="weightSelectedCategoryChart"></canvas>
@else
<div class="flex h-full flex-col items-center justify-center rounded-xl bg-slate-50 text-center">
<div class="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-white text-slate-300 shadow-sm"><i class="fas fa-cat text-xl"></i></div>
<p class="text-sm font-semibold text-slate-600">Belum ada tren {{ $weightTrendCategory ?? 'kategori ini' }}</p>
<p class="mt-1 text-xs text-slate-400">Tambahkan minimal satu pencatatan berat pada periode yang dipilih.</p>
</div>
@endif
</div>
</div>
</div>
<div class="border-t border-slate-100 px-5 py-5">
<div class="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h3 class="text-base font-bold text-slate-900">Berat Terakhir</h3>
<p class="mt-0.5 text-xs text-slate-500">Daftar berat terbaru, perubahan dari pencatatan sebelumnya, dan tanggal timbang.</p>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center">
<label class="relative block">
<span class="sr-only">Cari nama kucing</span>
<i class="fas fa-magnifying-glass pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-xs text-slate-400"></i>
<input id="weightTableSearch" type="search" placeholder="Cari nama kucing..." class="w-full rounded-xl border-slate-200 py-2.5 pl-9 pr-3 text-sm text-slate-700 sm:w-64 focus:border-blue-400 focus:ring-blue-400">
</label>
<div class="flex items-center gap-2 text-xs text-slate-500">
<span>Tampilkan</span>
<select id="weightRowsPerPage" class="rounded-xl border-slate-200 py-2 text-sm text-slate-700 focus:border-blue-400 focus:ring-blue-400">
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
<span>data</span>
</div>
@can('check-access', ['cats','update'])
<a href="{{ route('cats.bulk-weight') }}" class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-3.5 py-2.5 text-sm font-semibold text-white hover:bg-blue-700">
<i class="fas fa-weight-scale mr-2"></i>Perbarui berat
</a>
@endcan
</div>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-slate-200 text-sm">
<thead class="bg-slate-50/80 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Nama Kucing</th>
<th class="px-4 py-3">Kategori</th>
<th class="px-4 py-3 text-right">Berat Terakhir</th>
<th class="px-4 py-3 text-right">Perubahan</th>
<th class="px-4 py-3">Tanggal</th>
</tr>
</thead>
<tbody id="weightTableBody" class="divide-y divide-slate-100 bg-white">
@forelse(($weightLatestTable ?? collect()) as $item)
@php
$photoUrl = $item['photo'] ?
\Illuminate\Support\Facades\Storage::url($item['photo']) : asset('images/default-cat.png');
$change = $item['change'];
@endphp
<tr data-weight-row data-name="{{ strtolower($item['name']) }}" class="hover:bg-slate-50/70">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<img src="{{ $photoUrl }}" alt="Foto {{ $item['name'] }}" class="h-9 w-9 rounded-full border border-slate-200 object-cover" onerror="this.src='{{ asset('images/default-cat.png') }}'">
<div class="min-w-0">
<p class="truncate font-semibold text-slate-800">{{ $item['name'] }}</p>
<p class="text-xs text-slate-400">{{ $item['gender'] ?: '-' }}</p>
</div>
</div> </div>
</td> </td>
<td class="px-8 py-5 text-xs italic text-slate-500">"{{ $t->keluhan }}"</td> <td class="px-4 py-3">
<td class="px-8 py-5 text-right font-black text-indigo-600 text-[10px] uppercase">{{ $t->nama_obat }}</td> @php
$badge = match($item['category']) {
'Kitten' => 'bg-blue-50 text-blue-700 ring-blue-100',
'Junior' => 'bg-rose-50 text-rose-700 ring-rose-100',
default => 'bg-emerald-50 text-emerald-700 ring-emerald-100',
};
@endphp
<span class="inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ring-1 ring-inset {{ $badge }}">{{ $item['category'] }}</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-right font-bold text-slate-800">{{ number_format($item['latest'], 2, ',', '.') }} kg</td>
<td class="whitespace-nowrap px-4 py-3 text-right">
@if($change === null)
<span class="text-xs text-slate-400">Belum ada pembanding</span>
@elseif($change > 0)
<span class="font-semibold text-emerald-600"><i class="fas fa-arrow-up mr-1 text-xs"></i>+{{ number_format($change, 2, ',', '.') }} kg</span>
@elseif($change < 0)
<span class="font-semibold text-rose-600"><i class="fas fa-arrow-down mr-1 text-xs"></i>{{ number_format($change, 2, ',', '.') }} kg</span>
@else
<span class="font-semibold text-slate-500"><i class="fas fa-minus mr-1 text-xs"></i>0,00 kg</span>
@endif
</td>
<td class="whitespace-nowrap px-4 py-3 text-slate-500">{{ \Carbon\Carbon::parse($item['date'])->translatedFormat('d M Y') }}</td>
</tr> </tr>
@endforeach @empty
<tr data-empty-row><td colspan="5" class="px-4 py-10 text-center text-sm text-slate-500">Belum ada data berat pada periode yang dipilih.</td></tr>
@endforelse
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex flex-col gap-3 border-t border-slate-100 bg-slate-50/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p id="weightTableInfo" class="text-xs text-slate-500"></p>
<div class="flex items-center gap-2">
<button id="weightPrevPage" type="button" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">Sebelumnya</button>
<span id="weightPageLabel" class="min-w-20 text-center text-xs font-semibold text-slate-600"></span>
<button id="weightNextPage" type="button" class="rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">Berikutnya</button>
</div>
</div>
</div>
</div>
</section>
{{-- Charts --}} <div class="mt-6 grid gap-6 xl:grid-cols-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8"> <section class="border border-slate-200 bg-white xl:col-span-2">
<div class="bg-white rounded-[3rem] border border-slate-100 p-8"> <div class="flex items-center justify-between border-b border-slate-200 px-5 py-4">
<h4 class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-6">Sebaran Ras</h4> <div>
<canvas id="breedChart" class="max-h-[200px]"></canvas> <h2 class="font-semibold text-slate-900">Perkiraan kelahiran</h2>
<p class="text-xs text-slate-500">Perkawinan aktif dengan perkiraan lahir sekitar 63 hari setelah kawin.</p>
</div> </div>
<div class="bg-white rounded-[3rem] border border-slate-100 p-8"> <a href="{{ route('matting.index') }}" class="text-sm font-medium text-indigo-600">Lihat semua</a>
<h4 class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-6">Tren Berat Badan (kg)</h4>
<canvas id="weightChart" class="max-h-[200px]"></canvas>
</div> </div>
<div class="divide-y divide-slate-100">
@forelse($upcomingBirths as $mating)
<a href="{{ route('matting.edit',$mating->id) }}" class="grid gap-3 px-5 py-4 hover:bg-slate-50 sm:grid-cols-[1fr_auto] sm:items-center">
<div>
<p class="font-semibold text-slate-800">{{ $mating->dam->nama_kucing ?? 'Betina' }} <span class="font-normal text-slate-400">×</span> {{ $mating->sire->nama_kucing ?? 'Jantan' }}</p>
<p class="mt-1 text-sm text-slate-500">Perkiraan lahir {{ $mating->edd?->translatedFormat('d M Y') }}</p>
</div> </div>
<span class="text-sm font-semibold {{ $mating->days_to_go <= 2 ? 'text-rose-600' : 'text-slate-600' }}">{{ $mating->days_to_go < 0 ? 'Lewat '.abs($mating->days_to_go).' hari' : $mating->days_to_go.' hari lagi' }}</span>
</a>
@empty
<div class="px-5 py-10 text-center text-sm text-slate-500">Belum ada perkiraan kelahiran dalam waktu dekat.</div>
@endforelse
</div> </div>
</section>
{{-- Right Content: Reminders --}} <section class="border border-slate-200 bg-white">
<div class="space-y-8"> <div class="border-b border-slate-200 px-5 py-4">
<div class="bg-slate-900 rounded-[3rem] p-8 text-white relative overflow-hidden shadow-2xl"> <h2 class="font-semibold text-slate-900">Jadwal yang terlambat</h2>
<h3 class="text-[10px] font-black text-indigo-400 uppercase tracking-[0.3em] mb-6">Medical Reminders</h3> <p class="text-xs text-slate-500">Perlu ditindaklanjuti dari data kucing.</p>
<div class="space-y-6">
@foreach([['Vaksin', 'vaccine', 'syringe'], ['Kutu', 'flea', 'bug'], ['Cacing', 'deworming', 'bug-slash'], ['Grooming', 'grooming', 'soap']] as $item)
<div class="flex justify-between items-center">
<span class="text-[10px] font-bold text-slate-300 uppercase"><i class="fas fa-{{ $item[2] }} mr-2"></i> {{ $item[0] }}</span>
<span class="text-xs font-black {{ $medicalAlerts[$item[1]] > 0 ? 'text-rose-400 animate-pulse' : 'text-slate-500' }}">{{ $medicalAlerts[$item[1]] }}</span>
</div> </div>
<div class="grid grid-cols-2 gap-px bg-slate-200">
@php $labels=['vaccine'=>'Vaksin','flea'=>'Obat kutu','deworming'=>'Obat cacing','grooming'=>'Grooming']; @endphp
@foreach($overdueAlerts as $key=>$data)
<a href="{{ $data['url'] }}" class="bg-white p-4 hover:bg-slate-50">
<p class="text-xs text-slate-500">{{ $labels[$key] ?? $key }}</p>
<p class="mt-1 text-2xl font-bold {{ $data['count']>0?'text-rose-600':'text-slate-800' }}">{{ $data['count'] }}</p>
</a>
@endforeach @endforeach
</div> </div>
</section>
</div> </div>
<div class="bg-indigo-600 rounded-[3rem] p-8 text-white">
<h4 class="font-black text-sm uppercase tracking-widest mb-2 italic">Breeder Tip</h4> <div class="mt-6 grid gap-6 xl:grid-cols-2">
<p class="text-[10px] text-indigo-100 leading-relaxed font-medium">Jangan lupa berikan vitamin bulu khusus untuk kucing <strong>Persian</strong> Anda setiap pagi.</p> <section class="border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Stok menipis</h2></div>
<div class="divide-y divide-slate-100">
@forelse(($lowStock ?? collect())->take(8) as $item)
<a href="{{ $item->url ?? '#' }}" class="flex justify-between px-5 py-3 text-sm hover:bg-slate-50">
<span class="truncate pr-4">{{ $item->name }}</span>
<span class="font-semibold text-rose-600">{{ (float)$item->qty }} {{ $item->unit }}</span>
</a>
@empty
<div class="p-5 text-sm text-slate-500">Tidak ada stok kritis.</div>
@endforelse
</div> </div>
</section>
<section class="border border-slate-200 bg-white">
<div class="border-b border-slate-200 px-5 py-4"><h2 class="font-semibold text-slate-900">Mendekati kedaluwarsa</h2></div>
<div class="divide-y divide-slate-100">
@forelse($expiringItems as $item)
<a href="{{ $item->url ?? '#' }}" class="flex items-center justify-between gap-4 px-5 py-3 text-sm hover:bg-slate-50">
<div class="min-w-0">
<p class="truncate font-medium">{{ $item->name }}</p>
<p class="text-xs {{ $item->is_expired?'text-rose-600':'text-amber-600' }}">{{ $item->is_expired?'Sudah kedaluwarsa':$item->days_left.' hari lagi' }} · {{ $item->formatted_date }}</p>
</div> </div>
<span class="shrink-0 text-xs text-slate-500">{{ (float)$item->qty }} {{ $item->unit }}</span>
</a>
@empty
<div class="p-5 text-sm text-slate-500">Tidak ada peringatan kedaluwarsa.</div>
@endforelse
</div> </div>
</section>
</div> </div>
@push('scripts') @push('scripts')
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<script> <script>
// Breed Chart document.addEventListener('DOMContentLoaded', function () {
new Chart(document.getElementById('breedChart'), { const formatKg = (value) => `${Number(value).toFixed(2).replace('.', ',')} kg`;
type: 'doughnut', const formatMonth = (month) => {
data: { const d = new Date(`${month}-01T00:00:00`);
labels: {!! json_encode($chartData->pluck('ras')) !!}, return Number.isNaN(d.getTime()) ? month : d.toLocaleDateString('id-ID', { month: 'short', year: '2-digit' });
datasets: [{ data: {!! json_encode($chartData->pluck('total')) !!}, backgroundColor: ['#6366f1', '#f43f5e', '#fbbf24', '#10b981'], borderWidth: 0, spacing: 10 }] };
},
options: { cutout: '80%', plugins: { legend: { position: 'bottom', labels: { font: { size: 10, weight: 'bold' }, usePointStyle: true } } } }
});
// Weight Chart if (typeof Chart !== 'undefined') {
new Chart(document.getElementById('weightChart'), { Chart.defaults.font.family = "Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
Chart.defaults.color = '#64748b';
const categoryColors = {
Kitten: '#2563eb',
Junior: '#db2777',
Dewasa: '#059669'
};
const catPalette = ['#d97706', '#7c3aed', '#0891b2', '#2563eb', '#059669', '#be123c'];
const avgData = @json($categoryAverageTrend ?? ['labels'=>[], 'series'=>[]]);
const avgCanvas = document.getElementById('weightCategoryAverageChart');
if (avgCanvas && avgData.labels && avgData.labels.length) {
new Chart(avgCanvas, {
type: 'line', type: 'line',
data: { data: {
labels: {!! json_encode($weightTrends->pluck('month')) !!}, labels: avgData.labels.map(formatMonth),
datasets: [{ label: 'Berat (kg)', data: {!! json_encode($weightTrends->pluck('avg_weight')) !!}, borderColor: '#6366f1', tension: 0.4, fill: true, backgroundColor: 'rgba(99, 102, 241, 0.1)' }] datasets: (avgData.series || []).map((series) => ({
label: series.name,
data: series.data,
borderColor: categoryColors[series.name] || '#64748b',
backgroundColor: categoryColors[series.name] || '#64748b',
borderWidth: 2.5,
pointRadius: 3,
pointHoverRadius: 5,
pointBorderWidth: 0,
tension: 0.28,
spanGaps: true
}))
}, },
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: false, grid: { display: false } }, x: { grid: { display: false } } } } options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 18, font: { size: 11, weight: '600' } }
},
tooltip: {
backgroundColor: '#0f172a',
padding: 10,
cornerRadius: 10,
callbacks: { label: (ctx) => `${ctx.dataset.label}: ${formatKg(ctx.parsed.y)}` }
}
},
scales: {
x: { grid: { display: false }, border: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 8 } },
y: {
beginAtZero: true,
grid: { color: '#e2e8f0' },
border: { display: false },
title: { display: true, text: 'Berat (kg)', color: '#64748b', font: { size: 11, weight: '600' } }
}
}
}
});
}
const trendLabels = @json($weightTrendLabels ?? []);
const trendSeries = @json($weightTrendSeries ?? []);
const trendCanvas = document.getElementById('weightSelectedCategoryChart');
if (trendCanvas && trendLabels.length && trendSeries.length) {
new Chart(trendCanvas, {
type: 'line',
data: {
labels: trendLabels.map(formatMonth),
datasets: trendSeries.map((series, index) => ({
label: series.name,
data: series.data,
borderColor: catPalette[index % catPalette.length],
backgroundColor: catPalette[index % catPalette.length],
borderWidth: 2.2,
pointRadius: 3,
pointHoverRadius: 5,
pointBorderWidth: 0,
tension: 0.28,
spanGaps: true
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'nearest', intersect: false },
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 8, boxHeight: 8, padding: 15, font: { size: 10, weight: '600' } }
},
tooltip: {
backgroundColor: '#0f172a',
padding: 10,
cornerRadius: 10,
callbacks: { label: (ctx) => `${ctx.dataset.label}: ${formatKg(ctx.parsed.y)}` }
}
},
scales: {
x: { grid: { display: false }, border: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 8 } },
y: {
beginAtZero: true,
grid: { color: '#e2e8f0' },
border: { display: false },
title: { display: true, text: 'Berat (kg)', color: '#64748b', font: { size: 11, weight: '600' } }
}
}
}
});
}
}
// Pencarian + pagination tabel berat terbaru (client-side, tanpa request tambahan).
const searchInput = document.getElementById('weightTableSearch');
const rowsPerPageSelect = document.getElementById('weightRowsPerPage');
const tableRows = Array.from(document.querySelectorAll('[data-weight-row]'));
const prevButton = document.getElementById('weightPrevPage');
const nextButton = document.getElementById('weightNextPage');
const pageLabel = document.getElementById('weightPageLabel');
const tableInfo = document.getElementById('weightTableInfo');
let currentPage = 1;
const renderWeightTable = () => {
if (!tableRows.length) {
if (tableInfo) tableInfo.textContent = '0 data';
if (pageLabel) pageLabel.textContent = '';
if (prevButton) prevButton.disabled = true;
if (nextButton) nextButton.disabled = true;
return;
}
const search = (searchInput?.value || '').trim().toLowerCase();
const pageSize = Number(rowsPerPageSelect?.value || 10);
const filtered = tableRows.filter((row) => (row.dataset.name || '').includes(search));
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
currentPage = Math.min(currentPage, totalPages);
const start = (currentPage - 1) * pageSize;
const visible = new Set(filtered.slice(start, start + pageSize));
tableRows.forEach((row) => row.classList.toggle('hidden', !visible.has(row)));
if (tableInfo) {
const from = filtered.length ? start + 1 : 0;
const to = Math.min(start + pageSize, filtered.length);
tableInfo.textContent = `Menampilkan ${from}-${to} dari ${filtered.length} data`;
}
if (pageLabel) pageLabel.textContent = `Hal. ${currentPage} / ${totalPages}`;
if (prevButton) prevButton.disabled = currentPage <= 1;
if (nextButton) nextButton.disabled = currentPage >= totalPages;
};
searchInput?.addEventListener('input', () => { currentPage = 1; renderWeightTable(); });
rowsPerPageSelect?.addEventListener('change', () => { currentPage = 1; renderWeightTable(); });
prevButton?.addEventListener('click', () => { if (currentPage > 1) { currentPage--; renderWeightTable(); } });
nextButton?.addEventListener('click', () => { currentPage++; renderWeightTable(); });
renderWeightTable();
}); });
</script> </script>
@endpush @endpush

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