initial commit
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Cat;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
|
||||
class CatWeightTemplateExport implements FromCollection, WithHeadings, WithMapping, WithStyles, ShouldAutoSize, WithColumnFormatting
|
||||
{
|
||||
/**
|
||||
* Ambil data kucing yang aktif
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
// Tetap mengambil semua kucing yang tidak dihapus
|
||||
return Cat::whereNull('deleted_at')->get(['id', 'nama_kucing']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header Tabel
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
"Kode Kucing",
|
||||
"Nama Kucing",
|
||||
"Berat Badan (Kg)",
|
||||
"Tanggal & Waktu" // Ubah header agar staf tahu ini mendukung jam
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping baris data
|
||||
*/
|
||||
public function map($cat): array
|
||||
{
|
||||
return [
|
||||
$cat->id,
|
||||
$cat->nama_kucing,
|
||||
'', // Kosongkan untuk diisi staf
|
||||
|
||||
// Konversi tanggal PHP murni ke Serial Number Date milik Excel
|
||||
// Ini mencegah Excel mengubah tanggal menjadi format teks yang berantakan
|
||||
Date::dateTimeToExcel(now())
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Kolom secara spesifik
|
||||
*/
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return [
|
||||
// Memaksa kolom D (Tanggal & Waktu) menggunakan format kalender bawaan Excel
|
||||
// Formatnya akan menjadi YYYY-MM-DD HH:MM:SS saat staf membuka filenya
|
||||
'D' => NumberFormat::FORMAT_DATE_DATETIME,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Styling Header agar lebih cantik
|
||||
*/
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4F81BD']
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
|
||||
($item->sire?->nama_kucing ?? '-') . ' x ' . ($item->dam?->nama_kucing ?? '-'),
|
||||
($item->dam?->pedigree ?? '-'),
|
||||
'S: ' . ($item->ems_bapak ?? '-') . ' / D: ' . ($item->ems_ibu ?? '-'),
|
||||
'S: ' . ($item->no_pedigree_bapak ?? 'Non-Ped') . ' / D: ' . ($item->no_pedigree_ibu ?? 'Non-Ped'),
|
||||
'S: ' . ($item->no_pedigree_bapak ?? 'Non-pedigree') . ' / D: ' . ($item->no_pedigree_ibu ?? 'Non-pedigree'),
|
||||
'S: ' . ($item->no_microchip_bapak ?? '-') . ' / D: ' . ($item->no_microchip_ibu ?? '-'),
|
||||
$tglKawin,
|
||||
$eddDate,
|
||||
@@ -66,7 +66,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
|
||||
return [
|
||||
AfterSheet::class => function(AfterSheet $event) {
|
||||
$sheet = $event->sheet;
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC BREEDING REPORT');
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - LAPORAN PEMBIAKAN');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Treatment;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class TreatmentExport implements FromQuery, WithHeadings, WithMapping, WithStyles, ShouldAutoSize
|
||||
{
|
||||
protected $request;
|
||||
|
||||
public function __construct($request)
|
||||
{
|
||||
// Kita bisa menerima objek Request atau Query Builder hasil clone dari Controller
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query data dengan filter yang sinkron dengan tampilan UI
|
||||
*/
|
||||
public function query()
|
||||
{
|
||||
$query = Treatment::query()->with(['cat', 'user', 'details.medicine']);
|
||||
|
||||
// Filter 1: Pencarian Nama Kucing
|
||||
if ($this->request->filled('search')) {
|
||||
$query->whereHas('cat', function($q) {
|
||||
$q->where('nama_kucing', 'like', '%' . $this->request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Filter 2: Nama PIC (Staf)
|
||||
if ($this->request->filled('pic_id')) {
|
||||
$query->where('user_id', $this->request->pic_id);
|
||||
}
|
||||
|
||||
// Filter 3: Rentang Tanggal
|
||||
if ($this->request->filled('start_date') && $this->request->filled('end_date')) {
|
||||
$query->whereBetween('tanggal', [$this->request->start_date, $this->request->end_date]);
|
||||
}
|
||||
|
||||
// Filter 4: Kondisi (Healthy / Under Treatment)
|
||||
if ($this->request->filled('status')) {
|
||||
$query->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', $this->request->status);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->latest('tanggal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Header Tabel Excel
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
"TANGGAL",
|
||||
"NAMA KUCING",
|
||||
"STATUS KESEHATAN",
|
||||
"KELUHAN",
|
||||
"DIAGNOSA & TINDAKAN",
|
||||
"DAFTAR OBAT (QTY)",
|
||||
"DOSIS & KET",
|
||||
"PIC / STAF"
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformasi Data per Baris
|
||||
*/
|
||||
public function map($t): array
|
||||
{
|
||||
// Menggabungkan daftar obat menjadi satu string jika ada multi-medicine
|
||||
$medicineList = $t->details->map(function($d) {
|
||||
$namaObat = $d->medicine->nama_obat ?? 'Obat Terhapus';
|
||||
return "{$namaObat} ({$d->qty_used})";
|
||||
})->implode(", ");
|
||||
|
||||
// Jika data detail kosong, gunakan data fallback (legacy support)
|
||||
if ($medicineList === "") {
|
||||
$medicineList = $t->nama_obat ? "{$t->nama_obat} ({$t->qty_used})" : "-";
|
||||
}
|
||||
|
||||
// Mengambil deskripsi dosis/keterangan dari detail pertama atau header
|
||||
$dosisKet = $t->details->map(function($d) {
|
||||
return "{$d->dosis} [{$d->keterangan}]";
|
||||
})->implode(" | ");
|
||||
|
||||
return [
|
||||
$t->tanggal->format('d/m/Y'),
|
||||
$t->cat->nama_kucing ?? 'N/A',
|
||||
($t->cat->treatment_status == 1) ? 'UNDER TREATMENT' : 'HEALTHY',
|
||||
$t->keluhan,
|
||||
$t->diagnosa,
|
||||
$medicineList,
|
||||
$dosisKet ?: $t->dosis,
|
||||
$t->user->name ?? 'System'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Memberikan Styling pada file Excel
|
||||
*/
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
// Style Header (Baris 1)
|
||||
1 => [
|
||||
'font' => [
|
||||
'bold' => true,
|
||||
'color' => ['rgb' => 'FFFFFF'],
|
||||
'size' => 11
|
||||
],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4F46E5'] // Indigo-600
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
|
||||
],
|
||||
],
|
||||
|
||||
// Memberikan border ke seluruh data yang ada
|
||||
'A1:H' . ($sheet->getHighestRow()) => [
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN,
|
||||
'color' => ['rgb' => 'E2E8F0'], // Slate-200
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class UnifiedReportExport implements FromCollection, WithHeadings, WithMapping,
|
||||
$sheet = $event->sheet;
|
||||
|
||||
// Menulis Metadata
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC REPORT');
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - LAPORAN TERPADU');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email', 'password' => 'required|string', 'device_name' => 'nullable|string|max:100']);
|
||||
$user = User::where('email', strtolower($data['email']))->first();
|
||||
if (!$user || !Hash::check($data['password'], $user->password)) {
|
||||
return response()->json(['message' => 'Email atau kata sandi tidak sesuai.'], 422);
|
||||
}
|
||||
$plain = Str::random(80);
|
||||
ApiToken::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $data['device_name'] ?? 'Android',
|
||||
'token_hash' => hash('sha256', $plain),
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
return response()->json(['token' => $plain, 'token_type' => 'Bearer', 'user' => $this->userPayload($user)]);
|
||||
}
|
||||
public function me(Request $request) { return response()->json(['user' => $this->userPayload($request->user())]); }
|
||||
public function logout(Request $request) { $request->attributes->get('api_token')?->delete(); return response()->json(['message' => 'Sesi berhasil diakhiri.']); }
|
||||
private function userPayload(User $user): array { return ['id'=>$user->id,'name'=>$user->name,'email'=>$user->email,'role'=>$user->roleModel?->name ?? $user->getRawOriginal('role')]; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Matting;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BreedingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$items=Matting::with(['sire:id,nama_kucing,foto','dam:id,nama_kucing,foto','kittens.cat:id,nama_kucing,foto'])
|
||||
->when($request->status,fn($q,$v)=>$q->where('status',$v))->latest('tgl_kawin_awal')->paginate(15);
|
||||
$items->getCollection()->transform(fn($m)=>$this->payload($m));
|
||||
return response()->json($items);
|
||||
}
|
||||
public function show(Matting $matting) { Gate::authorize('check-access', ['matting', 'read']); $matting->load(['sire','dam','kittens.cat.registrations.association']); return response()->json(['data'=>$this->payload($matting,true)]); }
|
||||
private function payload(Matting $m,bool $detail=false): array
|
||||
{
|
||||
$edd=$m->tgl_kawin_awal ? Carbon::parse($m->tgl_kawin_awal)->addDays(63) : null;
|
||||
return ['id'=>$m->id,'sire'=>['id'=>$m->sire_id,'nama'=>$m->sire?->nama_kucing],'dam'=>['id'=>$m->dam_id,'nama'=>$m->dam?->nama_kucing],'tanggal_kawin_awal'=>$m->tgl_kawin_awal?->format('Y-m-d'),'tanggal_kawin_akhir'=>$m->tgl_kawin_akhir?->format('Y-m-d'),'perkiraan_lahir'=>$edd?->format('Y-m-d'),'status'=>$m->status,'jumlah_anak_hidup'=>$m->jumlah_anak_hidup,'anakan'=>$m->kittens->map(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'foto'=>$k->cat?->foto_url])->values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class CatController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cats = Cat::with(['registrations.association'])
|
||||
->when($request->search, fn($q,$s)=>$q->where(fn($x)=>$x->where('nama_kucing','like',"%$s%")->orWhere('microchip','like',"%$s%")->orWhere('kode_ems','like',"%$s%")))
|
||||
->when($request->gender, fn($q,$v)=>$q->where('jenis_kelamin',$v))
|
||||
->orderBy('nama_kucing')->paginate(min((int)$request->get('per_page',20),50));
|
||||
$cats->getCollection()->transform(fn($c)=>$this->payload($c));
|
||||
return response()->json($cats);
|
||||
}
|
||||
public function show(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cat->load(['sire:id,nama_kucing','dam:id,nama_kucing','registrations.association','registrations.documents','weightHistories'=>fn($q)=>$q->latest('tanggal_timbang')->take(30)]);
|
||||
return response()->json(['data'=>$this->payload($cat,true)]);
|
||||
}
|
||||
private function payload(Cat $c, bool $detail=false): array
|
||||
{
|
||||
$base=['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'ras'=>$c->ras,'jenis_kelamin'=>$c->jenis_kelamin,'tanggal_lahir'=>$c->tanggal_lahir?->format('Y-m-d'),'usia'=>$c->age,'kode_ems'=>$c->kode_ems,'microchip'=>$c->microchip,'status'=>$c->status,'berat_terakhir'=>(float)($c->berat_badan ?? $c->berat_terakhir ?? 0),'lokasi'=>$c->lokasi_kandang,'registrasi'=>$c->registrations->map(fn($r)=>['id'=>$r->id,'asosiasi'=>$r->association?->code,'nama_asosiasi'=>$r->association?->name,'nomor_registrasi'=>$r->registration_number,'nomor_pedigree'=>$r->pedigree_number,'nomor_sertifikat'=>$r->certificate_number,'title'=>$r->title,'utama'=>$r->is_primary])->values()];
|
||||
if($detail){$base['induk_jantan']=$c->sire?->nama_kucing;$base['induk_betina']=$c->dam?->nama_kucing;$base['riwayat_berat']=$c->weightHistories->map(fn($w)=>['id'=>$w->id,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang])->values();}
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use App\Models\Kitten;
|
||||
use App\Models\Matting;
|
||||
use App\Models\WeightHistory;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$now = now();
|
||||
$upcoming = Matting::with(['sire:id,nama_kucing','dam:id,nama_kucing'])
|
||||
->where('status', 'proses')->whereNotNull('tgl_kawin_awal')->latest('tgl_kawin_awal')->take(20)->get()
|
||||
->map(function ($m) use ($now) {
|
||||
$edd = Carbon::parse($m->tgl_kawin_awal)->addDays(63);
|
||||
return ['id'=>$m->id,'sire'=>$m->sire?->nama_kucing,'dam'=>$m->dam?->nama_kucing,'tanggal_kawin'=>$m->tgl_kawin_awal?->format('Y-m-d'),'perkiraan_lahir'=>$edd->format('Y-m-d'),'sisa_hari'=>$now->startOfDay()->diffInDays($edd->copy()->startOfDay(), false)];
|
||||
})->filter(fn($v) => $v['sisa_hari'] >= -3 && $v['sisa_hari'] <= 30)->values();
|
||||
|
||||
return response()->json([
|
||||
'ringkasan' => [
|
||||
'total_kucing' => Cat::count(),
|
||||
'jantan' => Cat::where('jenis_kelamin','Jantan')->count(),
|
||||
'betina' => Cat::where('jenis_kelamin','Betina')->count(),
|
||||
'breeding_aktif' => Matting::where('status','proses')->count(),
|
||||
'total_anakan' => Kitten::count(),
|
||||
],
|
||||
'perkiraan_kelahiran' => $upcoming,
|
||||
'berat_terbaru' => WeightHistory::with('cat:id,nama_kucing,foto')->latest('tanggal_timbang')->take(8)->get()->map(fn($w)=>['cat_id'=>$w->cat_id,'nama'=>$w->cat?->nama_kucing,'foto'=>$w->cat?->foto_url,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang]),
|
||||
'kucing_terbaru' => Cat::latest()->take(6)->get(['id','nama_kucing','foto','jenis_kelamin','kode_ems','status'])->map(fn($c)=>['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'jenis_kelamin'=>$c->jenis_kelamin,'kode_ems'=>$c->kode_ems,'status'=>$c->status]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Kitten;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class KittenController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$items=Kitten::with(['cat:id,nama_kucing,foto,induk_jantan_id,induk_betina_id','matting.sire:id,nama_kucing','matting.dam:id,nama_kucing'])
|
||||
->when($request->search,fn($q,$s)=>$q->where('nama','like',"%$s%"))->latest('tgl_lahir')->paginate(20);
|
||||
$items->getCollection()->transform(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'foto'=>$k->cat?->foto_url,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'sire'=>$k->matting?->sire?->nama_kucing,'dam'=>$k->matting?->dam?->nama_kucing]);
|
||||
return response()->json($items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class WeightController extends Controller
|
||||
{
|
||||
public function index(Cat $cat) { Gate::authorize('check-access', ['cats', 'read']); return response()->json(['data'=>$cat->weightHistories()->orderByDesc('tanggal_timbang')->paginate(30)]); }
|
||||
public function store(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$data=$request->validate(['berat'=>'required|numeric|min:0.05|max:30','tanggal'=>'nullable|date|before_or_equal:today']);
|
||||
$history=$cat->weightHistories()->create(['berat'=>$data['berat'],'tanggal_timbang'=>$data['tanggal'] ?? today()]);
|
||||
DB::table('cats')->where('id',$cat->id)->update(['berat_badan'=>$data['berat'],'berat_terakhir'=>$data['berat'],'updated_at'=>now()]);
|
||||
return response()->json(['message'=>'Berat badan berhasil disimpan.','data'=>$history],201);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\{Matting, Cat, Kitten};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\{Request, RedirectResponse, JsonResponse};
|
||||
use Illuminate\Support\Facades\{DB, Storage};
|
||||
use Illuminate\Support\Facades\{DB, Storage, Gate};
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MattingController extends Controller
|
||||
@@ -15,6 +15,7 @@ class MattingController extends Controller
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$mattings = Matting::with(['sire', 'dam'])
|
||||
->withCount('kittens')
|
||||
->latest()
|
||||
@@ -25,6 +26,7 @@ class MattingController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'create']);
|
||||
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']);
|
||||
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']);
|
||||
return view('pages.matting.create', compact('sires', 'dams'));
|
||||
@@ -32,6 +34,7 @@ class MattingController extends Controller
|
||||
|
||||
public function getCatData($id): JsonResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$cat = Cat::find($id);
|
||||
if (!$cat) return response()->json(['error' => 'Kucing tidak ditemukan'], 404);
|
||||
|
||||
@@ -48,6 +51,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'create']);
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request) {
|
||||
@@ -79,6 +83,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function edit(Matting $matting): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
// Load relasi agar data anak muncul di form edit
|
||||
$matting->load('kittens.cat');
|
||||
|
||||
@@ -93,6 +98,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $matting) {
|
||||
@@ -157,14 +163,12 @@ class MattingController extends Controller
|
||||
private function syncParentData(Request $request)
|
||||
{
|
||||
Cat::where('id', $request->sire_id)->update([
|
||||
'pedigree' => $request->no_pedigree_bapak,
|
||||
'microchip' => $request->no_microchip_bapak,
|
||||
'tanggal_lahir' => $request->tgl_lahir_sire,
|
||||
'kode_ems' => $request->ems_bapak // Sinkron EMS Bapak
|
||||
]);
|
||||
|
||||
Cat::where('id', $request->dam_id)->update([
|
||||
'pedigree' => $request->no_pedigree_ibu,
|
||||
'microchip' => $request->no_microchip_ibu,
|
||||
'tanggal_lahir' => $request->tgl_lahir_dam,
|
||||
'kode_ems' => $request->ems_ibu // Sinkron EMS Ibu
|
||||
@@ -218,6 +222,7 @@ class MattingController extends Controller
|
||||
|
||||
public function destroy(Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'delete']);
|
||||
return DB::transaction(function () use ($matting) {
|
||||
try {
|
||||
$pairName = ($matting->sire->nama_kucing ?? 'Sire') . ' x ' . ($matting->dam->nama_kucing ?? 'Dam');
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\{Cat, User, Matting};
|
||||
use App\Models\{Cat, User, Matting,Medicine};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\{Storage, DB, Gate}; // Tambahkan Gate
|
||||
use Illuminate\Support\Facades\{Storage, DB, Gate,Cache}; // Tambahkan Gate
|
||||
use Illuminate\Support\Carbon;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Imports\CatWeightImport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Exports\CatWeightTemplateExport;
|
||||
use Maatwebsite\Excel\Validators\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class CatController extends Controller
|
||||
{
|
||||
@@ -21,47 +28,106 @@ class CatController extends Controller
|
||||
/**
|
||||
* Menampilkan daftar kucing dengan filter cerdas.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses: Read
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses: 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
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_kucing', 'like', "%{$search}%")
|
||||
->orWhere('ras', 'like', "%{$search}%")
|
||||
->orWhere('microchip', 'like', "%{$search}%")
|
||||
->orWhere('pedigree', 'like', "%{$search}%")
|
||||
->orWhere('kode_ems', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Filter Spesifik
|
||||
if ($request->filled('status')) $query->where('status', $request->status);
|
||||
if ($request->filled('ras')) $query->where('ras', $request->ras);
|
||||
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
|
||||
|
||||
$cats = $query->latest()->paginate(15)->withQueryString();
|
||||
|
||||
// 3. Statistik Dashboard (Tetap Utuh)
|
||||
$totalCats = Cat::count();
|
||||
$safeWords = ['sehat', 'normal', 'aktif', 'sembuh', '-', 'tidak ada', 'clean'];
|
||||
$sickCats = Cat::whereNotNull('riwayat_sakit_terakhir')
|
||||
->where(function($q) use ($safeWords) {
|
||||
foreach ($safeWords as $word) {
|
||||
$q->where('riwayat_sakit_terakhir', 'NOT LIKE', "%{$word}%");
|
||||
}
|
||||
})->count();
|
||||
|
||||
$breeds = Cat::distinct()->pluck('ras');
|
||||
|
||||
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
|
||||
// 1. PENCARIAN GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_kucing', 'like', "%{$search}%")
|
||||
->orWhere('ras', 'like', "%{$search}%")
|
||||
->orWhere('microchip', 'like', "%{$search}%")
|
||||
->orWhere('pedigree', 'like', "%{$search}%")
|
||||
->orWhere('kode_ems', 'like', "%{$search}%")
|
||||
->orWhere('lokasi_kandang', 'like', "%{$search}%")
|
||||
->orWhereHas('pic', function($sub) use ($search) {
|
||||
$sub->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. FILTER OVERDUE (Link dari Dashboard)
|
||||
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('gender')) $query->where('jenis_kelamin', $request->gender);
|
||||
|
||||
// 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';
|
||||
|
||||
if ($sort === 'pic_name') {
|
||||
$query->leftJoin('users', 'cats.pic_id', '=', 'users.id')
|
||||
->select('cats.*')
|
||||
->orderBy('users.name', $direction);
|
||||
} else {
|
||||
$query->orderBy("cats.$sort", $direction);
|
||||
}
|
||||
|
||||
// 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'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
// Proteksi Akses: Create
|
||||
@@ -75,47 +141,93 @@ class CatController extends Controller
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'create']);
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'create']);
|
||||
|
||||
$validated = $this->validateCat($request);
|
||||
// 1. Validasi
|
||||
$validated = $this->validateCat($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated) {
|
||||
if ($request->hasFile('foto')) {
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
return DB::transaction(function () use ($request, $validated) {
|
||||
// 2. Handling Upload Foto (Fix: Encoding Intervention v3)
|
||||
if ($request->hasFile('foto')) {
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if (isset($validated['berat_badan'])) {
|
||||
$validated['berat_terakhir'] = $validated['berat_badan'];
|
||||
}
|
||||
// 3. Handling Berat Badan (Sederhanakan)
|
||||
// Cukup isi berat_badan, Model Cat akan otomatis mengisi berat_terakhir (via static::saving)
|
||||
if ($request->filled('berat_badan')) {
|
||||
$validated['berat_badan'] = $request->berat_badan;
|
||||
}
|
||||
|
||||
if (empty($validated['status'])) {
|
||||
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
|
||||
}
|
||||
// 4. Handling Status Otomatis
|
||||
if (empty($validated['status'])) {
|
||||
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
|
||||
}
|
||||
|
||||
// Memastikan registry disimpan sebagai JSON jika model tidak melakukan casting
|
||||
if (isset($validated['registry'])) {
|
||||
$validated['registry'] = $validated['registry'];
|
||||
}
|
||||
// 5. Simpan Data
|
||||
// Pastikan kolom-kolom induk juga masuk ke validated jika ada di request
|
||||
$validated['induk_jantan_id'] = $request->induk_jantan_id;
|
||||
$validated['induk_betina_id'] = $request->induk_betina_id;
|
||||
$validated['pic_id'] = $request->pic_id ?? auth()->id();
|
||||
|
||||
$cat = Cat::create($validated);
|
||||
$this->logAction('CREATED', $cat, "Mendaftarkan pasien baru: {$cat->nama_kucing}", 'Cat');
|
||||
$cat = Cat::create($validated);
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
|
||||
});
|
||||
}
|
||||
// 6. Logging (Pastikan Trait LogsActivity & Tabel Log sudah punya kolom 'model')
|
||||
try {
|
||||
$this->logAction('CREATED', $cat, "Mendaftarkan kucing baru: {$cat->nama_kucing}", 'Cat');
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::warning("Log gagal dicatat: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix: Perbaikan cara simpan gambar pada Intervention Image v3
|
||||
*/
|
||||
private function handleImageUpload($file): string
|
||||
{
|
||||
$filename = 'cat_' . uniqid() . '.png';
|
||||
$path = 'cats/' . $filename;
|
||||
|
||||
// Intervention v3: Gunakan encode() atau toPng() lalu simpan sebagai string
|
||||
$img = Image::read($file)->cover(500, 500, 'center');
|
||||
|
||||
// Simpan ke disk public
|
||||
Storage::disk('public')->put($path, $img->toPng()->toString());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function show(Cat $cat): View
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
{
|
||||
// 1. Otorisasi (TETAP ADA)
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load([
|
||||
'treatments' => fn($q) => $q->latest(),
|
||||
'sire', 'dam', 'pic',
|
||||
'weightHistories' => fn($q) => $q->latest()->limit(12)
|
||||
]);
|
||||
return view('pages.cats.show', compact('cat'));
|
||||
}
|
||||
// 2. Eager Loading (DITAMBAHKAN medicalRecords & performer)
|
||||
// Kita memuat medicalRecords agar tidak terjadi query berulang di dalam Loop Blade (N+1 Problem)
|
||||
$cat->load([
|
||||
'treatments' => fn($q) => $q->latest(),
|
||||
'medicalRecords' => fn($q) => $q->latest(), // Baru: Riwayat Vaksin/Obat Cacing
|
||||
'medicalRecords.performer', // Baru: Siapa yang melakukan tindakan
|
||||
'sire',
|
||||
'dam',
|
||||
'pic',
|
||||
'weightHistories' => fn($q) => $q->latest()->limit(12),
|
||||
'registrations.association',
|
||||
'registrations.documents'
|
||||
]);
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -130,30 +242,46 @@ class CatController extends Controller
|
||||
}
|
||||
|
||||
public function update(Request $request, Cat $cat): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
{
|
||||
// 1. Otorisasi Akses
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$validated = $this->validateCat($request, $cat->id);
|
||||
// 2. Validasi (Mengirimkan $cat->id agar Rule:unique mengabaikan record ini sendiri)
|
||||
$validated = $this->validateCat($request, $cat->id);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
if ($request->hasFile('foto')) {
|
||||
if ($cat->foto) Storage::disk('public')->delete($cat->foto);
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
|
||||
// 3. Handling Foto
|
||||
if ($request->hasFile('foto')) {
|
||||
// Hapus foto lama jika ada
|
||||
if ($cat->foto && Storage::disk('public')->exists($cat->foto)) {
|
||||
Storage::disk('public')->delete($cat->foto);
|
||||
}
|
||||
// Upload foto baru menggunakan helper yang sudah diperbaiki
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if ($request->filled('berat_badan')) {
|
||||
$validated['berat_terakhir'] = $request->berat_badan;
|
||||
if ($cat->berat_badan != $request->berat_badan) {
|
||||
$cat->weightHistories()->create(['berat' => $request->berat_badan]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 4. PENYEMPURNAAN REGISTRY (FIX UPDATE)
|
||||
* Jika di form semua checkbox tidak dicentang, $request->registry akan kosong.
|
||||
* Kita paksa nilainya menjadi ['NON-PED'] agar data lama di DB terhapus/terganti.
|
||||
*/
|
||||
$validated['registry'] = $request->input('registry', ['NON-PED']);
|
||||
|
||||
$cat->update($validated);
|
||||
$this->logAction('UPDATED', $cat, "Update data medis/profil: {$cat->nama_kucing}", 'Cat');
|
||||
// 5. Update Data Utama
|
||||
// Logika berat_badan sudah otomatis dihandle oleh Model (static::saving/updated)
|
||||
$cat->update($validated);
|
||||
|
||||
return redirect()->route('cats.index')->with('success', 'Data berhasil diperbarui.');
|
||||
});
|
||||
}
|
||||
// 6. Activity Log (Dibalut Try-Catch agar proses update tidak gagal hanya karena log)
|
||||
try {
|
||||
$this->logAction('UPDATED', $cat, "Memperbarui profil medis & identitas: {$cat->nama_kucing}", 'Cat');
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::warning("Activity Log gagal dicatat: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "Data {$cat->nama_kucing} berhasil diperbarui.");
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Cat $cat): RedirectResponse
|
||||
{
|
||||
@@ -189,71 +317,224 @@ class CatController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF ID Card / Medical Pass
|
||||
* PENYEMPURNAAN: Perbaikan Barcode (Base64 SVG)
|
||||
*/
|
||||
public function downloadPdf(Cat $cat)
|
||||
//cetak download health ledger pdf
|
||||
|
||||
public function downloadReport(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load([
|
||||
'medicalRecords' => fn($q) => $q->orderBy('date_given', 'desc'),
|
||||
'medicalRecords.performer',
|
||||
'treatments' => fn($q) => $q->orderBy('tanggal', 'desc'), // <--- Pastikan ini ada
|
||||
'sire',
|
||||
'dam',
|
||||
'weightHistories' => fn($q) => $q->orderBy('created_at', 'desc')
|
||||
]);
|
||||
|
||||
$groupedHistory = $cat->medicalRecords->groupBy('type');
|
||||
|
||||
$pdf = Pdf::loadView('pages.reports.medical_history', compact('cat', 'groupedHistory'))
|
||||
->setPaper('a4', 'portrait');
|
||||
|
||||
$filename = "Medical_Report_" . Str::slug($cat->nama_kucing) . "_" . date('Ymd') . ".pdf";
|
||||
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
|
||||
public function bulkWeightPage(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load(['pic', 'sire', 'dam']);
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// FIX: Konversi SVG ke Base64 agar tampil di domPDF tanpa Imagick
|
||||
$qrCodeRaw = QrCode::format('svg')
|
||||
->size(150)
|
||||
->margin(1)
|
||||
->generate(route('cats.show', $cat->id));
|
||||
|
||||
$qrCode = base64_encode($qrCodeRaw);
|
||||
return view('pages.cats.bulk-weight');
|
||||
}
|
||||
|
||||
$pdf = Pdf::loadView('pages.cats.pdf-card', [
|
||||
'cat' => $cat,
|
||||
'qrCode' => $qrCode, // Dikirim sebagai Base64
|
||||
'printedAt' => Carbon::now()->translatedFormat('d F Y H:i')
|
||||
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.',
|
||||
]);
|
||||
|
||||
return $pdf->setPaper([0, 0, 300, 500], 'portrait')
|
||||
->stream('MEDICAL_PASS_' . strtoupper($cat->nama_kucing) . '.pdf');
|
||||
try {
|
||||
// 1. Masukkan class import ke dalam variabel
|
||||
$importHelper = new CatWeightImport();
|
||||
|
||||
// 2. Jalankan proses import menggunakan variabel tersebut
|
||||
Excel::import($importHelper, $request->file('file_excel'));
|
||||
|
||||
// 3. Ambil jumlah data yang berhasil diproses
|
||||
$totalBerhasil = $importHelper->getImportedCount();
|
||||
|
||||
// 4. Sisipkan jumlahnya ke dalam pesan sukses
|
||||
return redirect()->route('cats.index')
|
||||
->with('success', "Data berat badan massal berhasil diproses! Sebanyak {$totalBerhasil} record berhasil disinkronisasi ke dalam database.");
|
||||
|
||||
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
|
||||
return back()->with('error', 'Tipe file tidak dikenali. Pastikan file tidak rusak (corrupt).');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// MENANGKAP ERROR SISTEM / DATABASE
|
||||
return back()->with('error', 'Gagal memproses file Excel. Pastikan format kolom sesuai template. Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan/Update Sertifikat Pedigree
|
||||
*/
|
||||
public function uploadPedigree(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$request->validate(['sertifikat_ped_file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240']);
|
||||
|
||||
$registration = $cat->registrations()->where('is_primary', true)->first() ?: $cat->registrations()->first();
|
||||
if (!$registration) {
|
||||
return redirect()->route('cats.pedigree', $cat)->with('error', 'Tambahkan registrasi asosiasi terlebih dahulu sebelum mengunggah sertifikat.');
|
||||
}
|
||||
|
||||
$path = $request->file('sertifikat_ped_file')->store('cat-registrations/' . $cat->id, 'public');
|
||||
$registration->documents()->create([
|
||||
'label' => 'Sertifikat Pedigree',
|
||||
'document_type' => 'pedigree',
|
||||
'file_path' => $path,
|
||||
]);
|
||||
|
||||
return redirect()->route('cats.pedigree', $cat)->with('success', 'Sertifikat pedigree berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* HELPERS (TETAP UTUH) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
private function validateCat(Request $request, $id = null)
|
||||
|
||||
private function validateCat(Request $request, $id = null)
|
||||
{
|
||||
return $request->validate([
|
||||
// Identitas Dasar
|
||||
'nama_kucing' => 'required|string|max:255',
|
||||
'ras' => 'required|string|max:100',
|
||||
'jenis_kelamin' => 'required|in:Jantan,Betina',
|
||||
'tanggal_lahir' => 'nullable|date|before_or_equal:today',
|
||||
|
||||
// Media
|
||||
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
|
||||
|
||||
// Registry & Legalitas (Ditambahkan NON-PED & Unik)
|
||||
'registry' => 'nullable|array',
|
||||
'registry.*' => ['string', \Illuminate\Validation\Rule::in(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'])],
|
||||
'pedigree' => [
|
||||
'nullable', 'string', 'max:255',
|
||||
Rule::unique('cats', 'pedigree')->ignore($id)
|
||||
],
|
||||
'microchip' => [
|
||||
'nullable', 'string', 'max:255',
|
||||
Rule::unique('cats', 'microchip')->ignore($id)
|
||||
],
|
||||
'kode_ems' => 'nullable|string|max:50',
|
||||
|
||||
// Status & Lokasi
|
||||
'status' => 'nullable|string|max:50',
|
||||
'lokasi_kandang'=> 'nullable|string|max:255',
|
||||
'pic_id' => 'nullable|exists:users,id',
|
||||
|
||||
// Silsilah (Validasi Induk harus sesuai Gender)
|
||||
'induk_jantan_id' => [
|
||||
'nullable',
|
||||
Rule::exists('cats', 'id')->where(function ($query) {
|
||||
$query->where('jenis_kelamin', 'Jantan');
|
||||
}),
|
||||
],
|
||||
'induk_betina_id' => [
|
||||
'nullable',
|
||||
Rule::exists('cats', 'id')->where(function ($query) {
|
||||
$query->where('jenis_kelamin', 'Betina');
|
||||
}),
|
||||
],
|
||||
|
||||
// Medis & Monitoring
|
||||
'berat_badan' => 'nullable|numeric|min:0|max:20',
|
||||
'riwayat_sakit_terakhir' => 'nullable|string',
|
||||
'jadwal_vaksin' => 'nullable|date',
|
||||
'jadwal_obat_cacing' => 'nullable|date',
|
||||
'jadwal_obat_kutu' => 'nullable|date',
|
||||
'jadwal_grooming' => 'nullable|date',
|
||||
], [
|
||||
// Custom Message (Opsional agar user paham kenapa gagal)
|
||||
'induk_jantan_id.exists' => 'Induk jantan harus kucing dengan jenis kelamin Jantan.',
|
||||
'induk_betina_id.exists' => 'Induk betina harus kucing dengan jenis kelamin Betina.',
|
||||
'microchip.unique' => 'Nomor Microchip sudah terdaftar di sistem.',
|
||||
'tanggal_lahir.before_or_equal' => 'Tanggal lahir tidak boleh melebihi hari ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function completeTreatment(Request $request, Cat $cat)
|
||||
{
|
||||
return $request->validate([
|
||||
'nama_kucing' => 'required|string|max:255',
|
||||
'ras' => 'required|string',
|
||||
'jenis_kelamin' => 'required|in:Jantan,Betina',
|
||||
'tanggal_lahir' => 'nullable|date',
|
||||
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
|
||||
'registry' => 'nullable|array',
|
||||
'registry.*' => 'string',
|
||||
'pedigree' => 'nullable|string|max:255',
|
||||
'microchip' => 'nullable|string|max:255',
|
||||
'kode_ems' => 'nullable|string|max:255',
|
||||
'status' => 'nullable|string',
|
||||
'induk_jantan_id' => 'nullable|exists:cats,id',
|
||||
'induk_betina_id' => 'nullable|exists:cats,id',
|
||||
'lokasi_kandang' => 'nullable|string',
|
||||
'pic_id' => 'nullable|exists:users,id',
|
||||
'berat_badan' => 'nullable|numeric|min:0',
|
||||
'riwayat_sakit_terakhir' => 'nullable|string',
|
||||
'jadwal_vaksin' => 'nullable|date',
|
||||
'jadwal_obat_cacing' => 'nullable|date',
|
||||
'jadwal_obat_kutu' => 'nullable|date',
|
||||
'jadwal_grooming' => 'nullable|date',
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
'treatment_id' => 'required|exists:treatments,id',
|
||||
'remark_pasca_sakit' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
// ========================================================
|
||||
// 1. EKSEKUSI PAKSA TABEL TREATMENTS (JALUR KERAS)
|
||||
// DB::table akan menembus langsung ke database MySQL Anda.
|
||||
// ========================================================
|
||||
DB::table('treatments')
|
||||
->where('id', $request->treatment_id)
|
||||
->update([
|
||||
'status_perawatan' => 2,
|
||||
'remark_pasca_sakit' => $request->remark_pasca_sakit,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Ambil nama keluhan untuk dimunculkan di notifikasi
|
||||
$namaKeluhan = DB::table('treatments')->where('id', $request->treatment_id)->value('keluhan');
|
||||
|
||||
// ========================================================
|
||||
// 2. CEK SISA PENYAKIT (Adakah penyakit lain di kucing ini yang masih = 1?)
|
||||
// ========================================================
|
||||
$sisaPenyakitAktif = DB::table('treatments')
|
||||
->where('cat_id', $cat->id)
|
||||
->where('status_perawatan', 1)
|
||||
->exists();
|
||||
|
||||
// ========================================================
|
||||
// 3. EKSEKUSI TABEL CATS BERDASARKAN HASIL CEK
|
||||
// ========================================================
|
||||
if (!$sisaPenyakitAktif) {
|
||||
// JIKA SUDAH TIDAK ADA PENYAKIT LAIN -> Kucing Full Sehat (2)
|
||||
$cat->treatment_status = 2;
|
||||
$cat->save();
|
||||
|
||||
return redirect()->back()->with('success', "Penyakit '{$namaKeluhan}' disembuhkan. Kucing sekarang berstatus SEHAT sepenuhnya!");
|
||||
}
|
||||
|
||||
// JIKA MASIH ADA PENYAKIT LAIN -> Pastikan kucing TETAP berstatus Treatment (1)
|
||||
$cat->treatment_status = 1;
|
||||
$cat->save();
|
||||
|
||||
return redirect()->back()->with('warning', "Penyakit '{$namaKeluhan}' disembuhkan, namun kucing MASIH DALAM PERAWATAN untuk keluhan lainnya.");
|
||||
}
|
||||
|
||||
private function handleImageUpload($file): string
|
||||
public function pedigree(Cat $cat)
|
||||
{
|
||||
$filename = 'cat_' . uniqid() . '.png';
|
||||
$path = 'cats/' . $filename;
|
||||
$img = Image::read($file)->cover(500, 500, 'center')->toPng();
|
||||
Storage::disk('public')->put($path, (string) $img);
|
||||
return $path;
|
||||
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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
@@ -18,13 +19,41 @@ class CategoryController extends Controller
|
||||
/**
|
||||
* Menampilkan daftar kategori.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
// Mengambil kategori beserta hitungan item di dalamnya (optional untuk insight)
|
||||
$categories = Category::latest()->get();
|
||||
return view('pages.inventory.categories.index', compact('categories'));
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// 1. Proteksi Akses (Sesuai Matriks Role Management)
|
||||
Gate::authorize('check-access', ['categories', 'read']);
|
||||
|
||||
$query = Category::query();
|
||||
|
||||
// 2. SEARCH GLOBAL (Mencari di Nama & Keywords)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('keywords', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 3. LOGIKA SORTING DINAMIS
|
||||
$allowedSorts = ['name', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'name';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// 4. EKSEKUSI DENGAN PAGINASI (Standarisasi 15 item per halaman)
|
||||
$categories = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK RINGKAS
|
||||
$totalCategories = Category::count();
|
||||
$latestCategory = Category::latest()->first()?->name ?? '-';
|
||||
|
||||
return view('pages.inventory.categories.index', compact(
|
||||
'categories', 'totalCategories', 'latestCategory'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Menyimpan kategori baru.
|
||||
*/
|
||||
@@ -46,13 +75,12 @@ class CategoryController extends Controller
|
||||
* Menampilkan halaman edit (Hanya Admin).
|
||||
*/
|
||||
public function edit(Category $category): View
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
{
|
||||
|
||||
Gate::authorize('check-access', ['categories', 'update']);
|
||||
|
||||
return view('pages.inventory.categories.edit', compact('category'));
|
||||
}
|
||||
return view('pages.inventory.categories.edit', compact('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Memperbarui data kategori.
|
||||
|
||||
@@ -3,86 +3,453 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment};
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment, Kitten};
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 1. Statistik Utama
|
||||
$now = Carbon::now();
|
||||
$selectedMonth = $request->get('month', $now->format('m'));
|
||||
$selectedYear = $request->get('year', $now->format('Y'));
|
||||
$selectedGender = $request->get('gender');
|
||||
$selectedStatus = $request->get('status');
|
||||
|
||||
$stats = [
|
||||
'total_cats' => Cat::count(),
|
||||
'total_staff' => \App\Models\User::count(),
|
||||
'sick_cats' => Cat::whereNotNull('riwayat_sakit_terakhir')->count(),
|
||||
'total_kittens' => Kitten::count(),
|
||||
'sick_cats' => Cat::where('treatment_status', 1)->count(),
|
||||
'total_mating' => Matting::where('status', 'proses')->count(),
|
||||
];
|
||||
|
||||
// 2. Birth Watch: Persalinan 7 Hari ke Depan (Rumus: H+63)
|
||||
$upcomingBirths = Matting::with(['dam', 'sire', 'pic'])
|
||||
$upcomingBirths = Matting::with(['dam', 'sire'])
|
||||
->where('status', 'proses')
|
||||
->whereNotNull('tanggal_kawin')
|
||||
->whereBetween('tanggal_kawin', [
|
||||
now()->subDays(70)->toDateString(),
|
||||
now()->subDays(56)->toDateString()
|
||||
])
|
||||
->whereNotNull('tgl_kawin_awal')
|
||||
->get()
|
||||
->map(function ($mating) {
|
||||
$mating->edd = Carbon::parse($mating->tanggal_kawin)->addDays(63);
|
||||
$mating->days_to_go = (int) now()->diffInDays($mating->edd, false);
|
||||
->map(function ($mating) use ($now) {
|
||||
$mating->edd = Carbon::parse($mating->tgl_kawin_awal)->addDays(63);
|
||||
$mating->days_to_go = (int) $now->copy()->startOfDay()->diffInDays($mating->edd->copy()->startOfDay(), false);
|
||||
return $mating;
|
||||
})
|
||||
->filter(fn($m) => $m->days_to_go >= -2 && $m->days_to_go <= 7)
|
||||
->filter(fn ($mating) => $mating->days_to_go >= -2 && $mating->days_to_go <= 7)
|
||||
->sortBy('days_to_go');
|
||||
|
||||
// 3. Medical Alerts
|
||||
$medicalAlerts = [
|
||||
'vaccine' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<=', now()->addDays(3))->count(),
|
||||
'flea' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<=', now()->addDays(3))->count(),
|
||||
'deworming' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<=', now()->addDays(3))->count(),
|
||||
'grooming' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<=', now()->addDays(3))->count(),
|
||||
$overdueAlerts = [
|
||||
'vaccine' => [
|
||||
'count' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'vaccine']),
|
||||
],
|
||||
'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 = [
|
||||
'medicines' => Medicine::where('qty', '<', 5)->get(['nama_obat as name', 'qty', 'unit']),
|
||||
'foods' => Food::where('qty', '<', 5)->get(['nama_makanan as name', 'qty', 'unit']),
|
||||
'supplies' => Supply::where('qty', '<', 5)->get(['nama_perlengkapan as name', 'qty', 'unit']),
|
||||
];
|
||||
$totalLowStock = collect($lowStock)->flatten(1)->count();
|
||||
|
||||
// 5. Chart Data: Sebaran Ras (Persian, British Shorthair, dll)
|
||||
$chartData = Cat::select('ras', DB::raw('count(*) as total'))->groupBy('ras')->get();
|
||||
|
||||
// 6. Weight Trends: Rata-rata Berat (6 Bulan Terakhir)
|
||||
$weightTrends = DB::table('weight_histories')
|
||||
/*
|
||||
* Berat terakhir untuk daftar/ranking pada bulan yang dipilih.
|
||||
* Kode ini dipertahankan agar fungsi dashboard lama tetap tersedia.
|
||||
*/
|
||||
$weightQuery = DB::table('weight_histories')
|
||||
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
|
||||
->select(
|
||||
DB::raw('AVG(berat) as avg_weight'),
|
||||
DB::raw("DATE_FORMAT(tanggal_timbang, '%M') as month"),
|
||||
DB::raw('MONTH(tanggal_timbang) as month_num')
|
||||
'cats.id as cat_id',
|
||||
'cats.nama_kucing',
|
||||
'cats.jenis_kelamin',
|
||||
'cats.status',
|
||||
'cats.tanggal_lahir',
|
||||
'weight_histories.berat',
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->where('tanggal_timbang', '>=', now()->subMonths(6))
|
||||
->groupBy('month', 'month_num')
|
||||
->orderBy('month_num')
|
||||
->get();
|
||||
->whereIn('weight_histories.id', function ($query) use ($selectedMonth, $selectedYear) {
|
||||
$query->select(DB::raw('MAX(id)'))
|
||||
->from('weight_histories')
|
||||
->whereMonth('tanggal_timbang', $selectedMonth)
|
||||
->whereYear('tanggal_timbang', $selectedYear)
|
||||
->groupBy('cat_id');
|
||||
});
|
||||
|
||||
if ($weightTrends->isEmpty()) {
|
||||
$weightTrends = collect([(object)['month' => now()->format('F'), 'avg_weight' => 0]]);
|
||||
if ($selectedGender) {
|
||||
$weightQuery->where('cats.jenis_kelamin', $selectedGender);
|
||||
}
|
||||
|
||||
// 7. Recent Activity
|
||||
$recentTreatments = Treatment::with(['cat', 'pic'])->latest()->take(5)->get();
|
||||
if ($selectedStatus) {
|
||||
if (in_array(strtolower($selectedStatus), ['adult', 'dewasa'])) {
|
||||
$weightQuery->whereIn('cats.status', ['Adult', 'Dewasa']);
|
||||
} else {
|
||||
$weightQuery->where('cats.status', $selectedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
$individualWeightData = $weightQuery
|
||||
->orderBy('weight_histories.berat', 'desc')
|
||||
->get()
|
||||
->map(function ($cat) use ($now) {
|
||||
$cat->usia = $this->formatAge($cat->tanggal_lahir, $now);
|
||||
$cat->kategori_usia = $this->ageCategory($cat->tanggal_lahir, $cat->status, $now);
|
||||
return $cat;
|
||||
});
|
||||
|
||||
$weightSummary = [
|
||||
'overall' => $individualWeightData->avg('berat') ?? 0,
|
||||
'male' => $individualWeightData->where('jenis_kelamin', 'Jantan')->avg('berat') ?? 0,
|
||||
'female' => $individualWeightData->where('jenis_kelamin', 'Betina')->avg('berat') ?? 0,
|
||||
];
|
||||
|
||||
/*
|
||||
* Pemantauan berat badan.
|
||||
* Kategori umur dihitung dari tanggal lahir agar konsisten:
|
||||
* Kitten < 7 bulan, Junior 7-11 bulan, Dewasa >= 12 bulan.
|
||||
*/
|
||||
$allowedTrendMonths = [3, 6, 12, 24];
|
||||
$weightTrendMonths = (int) $request->get('weight_months', 6);
|
||||
if (!in_array($weightTrendMonths, $allowedTrendMonths, true)) {
|
||||
$weightTrendMonths = 6;
|
||||
}
|
||||
|
||||
// Filter tabel dapat menampilkan semua kategori, sedangkan grafik individual
|
||||
// selalu dibatasi pada satu kategori agar mudah dibaca.
|
||||
$allowedTableCategories = ['Semua', 'Kitten', 'Junior', 'Dewasa'];
|
||||
$weightTableCategory = $request->get('weight_category', 'Semua');
|
||||
if (!in_array($weightTableCategory, $allowedTableCategories, true)) {
|
||||
$weightTableCategory = 'Semua';
|
||||
}
|
||||
|
||||
$allowedTrendCategories = ['Kitten', 'Junior', 'Dewasa'];
|
||||
$defaultTrendCategory = $weightTableCategory !== 'Semua' ? $weightTableCategory : 'Kitten';
|
||||
$weightTrendCategory = $request->get('trend_category', $defaultTrendCategory);
|
||||
if (!in_array($weightTrendCategory, $allowedTrendCategories, true)) {
|
||||
$weightTrendCategory = 'Kitten';
|
||||
}
|
||||
|
||||
$trendStart = $now->copy()->subMonths($weightTrendMonths - 1)->startOfMonth();
|
||||
|
||||
$weightTrendRows = DB::table('weight_histories')
|
||||
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
|
||||
->select(
|
||||
'cats.id as cat_id',
|
||||
'cats.nama_kucing',
|
||||
'cats.jenis_kelamin',
|
||||
'cats.status',
|
||||
'cats.tanggal_lahir',
|
||||
'cats.foto',
|
||||
'weight_histories.berat',
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->whereNull('cats.deleted_at')
|
||||
->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(
|
||||
'stats',
|
||||
'upcomingBirths',
|
||||
'medicalAlerts',
|
||||
'lowStock',
|
||||
'totalLowStock',
|
||||
'chartData',
|
||||
'weightTrends',
|
||||
'recentTreatments'
|
||||
));
|
||||
'stats',
|
||||
'upcomingBirths',
|
||||
'overdueAlerts',
|
||||
'individualWeightData',
|
||||
'weightSummary',
|
||||
'weightTrendLabels',
|
||||
'weightTrendSeries',
|
||||
'categoryAverageTrend',
|
||||
'weightCategorySummary',
|
||||
'weightKpis',
|
||||
'weightLatestTable',
|
||||
'weightTableCategory',
|
||||
'weightTrendCategory',
|
||||
'weightTrendMonths',
|
||||
'lowStock',
|
||||
'totalLowStock',
|
||||
'expiringItems',
|
||||
'selectedMonth',
|
||||
'selectedYear',
|
||||
'selectedGender',
|
||||
'selectedStatus',
|
||||
'topDiseasesWeek',
|
||||
'topDiseasesMonth',
|
||||
'recentTreatments'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function ageCategory($birthDate, ?string $storedStatus, Carbon $referenceDate): string
|
||||
{
|
||||
if ($birthDate) {
|
||||
$birth = Carbon::parse($birthDate);
|
||||
$months = $birth->diffInMonths($referenceDate);
|
||||
|
||||
if ($months < 7) {
|
||||
return 'Kitten';
|
||||
}
|
||||
|
||||
if ($months < 12) {
|
||||
return 'Junior';
|
||||
}
|
||||
|
||||
return 'Dewasa';
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) $storedStatus));
|
||||
return match ($status) {
|
||||
'kitten' => 'Kitten',
|
||||
'junior' => 'Junior',
|
||||
'adult', 'dewasa' => 'Dewasa',
|
||||
default => 'Dewasa',
|
||||
};
|
||||
}
|
||||
|
||||
private function formatAge($birthDate, Carbon $referenceDate): string
|
||||
{
|
||||
if (!$birthDate) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$diff = Carbon::parse($birthDate)->diff($referenceDate);
|
||||
$parts = [];
|
||||
|
||||
if ($diff->y > 0) {
|
||||
$parts[] = $diff->y . ' th';
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
$parts[] = $diff->m . ' bln';
|
||||
}
|
||||
if ($diff->y === 0 && $diff->d > 0) {
|
||||
$parts[] = $diff->d . ' hr';
|
||||
}
|
||||
|
||||
return $parts ? implode(' ', $parts) : '0 hr';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class FoodController extends Controller
|
||||
{
|
||||
@@ -18,17 +19,68 @@ class FoodController extends Controller
|
||||
* Tampilkan daftar stok makanan.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Food::with('category');
|
||||
{
|
||||
// Proteksi Akses
|
||||
Gate::authorize('check-access', ['foods', 'read']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_makanan', 'like', '%' . $request->search . '%');
|
||||
$now = now();
|
||||
$query = Food::with('category');
|
||||
|
||||
// 1. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan makanan yang sudah kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
|
||||
$foods = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.foods.index', compact('foods'));
|
||||
}
|
||||
|
||||
// 2. SEARCH GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_makanan', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 3. SORTING DINAMIS (Menambahkan kembali expired_at)
|
||||
$allowedSorts = ['nama_makanan', 'qty', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_makanan';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$foods = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 4. STATISTIK (Komprehensif)
|
||||
$totalStock = Food::sum('qty');
|
||||
$lowStockCount = Food::where('qty', '<=', 5)->count();
|
||||
|
||||
// Hitung item yang expired (Wajib ada kolom expired_at di database)
|
||||
$expiredCount = Food::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$totalItems = Food::count();
|
||||
|
||||
return view('pages.inventory.foods.index', compact(
|
||||
'foods',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount',
|
||||
'totalItems'
|
||||
));
|
||||
}
|
||||
/**
|
||||
* Tampilkan form tambah.
|
||||
*/
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MedicalDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Tren Penyakit Bulanan (Data 6 Bulan Terakhir)
|
||||
$monthlyTrends = Treatment::select(
|
||||
DB::raw('MONTHNAME(tanggal) as month'),
|
||||
DB::raw('COUNT(*) as total')
|
||||
@@ -21,20 +18,28 @@ class MedicalDashboardController extends Controller
|
||||
->orderBy(DB::raw('MONTH(tanggal)'))
|
||||
->get();
|
||||
|
||||
// 2. Daftar Obat Paling Sering Digunakan (Top 5)
|
||||
$topMedicines = Treatment::select('nama_obat', DB::raw('SUM(qty_used) as total_used'))
|
||||
->groupBy('nama_obat')
|
||||
// treatments memakai medicine_id. Nama obat diambil dari tabel medicines.
|
||||
$topObats = Treatment::query()
|
||||
->join('medicines', 'treatments.medicine_id', '=', 'medicines.id')
|
||||
->select(
|
||||
'medicines.id',
|
||||
'medicines.nama_obat',
|
||||
DB::raw('SUM(treatments.qty_used) as total_used')
|
||||
)
|
||||
->whereNotNull('treatments.medicine_id')
|
||||
->groupBy('medicines.id', 'medicines.nama_obat')
|
||||
->orderByDesc('total_used')
|
||||
->take(5)
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// 3. Keluhan/Penyakit yang Paling Sering Muncul
|
||||
$topComplaints = Treatment::select('keluhan', DB::raw('COUNT(*) as total'))
|
||||
->whereNotNull('keluhan')
|
||||
->where('keluhan', '!=', '')
|
||||
->groupBy('keluhan')
|
||||
->orderByDesc('total')
|
||||
->take(5)
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
return view('pages.dashboard.medical', compact('monthlyTrends', 'topMedicines', 'topComplaints'));
|
||||
return view('pages.dashboard.medical', compact('monthlyTrends', 'topObats', 'topComplaints'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cat;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\MedicalRecord;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Exception;
|
||||
|
||||
class MedicalRecordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Simpan rekam medis individu (dari halaman show cat)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Tambahan Validasi
|
||||
$request->validate([
|
||||
'cat_id' => 'required|exists:cats,id',
|
||||
'type' => 'required|string',
|
||||
'date_given' => 'required|date',
|
||||
'next_schedule' => 'nullable|date|after_or_equal:date_given',
|
||||
'medicines' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request) {
|
||||
$type = $request->type;
|
||||
$medicines = $request->input('medicines', []);
|
||||
|
||||
if ($type === 'Grooming') {
|
||||
$this->createRecord($request->cat_id, $type, 'Routine Grooming', null, 'SESSION', $request);
|
||||
} else {
|
||||
foreach ($medicines as $med) {
|
||||
if (empty($med['name'])) continue;
|
||||
|
||||
$doseValue = $this->parseFraction($med['dosage']);
|
||||
|
||||
// Pengecekan Stok Sebelum Potong
|
||||
$item = Medicine::where('nama_obat', $med['name'])->first();
|
||||
if ($item) {
|
||||
if ($item->qty < $doseValue) {
|
||||
throw new Exception("Stok obat '{$med['name']}' tidak mencukupi. Sisa: {$item->qty}");
|
||||
}
|
||||
$item->decrement('qty', $doseValue);
|
||||
}
|
||||
|
||||
$this->createRecord($request->cat_id, $type, $med['name'], $med['dosage'], $med['unit'], $request);
|
||||
}
|
||||
}
|
||||
|
||||
// Update pengingat di tabel cats
|
||||
$this->updateCatReminder($request->cat_id, $type, $request->next_schedule);
|
||||
|
||||
return back()->with('success', 'Medical Ledger berhasil dicatat dan jadwal otomatis diperbarui.');
|
||||
});
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal mencatat rekam medis: ' . $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form Bulk Treatment
|
||||
*/
|
||||
/**
|
||||
* Tampilkan form Bulk Treatment
|
||||
*/
|
||||
public function bulkCreate()
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Mengambil kucing yang Sehat (2) maupun yang sedang Treatment (1)
|
||||
$cats = Cat::whereIn('treatment_status', [1, 2])
|
||||
->orderBy('nama_kucing', 'asc')
|
||||
->get();
|
||||
|
||||
// Mengambil obat dengan stok yang valid (qty > 0)
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
|
||||
|
||||
return view('pages.cats.medical-bulk', compact('cats', 'availableMedicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan Bulk Treatment (Massal)
|
||||
*/
|
||||
public function bulkStore(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
'cat_ids' => 'required|array|min:1',
|
||||
'type' => 'required',
|
||||
'date_given' => 'required|date',
|
||||
'next_schedule' => 'nullable|date|after_or_equal:date_given',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request) {
|
||||
$catIds = $request->cat_ids;
|
||||
$medicines = $request->input('medicines', []);
|
||||
$totalCats = count($catIds);
|
||||
|
||||
foreach ($catIds as $catId) {
|
||||
if ($request->type === 'Grooming') {
|
||||
$this->createRecord($catId, $request->type, 'Routine Grooming', null, 'SESSION', $request);
|
||||
} else {
|
||||
foreach ($medicines as $med) {
|
||||
if (empty($med['name'])) continue;
|
||||
|
||||
$doseValue = $this->parseFraction($med['dosage']);
|
||||
|
||||
// Validasi stok untuk bulk (kebutuhan = dosis * jumlah kucing)
|
||||
$item = Medicine::where('nama_obat', $med['name'])->first();
|
||||
if ($item) {
|
||||
if ($item->qty < $doseValue) {
|
||||
throw new Exception("Stok '{$med['name']}' tidak cukup untuk pemrosesan massal.");
|
||||
}
|
||||
$item->decrement('qty', $doseValue);
|
||||
}
|
||||
|
||||
$this->createRecord($catId, $request->type, $med['name'], $med['dosage'], $med['unit'], $request);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateCatReminder($catId, $request->type, $request->next_schedule);
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "Bulk Treatment berhasil diterapkan pada $totalCats kucing.");
|
||||
});
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal memproses bulk treatment: ' . $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Simpan data ke tabel medical_records
|
||||
*/
|
||||
/**
|
||||
* Helper: Simpan data ke tabel medical_records
|
||||
*/
|
||||
private function createRecord($catId, $type, $name, $dosage, $unit, $request)
|
||||
{
|
||||
return MedicalRecord::create([
|
||||
'cat_id' => $catId,
|
||||
'user_id' => auth()->id(), // Ini adalah kolom yang benar sesuai migration kita
|
||||
'type' => $type,
|
||||
'treatment_name' => $name,
|
||||
'dosage' => $this->parseFraction($dosage),
|
||||
'unit' => $unit,
|
||||
'date_given' => $request->date_given,
|
||||
'next_schedule' => $request->next_schedule,
|
||||
'notes' => $request->notes
|
||||
// HAPUS baris 'performed_by' di sini
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Update kolom jadwal di tabel cats
|
||||
*/
|
||||
private function updateCatReminder($catId, $type, $nextDate)
|
||||
{
|
||||
if (!$nextDate) return;
|
||||
|
||||
$mapping = [
|
||||
'Vaccine' => 'jadwal_vaksin',
|
||||
'Deworming' => 'jadwal_obat_cacing',
|
||||
'Flea Treatment' => 'jadwal_obat_kutu',
|
||||
'Grooming' => 'jadwal_grooming',
|
||||
];
|
||||
|
||||
if (isset($mapping[$type])) {
|
||||
Cat::where('id', $catId)->update([$mapping[$type] => $nextDate]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Konversi pecahan (1/2) ke desimal (0.5)
|
||||
*/
|
||||
private function parseFraction($fraction)
|
||||
{
|
||||
if (is_null($fraction)) return 0;
|
||||
|
||||
$fraction = str_replace(' ', '', $fraction); // Hapus spasi jika ada
|
||||
|
||||
if (str_contains($fraction, '/')) {
|
||||
list($numerator, $denominator) = explode('/', $fraction);
|
||||
return $denominator > 0 ? (float)$numerator / (float)$denominator : 0;
|
||||
}
|
||||
return (float)$fraction;
|
||||
}
|
||||
|
||||
public function create(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
|
||||
|
||||
|
||||
return view('pages.cats.medical-ledger-create', compact('cat', 'availableMedicines'));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Imports\MedicineImport;
|
||||
use App\Exports\MedicineTemplateExport;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class MedicineController extends Controller
|
||||
{
|
||||
@@ -20,18 +21,74 @@ class MedicineController extends Controller
|
||||
* Tampilkan daftar obat-obatan (Index).
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Medicine::query();
|
||||
{
|
||||
// Proteksi Akses
|
||||
Gate::authorize('check-access', ['medicines', 'read']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_obat', 'like', '%' . $request->search . '%');
|
||||
$now = now();
|
||||
$query = Medicine::query();
|
||||
|
||||
// 1. FILTER KHUSUS (DARI DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan yang sudah lewat tanggal kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang akan kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis (<= 5)
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
|
||||
$medicines = $query->latest()->paginate(10);
|
||||
|
||||
return view('pages.inventory.medicines.index', compact('medicines'));
|
||||
}
|
||||
|
||||
// 2. SEARCH GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_obat', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 3. FILTER KATEGORI (Opsional jika ada dropdown di UI)
|
||||
if ($request->filled('category')) {
|
||||
$query->where('kategori', $request->category);
|
||||
}
|
||||
|
||||
// 4. SORTING DINAMIS
|
||||
// Gunakan 'expired_at' sebagai default sorting jika dari Dashboard 'expired'
|
||||
$allowedSorts = ['nama_obat', 'kategori', 'qty', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_obat';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$medicines = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK BOX (Selalu hitung total keseluruhan, bukan hasil filter)
|
||||
$totalStock = Medicine::sum('qty');
|
||||
$lowStockCount = Medicine::where('qty', '<=', 5)->count();
|
||||
|
||||
// Menghitung barang yang sudah expired (merah)
|
||||
$expiredCount = Medicine::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$categories = Medicine::distinct()->whereNotNull('kategori')->pluck('kategori');
|
||||
|
||||
return view('pages.inventory.medicines.index', compact(
|
||||
'medicines',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount', // Tambahan statistik baru
|
||||
'categories'
|
||||
));
|
||||
}
|
||||
/**
|
||||
* Tampilkan form tambah obat.
|
||||
*/
|
||||
|
||||
@@ -74,17 +74,16 @@ class ReportController extends Controller
|
||||
|
||||
protected function applyModuleFilters($query, $request, $module)
|
||||
{
|
||||
$status = $request->status;
|
||||
$status = $request->status;
|
||||
|
||||
// TAMBAHAN: Logika Filter untuk Modul Cats (Sehat/Sakit)
|
||||
// Ini yang membuat filter status di populasi kucing bekerja
|
||||
// PERBAIKAN 1: Gunakan $request->filled('status') sesuai dengan <select name="status"> di Blade
|
||||
if ($module === 'cats' && $request->filled('status')) {
|
||||
match($status) {
|
||||
'sakit' => $query->whereNotNull('riwayat_sakit_terakhir')->where('riwayat_sakit_terakhir', '!=', ''),
|
||||
'sehat' => $query->where(function($q) {
|
||||
$q->whereNull('riwayat_sakit_terakhir')
|
||||
->orWhere('riwayat_sakit_terakhir', '');
|
||||
}),
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
@@ -13,6 +14,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'read']);
|
||||
$roles = Role::all();
|
||||
// Menambahkan prefix 'pages.' sesuai struktur folder Anda
|
||||
return view('pages.admin.roles.index', compact('roles'));
|
||||
@@ -20,12 +22,14 @@ class RoleController extends Controller
|
||||
|
||||
public function create()
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'create']);
|
||||
// Mengarahkan ke folder pages sesuai standarisasi Anda
|
||||
return view('pages.admin.roles.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'create']);
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'slug' => 'required|string|max:255|unique:roles,slug',
|
||||
@@ -53,6 +57,7 @@ class RoleController extends Controller
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'update']);
|
||||
$role = Role::with('permissions')->findOrFail($id);
|
||||
|
||||
/**
|
||||
@@ -94,6 +99,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'update']);
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
DB::transaction(function () use ($request, $role) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
|
||||
class SettingController extends Controller
|
||||
@@ -28,6 +29,7 @@ class SettingController extends Controller
|
||||
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['settings', 'read']);
|
||||
$settings = Setting::pluck('value', 'key')->all();
|
||||
|
||||
// Tambahkan default jika version belum ada
|
||||
@@ -49,6 +51,7 @@ class SettingController extends Controller
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['settings', 'update']);
|
||||
$request->validate([
|
||||
'app_logo' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
|
||||
'app_favicon' => 'nullable|image|mimes:png,jpg,ico|max:512',
|
||||
|
||||
@@ -9,6 +9,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SupplyController extends Controller
|
||||
{
|
||||
@@ -17,18 +19,74 @@ class SupplyController extends Controller
|
||||
/**
|
||||
* Tampilkan daftar perlengkapan.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Supply::with('category');
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// 1. Proteksi Akses (Sesuai Matriks Role Management)
|
||||
Gate::authorize('check-access', ['supplies', 'read']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_perlengkapan', 'like', '%' . $request->search . '%');
|
||||
$now = now();
|
||||
$query = Supply::with('category');
|
||||
|
||||
// 2. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan perlengkapan yang sudah kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
|
||||
$supplies = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.supplies.index', compact('supplies'));
|
||||
}
|
||||
|
||||
// 3. SEARCH GLOBAL (Mencari di Nama, Kategori, dan Unit)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_perlengkapan', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%")
|
||||
->orWhereHas('category', function($sub) use ($search) {
|
||||
$sub->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. LOGIKA SORTING DINAMIS (Menambahkan expired_at dalam daftar sort)
|
||||
$allowedSorts = ['nama_perlengkapan', 'qty', 'kategori', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_perlengkapan';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// Eksekusi Query dengan Pagination
|
||||
$supplies = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK RINGKAS (Komprehensif)
|
||||
$totalStock = Supply::sum('qty');
|
||||
$lowStockCount = Supply::where('qty', '<=', 5)->count();
|
||||
|
||||
// Hitung item yang expired (Wajib ada kolom expired_at di database)
|
||||
$expiredCount = Supply::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$totalItems = Supply::count();
|
||||
|
||||
return view('pages.inventory.supplies.index', compact(
|
||||
'supplies',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount', // Variabel statistik baru
|
||||
'totalItems'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form tambah.
|
||||
*/
|
||||
@@ -103,24 +161,41 @@ class SupplyController extends Controller
|
||||
/**
|
||||
* Hapus data perlengkapan.
|
||||
*/
|
||||
public function destroy(Supply $supply): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
public function destroy(Supply $supply): RedirectResponse
|
||||
{
|
||||
// 1. OTORISASI DINAMIS (Inilah perbaikan untuk akses Supervisor)
|
||||
// Menggunakan Gate 'check-access' agar sinkron dengan Role Management Anda
|
||||
Gate::authorize('check-access', ['supplies', 'delete']);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$supplyName = $supply->nama_perlengkapan;
|
||||
|
||||
if ($supply->foto_produk) {
|
||||
// 2. PEMBERSIHAN FILE (STORAGE)
|
||||
if ($supply->foto_produk && Storage::disk('public')->exists($supply->foto_produk)) {
|
||||
Storage::disk('public')->delete($supply->foto_produk);
|
||||
}
|
||||
|
||||
// PENTING: Log dilakukan SEBELUM delete agar ID tidak null
|
||||
$this->logAction('DELETED_SUPPLY', $supply, "Menghapus perlengkapan: {$supplyName}");
|
||||
// 3. AUDIT TRAIL
|
||||
// Dicatat sebelum delete agar konteks data $supply masih tersedia secara lengkap
|
||||
if (method_exists($this, 'logAction')) {
|
||||
$this->logAction('DELETE', 'Inventory', "Menghapus perlengkapan: {$supplyName}");
|
||||
}
|
||||
|
||||
// 4. EKSEKUSI HAPUS
|
||||
$supply->delete();
|
||||
|
||||
// FIX: Menggunakan .index
|
||||
return redirect()->route('inventory.supplies.index')->with('success', 'Data perlengkapan berhasil dihapus.');
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('inventory.supplies.index')
|
||||
->with('success', "Perlengkapan '{$supplyName}' berhasil dihapus dari sistem.");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
// Reporting error jika terjadi kegagalan sistem
|
||||
return back()->with('error', 'Gagal menghapus data. Silakan coba lagi atau hubungi IT.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Cat;
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\TreatmentDetail; // Menambahkan model Detail
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -18,167 +19,314 @@ class TreatmentController extends Controller
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Menampilkan daftar rekam medis dengan optimasi Eager Loading.
|
||||
* Menampilkan daftar rekam medis dengan optimasi Eager Loading & Filtering.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$treatments = Treatment::with([
|
||||
'cat:id,nama_kucing,foto',
|
||||
'medicine:id,nama_obat,unit',
|
||||
'user:id,name'
|
||||
])
|
||||
->latest('tanggal')
|
||||
->paginate(15);
|
||||
public function index(Request $request): View|\Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
{
|
||||
// 1. QUERY DASAR DENGAN OPTIMASI EAGER LOADING
|
||||
$query = Treatment::with([
|
||||
'cat:id,nama_kucing,foto,treatment_status',
|
||||
'details.medicine:id,nama_obat,unit',
|
||||
'user:id,name'
|
||||
])
|
||||
->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', 1);
|
||||
});
|
||||
|
||||
return view('pages.treatments.index', compact('treatments'));
|
||||
// 2. LOGIKA FILTERING DINAMIS
|
||||
if ($request->filled('search')) {
|
||||
$query->whereHas('cat', function($q) use ($request) {
|
||||
$q->where('nama_kucing', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('pic_id')) {
|
||||
$query->where('user_id', $request->pic_id);
|
||||
}
|
||||
|
||||
if ($request->filled('start_date') && $request->filled('end_date')) {
|
||||
$query->whereBetween('tanggal', [$request->start_date, $request->end_date]);
|
||||
}
|
||||
|
||||
// 3. LOGIKA EXPORT (Pastikan blok IF ditutup dengan benar)
|
||||
if ($request->filled('export')) {
|
||||
$exportData = new \App\Exports\TreatmentExport($request);
|
||||
$fileName = 'Report_Active_Treatment_' . now()->format('Ymd_His');
|
||||
|
||||
if ($request->export === 'excel') {
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.xlsx");
|
||||
}
|
||||
|
||||
if ($request->export === 'pdf') {
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.pdf", \Maatwebsite\Excel\Excel::DOMPDF);
|
||||
}
|
||||
} // <--- Pastikan kurung ini menutup blok export, bukan menutup fungsi index
|
||||
|
||||
// 4. EKSEKUSI DATA UNTUK VIEW (Line 67)
|
||||
$treatments = $query->latest('tanggal')->paginate(20)->withQueryString();
|
||||
|
||||
$pics = \App\Models\User::select('id', 'name')->orderBy('name')->get();
|
||||
|
||||
return view('pages.treatments.index', compact('treatments', 'pics'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Form tambah rekam medis.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$selectedCat = $request->filled('cat_id') ? Cat::findOrFail($request->cat_id) : null;
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::where('qty', '>', 0)->get();
|
||||
$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();
|
||||
}
|
||||
|
||||
// Menampilkan semua obat (termasuk stok 0 untuk kebutuhan edit/view,
|
||||
// namun di filter di frontend untuk input baru)
|
||||
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
|
||||
|
||||
return view('pages.treatments.create', compact('selectedCat', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan rekam medis baru & potong stok (mendukung desimal).
|
||||
* Simpan rekam medis baru & potong stok (Mendukung Multi-Obat).
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
$fotoPath = null;
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, &$validated) {
|
||||
$medicine = Medicine::findOrFail($request->medicine_id);
|
||||
|
||||
// Validasi kecukupan stok desimal
|
||||
if ($medicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty} {$medicine->unit}).");
|
||||
}
|
||||
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// Otomatisasi data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $medicine->nama_obat;
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($request, $fotoPath) {
|
||||
|
||||
$treatment = Treatment::create($validated);
|
||||
$userId = auth()->id() ?? 1;
|
||||
$firstTreatment = null;
|
||||
|
||||
// Potong stok (decrement mendukung nilai float/decimal)
|
||||
$medicine->decrement('qty', $request->qty_used);
|
||||
if ($request->filled('items')) {
|
||||
foreach ($request->items as $item) {
|
||||
$medicine = \App\Models\Medicine::findOrFail($item['medicine_id']);
|
||||
|
||||
$this->logAction('TREATED', $treatment, "Mencatat pengobatan oleh " . auth()->user()->name);
|
||||
if ($medicine->qty < $item['qty_used']) {
|
||||
throw new \Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty}).");
|
||||
}
|
||||
|
||||
$treatment = \App\Models\Treatment::create([
|
||||
'cat_id' => $request->cat_id,
|
||||
'user_id' => $userId,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
|
||||
// Langsung masukkan diagnosa tanpa embel-embel
|
||||
'diagnosa' => $request->diagnosa,
|
||||
|
||||
'foto_bukti' => $fotoPath,
|
||||
'status_perawatan' => 1,
|
||||
'medicine_id' => $item['medicine_id'],
|
||||
'qty_used' => $item['qty_used'],
|
||||
]);
|
||||
|
||||
if (!$firstTreatment) {
|
||||
$firstTreatment = $treatment;
|
||||
}
|
||||
|
||||
$medicine->decrement('qty', $item['qty_used']);
|
||||
}
|
||||
}
|
||||
|
||||
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
|
||||
|
||||
if ($firstTreatment) {
|
||||
$this->logAction('TREATED', $firstTreatment, "Mencatat pengobatan klinis untuk " . $firstTreatment->cat->nama_kucing);
|
||||
}
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis klinis berhasil dicatat dan stok dipotong otomatis.');
|
||||
});
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis berhasil dicatat dengan presisi.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
if (isset($validated['foto_bukti'])) {
|
||||
Storage::disk('public')->delete($validated['foto_bukti']);
|
||||
} catch (\Exception $e) {
|
||||
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
|
||||
}
|
||||
return back()->withInput()->with('error', 'Gagal menyimpan: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Menampilkan detail spesifik satu rekam medis.
|
||||
*/
|
||||
public function show(Treatment $treatment): View
|
||||
{
|
||||
// Load details untuk menampilkan list obat
|
||||
$treatment->load(['cat', 'medicine', 'user', 'details.medicine']);
|
||||
return view('pages.treatments.show', compact('treatment'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Form edit rekam medis.
|
||||
*/
|
||||
public function edit(Treatment $treatment): View
|
||||
{
|
||||
$treatment->load('details');
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::all();
|
||||
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
|
||||
|
||||
return view('pages.treatments.edit', compact('treatment', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rekam medis & sinkronisasi ulang stok desimal.
|
||||
* Update rekam medis & sinkronisasi ulang stok desimal (Multi-Obat).
|
||||
*/
|
||||
public function update(Request $request, Treatment $treatment): RedirectResponse
|
||||
public function update(Request $request, Treatment $treatment)
|
||||
{
|
||||
// 1. Validasi Input
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $treatment, &$validated) {
|
||||
// 1. Kembalikan stok lama (Desimal Revert)
|
||||
$oldMedicine = Medicine::find($treatment->medicine_id);
|
||||
return DB::transaction(function () use ($request, $treatment) {
|
||||
|
||||
// 2. Kelola Foto Bukti (Hapus yang lama jika ada foto baru)
|
||||
$fotoPath = $treatment->foto_bukti;
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
|
||||
}
|
||||
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// 3. KEMBALIKAN STOK OBAT LAMA (Penting agar stok tidak bocor)
|
||||
$oldMedicine = \App\Models\Medicine::find($treatment->medicine_id);
|
||||
if ($oldMedicine) {
|
||||
$oldMedicine->increment('qty', $treatment->qty_used);
|
||||
}
|
||||
|
||||
// 2. Potong stok baru
|
||||
$newMedicine = Medicine::findOrFail($request->medicine_id);
|
||||
if ($newMedicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi untuk perubahan ini.");
|
||||
}
|
||||
$newMedicine->decrement('qty', $request->qty_used);
|
||||
// 4. Ambil data obat PERTAMA dari form untuk memperbarui data saat ini (ID 9)
|
||||
$firstItem = $request->items[0];
|
||||
$newMedicine = \App\Models\Medicine::findOrFail($firstItem['medicine_id']);
|
||||
|
||||
// 3. Kelola Foto
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
if ($newMedicine->qty < $firstItem['qty_used']) {
|
||||
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi (Sisa: {$newMedicine->qty}).");
|
||||
}
|
||||
|
||||
// 4. Update data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $newMedicine->nama_obat;
|
||||
$treatment->update($validated);
|
||||
// Format Diagnosa (Gabungan Diagnosa Utama + Dosis/Ket)
|
||||
$catatanDosis = isset($firstItem['dosis']) ? " | Dosis: " . $firstItem['dosis'] : "";
|
||||
$catatanKet = isset($firstItem['keterangan']) && $firstItem['keterangan'] != '' ? " (" . $firstItem['keterangan'] . ")" : "";
|
||||
$diagnosaFinal = $request->diagnosa . $catatanDosis . $catatanKet;
|
||||
|
||||
$this->logAction('UPDATED_TREATMENT', $treatment, "Diperbarui oleh " . auth()->user()->name);
|
||||
// EKSEKUSI UPDATE REKAM MEDIS (Tanpa kolom nama_obat!)
|
||||
$treatment->update([
|
||||
'cat_id' => $request->cat_id,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
'diagnosa' => $diagnosaFinal,
|
||||
'foto_bukti' => $fotoPath,
|
||||
'medicine_id' => $firstItem['medicine_id'],
|
||||
'qty_used' => $firstItem['qty_used'],
|
||||
]);
|
||||
|
||||
// Potong stok obat pertama
|
||||
$newMedicine->decrement('qty', $firstItem['qty_used']);
|
||||
|
||||
// 5. JIKA STAF MENAMBAHKAN OBAT EKSTRA DI HALAMAN EDIT
|
||||
// Buat sebagai record baru agar data tidak hilang
|
||||
if (count($request->items) > 1) {
|
||||
for ($i = 1; $i < count($request->items); $i++) {
|
||||
$extraItem = $request->items[$i];
|
||||
$extraMed = \App\Models\Medicine::findOrFail($extraItem['medicine_id']);
|
||||
|
||||
if ($extraMed->qty < $extraItem['qty_used']) {
|
||||
throw new Exception("Stok {$extraMed->nama_obat} tidak mencukupi.");
|
||||
}
|
||||
|
||||
$catatanDosisEx = isset($extraItem['dosis']) ? " | Dosis: " . $extraItem['dosis'] : "";
|
||||
$catatanKetEx = isset($extraItem['keterangan']) && $extraItem['keterangan'] != '' ? " (" . $extraItem['keterangan'] . ")" : "";
|
||||
$diagnosaFinalEx = $request->diagnosa . $catatanDosisEx . $catatanKetEx;
|
||||
|
||||
\App\Models\Treatment::create([
|
||||
'cat_id' => $request->cat_id,
|
||||
'user_id' => auth()->id() ?? 1,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
'diagnosa' => $diagnosaFinalEx,
|
||||
'foto_bukti' => $fotoPath,
|
||||
'status_perawatan' => $treatment->status_perawatan, // Ikuti status (Misal: 1 / Aktif)
|
||||
'medicine_id' => $extraItem['medicine_id'],
|
||||
'qty_used' => $extraItem['qty_used'],
|
||||
]);
|
||||
|
||||
$extraMed->decrement('qty', $extraItem['qty_used']);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Sinkronisasi Kucing & Log Activity
|
||||
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
|
||||
$this->logAction('UPDATED', $treatment, "Memperbarui rekam medis klinis untuk " . $treatment->cat->nama_kucing);
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis berhasil diperbarui dan stok obat disesuaikan ulang secara otomatis!');
|
||||
});
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)->with('success', 'Rekam medis & inventori berhasil di-sinkronisasi.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
return back()->withInput()->with('error', 'Gagal memperbarui: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus rekam medis & kembalikan stok.
|
||||
* Hapus rekam medis & kembalikan semua stok obat terkait.
|
||||
*/
|
||||
public function destroy(Treatment $treatment): RedirectResponse
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($treatment) {
|
||||
if ($treatment->medicine_id) {
|
||||
Medicine::where('id', $treatment->medicine_id)->increment('qty', $treatment->qty_used);
|
||||
// Kembalikan semua stok dari tabel detail
|
||||
foreach ($treatment->details as $detail) {
|
||||
Medicine::where('id', $detail->medicine_id)->increment('qty', $detail->qty_used);
|
||||
}
|
||||
|
||||
if ($treatment->foto_bukti) {
|
||||
Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
}
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
|
||||
$this->logAction('DELETED_TREATMENT', $treatment, "Menghapus catatan medis kucing: {$treatment->cat->nama_kucing}");
|
||||
|
||||
// Delete otomatis menghapus details karena cascade di migration
|
||||
$treatment->delete();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Data rekam medis dihapus dan stok dikembalikan.');
|
||||
return back()->with('success', 'Data rekam medis dihapus dan semua stok obat dikembalikan.');
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal menghapus data.');
|
||||
return back()->with('error', 'Gagal menghapus data: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Validasi: Terpusat untuk Clean Code & Mendukung Desimal.
|
||||
*/
|
||||
private function validateTreatment(Request $request)
|
||||
private function validateTreatment(Request $request)
|
||||
{
|
||||
return $request->validate([
|
||||
'cat_id' => 'required|exists:cats,id',
|
||||
'medicine_id' => 'required|exists:medicines,id',
|
||||
// 'numeric' mengizinkan input desimal (koma)
|
||||
'qty_used' => 'required|numeric|min:0.01',
|
||||
'tanggal' => 'required|date',
|
||||
'keluhan' => 'required|string|max:255',
|
||||
'diagnosa' => 'required|string',
|
||||
'dosis' => 'required|string',
|
||||
'keterangan' => 'nullable|string',
|
||||
'foto_bukti' => 'nullable|image|max:2048',
|
||||
|
||||
// Validasi untuk Single Medicine
|
||||
'medicine_id' => 'required_without:items|nullable|exists:medicines,id',
|
||||
'qty_used' => 'required_without:items|nullable|numeric|min:0',
|
||||
|
||||
// Validasi untuk Multi Medicine
|
||||
// FIX: Wajibkan array items jika mode single (medicine_id) kosong
|
||||
'items' => 'required_without:medicine_id|nullable|array',
|
||||
'items.*.medicine_id' => 'required_with:items|exists:medicines,id',
|
||||
'items.*.qty_used' => 'required_with:items|numeric|min:0.01',
|
||||
|
||||
], [
|
||||
// Pesan Error Custom (Opsional, agar lebih mudah di-debug)
|
||||
'items.required_without' => 'Anda harus memasukkan minimal 1 obat.',
|
||||
'items.*.medicine_id.required_with' => 'Ada pilihan obat yang masih kosong di daftar.',
|
||||
'items.*.qty_used.required_with' => 'Jumlah (Qty) obat harus diisi.',
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,12 @@ class UserController extends Controller
|
||||
Gate::authorize('check-access', ['users', 'read']);
|
||||
|
||||
$users = User::query()
|
||||
->with('role')
|
||||
->with('roleModel')
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
->paginate(10);
|
||||
@@ -194,7 +196,6 @@ class UserController extends Controller
|
||||
public function clearLogs()
|
||||
{
|
||||
Gate::authorize('check-access', ['logs', 'delete']);
|
||||
ActivityLog::truncate();
|
||||
return back()->with('success', 'Seluruh riwayat aktivitas telah dibersihkan.');
|
||||
return back()->with('error', 'Penghapusan massal audit trail dinonaktifkan untuk menjaga jejak perubahan data. Gunakan kebijakan retensi/arsip bila diperlukan.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnLoginController
|
||||
{
|
||||
/**
|
||||
* Returns the challenge to assertion.
|
||||
*/
|
||||
public function options(AssertionRequest $request): Responsable
|
||||
{
|
||||
return $request->toVerify($request->validate(['email' => 'sometimes|email|string']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user in.
|
||||
*/
|
||||
public function login(AssertedRequest $request): Response
|
||||
{
|
||||
return response()->noContent($request->login() ? 204 : 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnRegisterController
|
||||
{
|
||||
/**
|
||||
* Returns a challenge to be verified by the user device.
|
||||
*/
|
||||
public function options(AttestationRequest $request): Responsable
|
||||
{
|
||||
return $request
|
||||
->fastRegistration()
|
||||
// ->userless()
|
||||
// ->allowDuplicates()
|
||||
->toCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a device for further WebAuthn authentication.
|
||||
*/
|
||||
public function register(AttestedRequest $request): Response
|
||||
{
|
||||
$request->save();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class AdminMiddleware
|
||||
|
||||
// 2. Ambil slug role (Mendukung sistem baru yang kita buat)
|
||||
// Kita gunakan optional() agar jika user tidak punya role, aplikasi tidak crash.
|
||||
$roleSlug = optional(auth()->user()->role)->slug ?? auth()->user()->role;
|
||||
$roleSlug = auth()->user()->roleModel?->slug ?? auth()->user()->getRawOriginal('role');
|
||||
|
||||
// 3. Logika Existing Anda (Tetap Dipertahankan)
|
||||
if ($roleSlug !== 'admin') {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiToken
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$plain = $request->bearerToken();
|
||||
if (!$plain) {
|
||||
return response()->json(['message' => 'Token akses tidak ditemukan.'], 401);
|
||||
}
|
||||
|
||||
$token = ApiToken::with('user')->where('token_hash', hash('sha256', $plain))->first();
|
||||
if (!$token || !$token->user || ($token->expires_at && $token->expires_at->isPast())) {
|
||||
return response()->json(['message' => 'Token akses tidak valid atau sudah kedaluwarsa.'], 401);
|
||||
}
|
||||
|
||||
$token->forceFill(['last_used_at' => now()])->saveQuietly();
|
||||
$request->setUserResolver(fn () => $token->user);
|
||||
$request->attributes->set('api_token', $token);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\WeightHistory;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStartRow;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
|
||||
class CatWeightImport implements ToCollection, WithStartRow
|
||||
{
|
||||
// Tambahkan variabel untuk menghitung data yang sukses
|
||||
private int $importedCount = 0;
|
||||
|
||||
public function startRow(): int
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
public function collection(Collection $rows)
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$catId = $row[0] ?? null;
|
||||
$berat = $row[2] ?? null;
|
||||
$tanggalExcel = $row[3] ?? null;
|
||||
|
||||
if (empty($catId) || empty($berat) || empty($tanggalExcel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedDate = null;
|
||||
try {
|
||||
if (is_numeric($tanggalExcel)) {
|
||||
$parsedDate = Date::excelToDateTimeObject($tanggalExcel);
|
||||
} else {
|
||||
$tanggalExcel = str_ireplace(
|
||||
['januari', 'februari', 'maret', 'april', 'mei', 'juni', 'juli', 'agustus', 'september', 'oktober', 'november', 'desember'],
|
||||
['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'],
|
||||
$tanggalExcel
|
||||
);
|
||||
$parsedDate = Carbon::parse($tanggalExcel);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$carbonDate = Carbon::instance($parsedDate);
|
||||
$dateOnly = $carbonDate->format('Y-m-d');
|
||||
$timeOnly = $carbonDate->format('H:i:s');
|
||||
|
||||
$existingRecord = WeightHistory::where('cat_id', $catId)
|
||||
->whereDate('tanggal_timbang', $dateOnly)
|
||||
->first();
|
||||
|
||||
if ($existingRecord) {
|
||||
$finalTime = ($timeOnly === '00:00:00') ? Carbon::parse($existingRecord->tanggal_timbang)->format('H:i:s') : $timeOnly;
|
||||
|
||||
$existingRecord->update([
|
||||
'berat' => $berat,
|
||||
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
|
||||
]);
|
||||
|
||||
// Tambah hitungan jika berhasil update
|
||||
$this->importedCount++;
|
||||
} else {
|
||||
$finalTime = ($timeOnly === '00:00:00') ? now()->format('H:i:s') : $timeOnly;
|
||||
|
||||
WeightHistory::create([
|
||||
'cat_id' => $catId,
|
||||
'berat' => $berat,
|
||||
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
|
||||
]);
|
||||
|
||||
// Tambah hitungan jika berhasil insert baru
|
||||
$this->importedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fungsi untuk melempar total hitungan ke Controller
|
||||
public function getImportedCount(): int
|
||||
{
|
||||
return $this->importedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ApiToken extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'name', 'token_hash', 'last_used_at', 'expires_at'];
|
||||
protected function casts(): array { return ['last_used_at' => 'datetime', 'expires_at' => 'datetime']; }
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
+106
-44
@@ -16,7 +16,6 @@ class Cat extends Model
|
||||
|
||||
/**
|
||||
* SINKRONISASI TOTAL: 22 Kolom Database
|
||||
* Memasukkan berat_terakhir, lokasi, dan kolom identitas lainnya.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_kucing', 'ras', 'registry', 'jenis_kelamin', 'tanggal_lahir',
|
||||
@@ -24,9 +23,38 @@ class Cat extends Model
|
||||
'jadwal_vaksin', 'jadwal_obat_cacing', 'jadwal_obat_kutu',
|
||||
'jadwal_grooming', 'induk_jantan_id', 'induk_betina_id',
|
||||
'lokasi_kandang', 'pic_id', 'berat_badan',
|
||||
'pedigree', 'microchip', 'status', 'kode_ems' // Kolom tambahan dari UI Anda
|
||||
'pedigree','sertifikat_ped_file', 'microchip', 'status', 'kode_ems','treatment_status'
|
||||
];
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi (Booted)
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
// A. Sebelum simpan: Sinkronisasi berat_terakhir
|
||||
static::saving(function ($cat) {
|
||||
if ($cat->isDirty('berat_badan')) {
|
||||
$cat->berat_terakhir = $cat->berat_badan;
|
||||
}
|
||||
});
|
||||
|
||||
// B. Setelah update berhasil: Catat ke tabel history
|
||||
static::updated(function ($cat) {
|
||||
if ($cat->wasChanged('berat_badan')) {
|
||||
// Kita gunakan try-catch agar jika tabel history bermasalah, update kucing tidak ikut mati
|
||||
try {
|
||||
$cat->weightHistories()->create([
|
||||
'berat' => $cat->berat_badan,
|
||||
'tanggal_timbang' => now(), // Pastikan kolom ini ada di migrasi Anda
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Log error diam-diam agar user tidak melihat Error 500
|
||||
\Illuminate\Support\Facades\Log::error("Gagal mencatat history berat: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Casting Laravel 11/12 Style
|
||||
*/
|
||||
@@ -38,7 +66,7 @@ class Cat extends Model
|
||||
'jadwal_obat_cacing' => 'date',
|
||||
'jadwal_obat_kutu' => 'date',
|
||||
'jadwal_grooming' => 'date',
|
||||
'registry' => 'array',
|
||||
'registry' => 'array', // Otomatis mengurus JSON di DB
|
||||
'berat_badan' => 'decimal:2',
|
||||
'berat_terakhir' => 'decimal:2',
|
||||
];
|
||||
@@ -48,10 +76,10 @@ class Cat extends Model
|
||||
/* RELATIONS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
public function treatments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Treatment::class);
|
||||
}
|
||||
public function treatments()
|
||||
{
|
||||
return $this->hasMany(Treatment::class, 'cat_id');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
return Attribute::make(
|
||||
// Database menggunakan enum 'Jantan'/'Betina' (Case Sensitive)
|
||||
get: fn () => strtolower($this->jenis_kelamin) === 'jantan'
|
||||
? '<i class="fas fa-mars text-indigo-500"></i>'
|
||||
: '<i class="fas fa-venus text-rose-500"></i>'
|
||||
get: fn () => strtolower($this->jenis_kelamin ?? '') === 'jantan'
|
||||
? '<i class="fas fa-mars text-indigo-500" title="Jantan"></i>'
|
||||
: '<i class="fas fa-venus text-rose-500" title="Betina"></i>'
|
||||
);
|
||||
}
|
||||
|
||||
protected function fotoUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->foto
|
||||
? Storage::url($this->foto)
|
||||
: asset('images/default-cat.png')
|
||||
get: function () {
|
||||
if ($this->foto && Storage::disk('public')->exists($this->foto)) {
|
||||
return Storage::url($this->foto);
|
||||
}
|
||||
return asset('images/default-cat.png');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,57 +166,79 @@ class Cat extends Model
|
||||
);
|
||||
}
|
||||
|
||||
// Usia presisi untuk laporan medis (Tahun, Bulan, Hari)
|
||||
protected function usiaDetail(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->tanggal_lahir) return 'Data N/A';
|
||||
$diff = $this->tanggal_lahir->diff(now());
|
||||
return "{$diff->y} Thn, {$diff->m} Bln, {$diff->d} Hari";
|
||||
return "{$diff->y}th {$diff->m}bln {$diff->d}hr";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function registryLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->registry ?? 'NON-PED'
|
||||
);
|
||||
}
|
||||
|
||||
// Badges untuk registry
|
||||
/**
|
||||
* Badge Registry yang disempurnakan (lebih bersih)
|
||||
*/
|
||||
protected function registryBadges(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
// Jika tidak ada registry
|
||||
if (empty($this->registry)) {
|
||||
return '<span class="text-[9px] text-slate-400 font-bold italic tracking-widest">NON-PED</span>';
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$registries = $this->registry;
|
||||
|
||||
// Jika kosong atau eksplisit memilih NON-PED
|
||||
if (empty($registries) || (is_array($registries) && in_array('NON-PED', $registries))) {
|
||||
return '<span class="px-2 py-0.5 bg-slate-100 text-slate-400 border border-slate-200 rounded-lg text-[8px] font-black uppercase tracking-tighter">NON-PED</span>';
|
||||
}
|
||||
|
||||
// Mapping warna untuk setiap federasi
|
||||
$config = [
|
||||
'ICA' => ['bg' => 'bg-blue-50', 'text' => 'text-blue-700', 'border' => 'border-blue-100'],
|
||||
'CFA' => ['bg' => 'bg-rose-50', 'text' => 'text-rose-700', 'border' => 'border-rose-100'],
|
||||
'WCF' => ['bg' => 'bg-emerald-50', 'text' => 'text-emerald-700', 'border' => 'border-emerald-100'],
|
||||
'FFF' => ['bg' => 'bg-amber-50', 'text' => 'text-amber-700', 'border' => 'border-amber-100'],
|
||||
'ICA' => 'bg-blue-50 text-blue-700 border-blue-100',
|
||||
'CFA' => 'bg-rose-50 text-rose-700 border-rose-100',
|
||||
'WCF' => 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||
'FFF' => 'bg-amber-50 text-amber-700 border-amber-100',
|
||||
];
|
||||
|
||||
$html = '<div class="flex flex-wrap gap-1">';
|
||||
$registries = is_array($this->registry) ? $this->registry : [$this->registry];
|
||||
$list = is_array($registries) ? $registries : [$registries];
|
||||
|
||||
foreach ($registries as $reg) {
|
||||
$style = $config[$reg] ?? ['bg' => 'bg-slate-50', 'text' => 'text-slate-600', 'border' => 'border-slate-200'];
|
||||
|
||||
$html .= "<span class='px-2 py-0.5 {$style['bg']} {$style['text']} {$style['border']} border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm'>";
|
||||
$html .= $reg;
|
||||
$html .= "</span>";
|
||||
foreach ($list as $reg) {
|
||||
if($reg === 'NON-PED') continue; // Skip jika tercampur (opsional)
|
||||
$uReg = strtoupper($reg);
|
||||
$style = $config[$uReg] ?? 'bg-slate-50 text-slate-600 border-slate-200';
|
||||
$html .= "<span class='px-2 py-0.5 border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm {$style}'>{$uReg}</span>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function medicalRecords()
|
||||
{
|
||||
// Mengambil riwayat, terbaru diletakkan paling atas
|
||||
return $this->hasMany(MedicalRecord::class)->orderBy('date_given', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sinkronkan kolom status perawatan dari treatment yang masih aktif.
|
||||
* Nilai 1 = dalam perawatan, 2 = sehat.
|
||||
*/
|
||||
public function updateTreatmentStatus(): void
|
||||
{
|
||||
$status = $this->treatments()->where('status_perawatan', 1)->exists() ? 1 : 2;
|
||||
$this->forceFill(['treatment_status' => $status])->saveQuietly();
|
||||
}
|
||||
/**
|
||||
* Scope untuk filter kucing yang sedang sakit (Under Treatment)
|
||||
*/
|
||||
public function scopeUnderTreatment($query)
|
||||
{
|
||||
// Hapus logika whereHas lama, ganti dengan ini:
|
||||
return $query->where('treatment_status', 1);
|
||||
}
|
||||
|
||||
public function scopeHealthy($query)
|
||||
{
|
||||
return $query->where('treatment_status', 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CatAssociation extends Model
|
||||
{
|
||||
protected $fillable = ['code', 'name', 'country', 'website', 'is_active'];
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function registrations()
|
||||
{
|
||||
return $this->hasMany(CatRegistration::class, 'association_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CatRegistration extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'cat_id', 'association_id', 'registration_number', 'pedigree_number',
|
||||
'certificate_number', 'issue_date', 'title', 'ownership_status',
|
||||
'is_primary', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['issue_date' => 'date', 'is_primary' => 'boolean'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (CatRegistration $registration) {
|
||||
$registration->loadMissing('documents');
|
||||
foreach ($registration->documents as $document) {
|
||||
if ($document->file_path) {
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function cat() { return $this->belongsTo(Cat::class); }
|
||||
public function association() { return $this->belongsTo(CatAssociation::class, 'association_id'); }
|
||||
public function documents() { return $this->hasMany(CatRegistrationDocument::class)->latest(); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CatRegistrationDocument extends Model
|
||||
{
|
||||
protected $fillable = ['cat_registration_id', 'label', 'document_type', 'file_path', 'issued_at'];
|
||||
protected function casts(): array { return ['issued_at' => 'date']; }
|
||||
public function registration() { return $this->belongsTo(CatRegistration::class, 'cat_registration_id'); }
|
||||
public function getUrlAttribute(): string { return Storage::disk('public')->url($this->file_path); }
|
||||
}
|
||||
+47
-14
@@ -3,19 +3,41 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Food extends Model
|
||||
{
|
||||
protected $table = 'foods';
|
||||
|
||||
// Gunakan guarded atau fillable, pastikan 'slug' dan 'category_id' diizinkan
|
||||
protected $guarded = ['id'];
|
||||
// Menggunakan fillable lebih aman untuk tracking kolom apa saja yang masuk ke DB
|
||||
protected $fillable = [
|
||||
'nama_makanan',
|
||||
'foto_produk',
|
||||
'slug',
|
||||
'qty',
|
||||
'unit',
|
||||
'category_id',
|
||||
'kategori',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting tipe data agar terbaca sebagai objek oleh Carbon
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke model Category
|
||||
*/
|
||||
public function category()
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
@@ -27,14 +49,13 @@ class Food extends Model
|
||||
{
|
||||
static::saving(function ($food) {
|
||||
// 1. OTOMATIS GENERATE SLUG
|
||||
// Hanya generate jika nama berubah atau slug masih kosong
|
||||
if ($food->isDirty('nama_makanan') || empty($food->slug)) {
|
||||
$food->slug = Str::slug($food->nama_makanan) . '-' . Str::random(5);
|
||||
$food->slug = Str::slug($food->nama_makanan) . '-' . Str::lower(Str::random(5));
|
||||
}
|
||||
|
||||
// 2. AUTO-CATEGORIZATION (SMART LOGIC)
|
||||
// Hanya jalankan jika category_id belum diisi manual
|
||||
if (empty($food->category_id)) {
|
||||
// Jalankan hanya jika category_id belum diisi manual atau nama makanan berubah
|
||||
if (empty($food->category_id) || $food->isDirty('nama_makanan')) {
|
||||
$productName = strtolower($food->nama_makanan);
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
$found = false;
|
||||
@@ -49,27 +70,39 @@ class Food extends Model
|
||||
$food->category_id = $cat->id;
|
||||
$food->kategori = $cat->name;
|
||||
$found = true;
|
||||
break; // Keluar dari loop keyword
|
||||
break 2; // Keluar dari kedua loop sekaligus
|
||||
}
|
||||
}
|
||||
if ($found) break; // Keluar dari loop kategori
|
||||
}
|
||||
|
||||
// 3. FALLBACK LOGIC
|
||||
// Jika tidak ada keyword yang cocok, cari kategori default
|
||||
if (!$found) {
|
||||
if (!$found && empty($food->category_id)) {
|
||||
$default = Category::where('name', 'Dry Food')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$food->category_id = $default->id;
|
||||
$food->kategori = $default->name;
|
||||
} else {
|
||||
$food->kategori = 'Umum';
|
||||
$food->kategori = $food->kategori ?: 'Umum';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SINKRONISASI TEKS KATEGORI
|
||||
// Jika category_id diisi manual di form, pastikan kolom 'kategori' (string) juga terupdate
|
||||
if ($food->isDirty('category_id') && !empty($food->category_id)) {
|
||||
$food->kategori = $food->category?->name ?? $food->kategori;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Cek Status Expired
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,9 @@ class Kitten extends Model
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if ($this->is_pedigree) {
|
||||
return '<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase border border-indigo-100 italic">Certificate OK</span>';
|
||||
return '<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase border border-indigo-100 italic">Sertifikat tersedia</span>';
|
||||
}
|
||||
return '<span class="px-2 py-0.5 bg-slate-50 text-slate-400 rounded text-[9px] font-black uppercase border border-slate-100">Non-Ped</span>';
|
||||
return '<span class="px-2 py-0.5 bg-slate-50 text-slate-400 rounded text-[9px] font-black uppercase border border-slate-100">Non-pedigree</span>';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
protected static function booted()
|
||||
@@ -34,8 +34,7 @@ class Matting extends Model
|
||||
// 1. Update data Sire (Bapak)
|
||||
if ($matting->sire_id && $matting->sire) {
|
||||
$matting->sire->update([
|
||||
'pedigree' => $matting->no_pedigree_bapak,
|
||||
'microchip' => $now_microchip_bapak ?? $matting->no_microchip_bapak,
|
||||
'microchip' => $matting->no_microchip_bapak,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_sire,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_bapak
|
||||
'kode_ems' => $matting->ems_bapak,
|
||||
@@ -45,7 +44,6 @@ class Matting extends Model
|
||||
// 2. Update data Dam (Ibu)
|
||||
if ($matting->dam_id && $matting->dam) {
|
||||
$matting->dam->update([
|
||||
'pedigree' => $matting->no_pedigree_ibu,
|
||||
'microchip' => $matting->no_microchip_ibu,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_dam,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_ibu
|
||||
@@ -74,7 +72,7 @@ class Matting extends Model
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS (UI & Breeding Intelligence) */
|
||||
/* ACCESSORS (UI & BREEDING) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -114,7 +112,7 @@ class Matting extends Model
|
||||
get: function () {
|
||||
$status = strtolower($this->status);
|
||||
return match($status) {
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">Monitoring</span>',
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">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>',
|
||||
'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>',
|
||||
@@ -131,7 +129,7 @@ class Matting extends Model
|
||||
// Pastikan kolom di tabel 'mattings' adalah 'user_id'
|
||||
// Jika kolomnya bernama 'pic_id', ganti 'user_id' menjadi 'pic_id'
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
'name' => 'Sistem / Admin'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo; // 1. TAMBAHKAN BARIS INI
|
||||
|
||||
class MedicalRecord extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'cat_id',
|
||||
'user_id',
|
||||
'type',
|
||||
'treatment_name',
|
||||
'dosage',
|
||||
'unit',
|
||||
'date_given',
|
||||
'next_schedule',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date_given' => 'date',
|
||||
'next_schedule' => 'date',
|
||||
];
|
||||
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class);
|
||||
}
|
||||
|
||||
// 2. PASTIKAN RELASI INI BENAR
|
||||
public function performer(): BelongsTo
|
||||
{
|
||||
// Karena kolomnya sekarang user_id (bukan performed_by),
|
||||
// kita beri tahu Laravel secara eksplisit:
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
+73
-30
@@ -4,58 +4,77 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class Medicine extends Model
|
||||
{
|
||||
/**
|
||||
* Mass assignment untuk kolom-kolom inventori.
|
||||
* Kolom 'kategori' tetap ada di fillable agar bisa diisi otomatis oleh sistem.
|
||||
* Mass assignment.
|
||||
* Menambahkan expired_at dan category_id agar sinkron dengan update database terbaru.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_obat',
|
||||
'foto_produk',
|
||||
'kategori',
|
||||
'category_id',
|
||||
'qty',
|
||||
'unit',
|
||||
'expired_date'
|
||||
'expired_date',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Casting (Laravel 11/12 style)
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_date' => 'date',
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float', // Mendukung dosis desimal (misal 2.5 ml)
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi & Integritas Data
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::saving(function ($medicine) {
|
||||
$name = strtolower($medicine->nama_obat);
|
||||
|
||||
// Ambil semua kategori dari database
|
||||
$categories = \App\Models\Category::all();
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
// Pecah kata kunci berdasarkan koma
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
{
|
||||
static::saving(function ($medicine) {
|
||||
$name = strtolower($medicine->nama_obat);
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($name, trim($keyword))) {
|
||||
$medicine->category_id = $cat->id;
|
||||
$medicine->kategori = $cat->name; // Sinkronisasi teks kategori
|
||||
return;
|
||||
// 1. OTOMATISASI KATEGORI BERDASARKAN KEYWORDS
|
||||
$categories = \App\Models\Category::all();
|
||||
$matched = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($name, trim($keyword))) {
|
||||
$medicine->category_id = $cat->id;
|
||||
$medicine->kategori = $cat->name;
|
||||
$matched = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$medicine->kategori = 'Umum';
|
||||
});
|
||||
}
|
||||
if (!$matched) {
|
||||
$medicine->kategori = $medicine->kategori ?: 'Umum';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
];
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Mempermudah pemanggilan foto produk dengan fallback image.
|
||||
@@ -66,10 +85,26 @@ class Medicine extends Model
|
||||
return asset('storage/' . $this->foto_produk);
|
||||
}
|
||||
|
||||
// Menggunakan UI Avatars sebagai placeholder jika foto tidak ada
|
||||
return 'https://ui-avatars.com/api/?name=' . urlencode($this->nama_obat) . '&background=f1f5f9&color=64748b';
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Cek Status Expired secara instan.
|
||||
* Bisa digunakan di Blade: @if($medicine->is_expired) ... @endif
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Kategori Obat.
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke riwayat pengobatan kucing.
|
||||
*/
|
||||
@@ -77,4 +112,12 @@ class Medicine extends Model
|
||||
{
|
||||
return $this->hasMany(Treatment::class, 'medicine_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Melihat riwayat penggunaan obat ini di berbagai rekam medis
|
||||
*/
|
||||
public function treatmentDetails()
|
||||
{
|
||||
return $this->hasMany(TreatmentDetail::class);
|
||||
}
|
||||
}
|
||||
+70
-27
@@ -9,59 +9,102 @@ use Illuminate\Support\Str;
|
||||
class Supply extends Model
|
||||
{
|
||||
protected $table = 'supplies';
|
||||
protected $guarded = ['id'];
|
||||
|
||||
// Menggunakan fillable untuk keamanan mass assignment
|
||||
protected $fillable = [
|
||||
'nama_perlengkapan',
|
||||
'foto_produk',
|
||||
'slug',
|
||||
'qty',
|
||||
'unit',
|
||||
'category_id',
|
||||
'kategori',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Casting (Laravel 11/12)
|
||||
* Memastikan expired_at menjadi objek Carbon untuk mencegah error isPast()
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke model Category
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi & Integritas Data
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::saving(function ($supply) {
|
||||
// 1. Sinkronisasi Slug (Mencegah error SQL sebelumnya)
|
||||
// 1. OTOMATIS GENERATE SLUG
|
||||
if ($supply->isDirty('nama_perlengkapan') || empty($supply->slug)) {
|
||||
$supply->slug = Str::slug($supply->nama_perlengkapan) . '-' . Str::random(5);
|
||||
$supply->slug = Str::slug($supply->nama_perlengkapan) . '-' . Str::lower(Str::random(5));
|
||||
}
|
||||
|
||||
// 2. Auto-Categorization
|
||||
if (empty($supply->category_id)) {
|
||||
// 2. AUTO-CATEGORIZATION (SMART LOGIC)
|
||||
// Jalankan jika category_id kosong atau nama perlengkapan berubah
|
||||
if (empty($supply->category_id) || $supply->isDirty('nama_perlengkapan')) {
|
||||
$name = strtolower($supply->nama_perlengkapan);
|
||||
|
||||
// Ambil kategori yang punya keywords
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
$found = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
$keywordsArray = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$trimmed = trim($keyword);
|
||||
foreach ($keywordsArray as $keyword) {
|
||||
$cleanKeyword = trim($keyword);
|
||||
|
||||
// Cek apakah "pasir" atau keyword lain ada di nama produk
|
||||
if (!empty($trimmed) && str_contains($name, $trimmed)) {
|
||||
if (!empty($cleanKeyword) && str_contains($name, $cleanKeyword)) {
|
||||
$supply->category_id = $cat->id;
|
||||
// PENTING: Update kolom teks 'kategori' agar tampilan tidak 'Umum'
|
||||
$supply->kategori = $cat->name;
|
||||
return;
|
||||
$found = true;
|
||||
break 2; // Berhenti mencari jika sudah ketemu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback jika tidak ada yang cocok
|
||||
// Cari kategori yang mengandung kata 'Pasir' atau 'Umum'
|
||||
$default = Category::where('name', 'LIKE', '%Pasir%')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$supply->category_id = $default->id;
|
||||
$supply->kategori = $default->name;
|
||||
} else {
|
||||
$supply->kategori = 'Umum';
|
||||
// 3. FALLBACK LOGIC
|
||||
if (!$found && empty($supply->category_id)) {
|
||||
$default = Category::where('name', 'LIKE', '%Pasir%')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$supply->category_id = $default->id;
|
||||
$supply->kategori = $default->name;
|
||||
} else {
|
||||
$supply->kategori = $supply->kategori ?: 'Umum';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SINKRONISASI TEKS KATEGORI
|
||||
// Jika ID kategori diubah manual di form, update teks kolom 'kategori'
|
||||
if ($supply->isDirty('category_id') && !empty($supply->category_id)) {
|
||||
$supply->kategori = $supply->category?->name ?? $supply->kategori;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Membantu pengecekan expired di Blade secara rapi
|
||||
* Contoh: @if($supply->is_expired) ... @endif
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
}
|
||||
+62
-19
@@ -4,8 +4,11 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Treatment extends Model
|
||||
{
|
||||
@@ -24,10 +27,12 @@ class Treatment extends Model
|
||||
'foto_bukti',
|
||||
'medicine_id',
|
||||
'qty_used',
|
||||
'status_perawatan', // Tambahan Baru
|
||||
'remark_pasca_sakit'
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting data agar formatnya sesuai (Tanggal jadi object Carbon).
|
||||
* Casting data agar formatnya sesuai.
|
||||
*/
|
||||
protected $casts = [
|
||||
'tanggal' => 'datetime',
|
||||
@@ -35,7 +40,7 @@ class Treatment extends Model
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* RELASI (MENGATASI ERROR RELATION NOT FOUND) */
|
||||
/* RELASI */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -43,25 +48,19 @@ class Treatment extends Model
|
||||
*/
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'cat_id')->withDefault([
|
||||
'nama_kucing' => 'Kucing Terhapus'
|
||||
]);
|
||||
return $this->belongsTo(Cat::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi utama ke User (PIC).
|
||||
* Nama fungsi 'user' wajib ada karena dipanggil di TreatmentController baris 31.
|
||||
* Relasi utama ke User (PIC).
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
]);
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias untuk relasi user.
|
||||
* Nama fungsi 'pic' wajib ada karena dipanggil di DashboardController.
|
||||
* Alias untuk relasi user (Digunakan di Dashboard).
|
||||
*/
|
||||
public function pic(): BelongsTo
|
||||
{
|
||||
@@ -69,22 +68,38 @@ class Treatment extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Inventori Obat.
|
||||
* PENTING: Relasi ke Medicine tunggal (Legacy Support).
|
||||
* Ini yang memperbaiki error 'RelationNotFoundException' pada data lama.
|
||||
*/
|
||||
public function medicine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Medicine::class, 'medicine_id')->withDefault([
|
||||
'nama_obat' => $this->nama_obat ?? 'Obat Manual'
|
||||
]);
|
||||
return $this->belongsTo(Medicine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Detail Obat (Sistem Baru Multi-Medicine).
|
||||
*/
|
||||
public function details(): HasMany
|
||||
{
|
||||
return $this->hasMany(TreatmentDetail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi langsung ke banyak Medicine (Many-to-Many via pivot details).
|
||||
*/
|
||||
public function medicines(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Medicine::class, 'treatment_details')
|
||||
->withPivot('dosis', 'keterangan_pakai')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS */
|
||||
/* ACCESSORS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Mengambil URL foto bukti.
|
||||
* Cara akses di Blade: {{ $treatment->foto_url }}
|
||||
*/
|
||||
public function getFotoUrlAttribute(): string
|
||||
{
|
||||
@@ -95,8 +110,26 @@ class Treatment extends Model
|
||||
return Storage::url($this->foto_bukti);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menghitung Progres Pengobatan secara otomatis.
|
||||
* Cara pakai di Blade: {{ $treatment->status_progress }}
|
||||
*/
|
||||
public function getStatusProgressAttribute(): string
|
||||
{
|
||||
$text = strtolower($this->diagnosa . ' ' . $this->keterangan);
|
||||
|
||||
// Keyword indikator membaik
|
||||
$improvingKeywords = ['baik', 'makan', 'aktif', 'sembuh', 'lincah', 'normal', 'turun panas'];
|
||||
|
||||
if (Str::contains($text, $improvingKeywords)) {
|
||||
return 'MEMBAIK';
|
||||
}
|
||||
|
||||
return 'STUCK';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SCOPES */
|
||||
/* SCOPES */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -106,4 +139,14 @@ class Treatment extends Model
|
||||
{
|
||||
$query->orderBy('tanggal', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope khusus untuk mengambil rekam medis yang sedang aktif (Under Treatment).
|
||||
*/
|
||||
public function scopeActiveTreatment(Builder $query): Builder
|
||||
{
|
||||
return $query->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TreatmentDetail extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Menentukan kolom yang boleh diisi secara massal
|
||||
protected $fillable = [
|
||||
'treatment_id',
|
||||
'medicine_id',
|
||||
'dosis',
|
||||
'keterangan_pakai',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relasi balik ke Header Treatment (Rekam Medis Utama)
|
||||
*/
|
||||
public function treatment()
|
||||
{
|
||||
return $this->belongsTo(Treatment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke data Obat (Inventory)
|
||||
*/
|
||||
public function medicine()
|
||||
{
|
||||
return $this->belongsTo(Medicine::class);
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -8,12 +8,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo; // Tambahan: Import untuk relasi
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; // Tambahkan ini
|
||||
use Laragear\WebAuthn\WebAuthnAuthentication;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasPermissions; // Tambahan: Gunakan HasPermissions
|
||||
|
||||
use WebAuthnAuthentication;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
@@ -55,23 +59,25 @@ class User extends Authenticatable
|
||||
* Tambahan: Relasi ke tabel Roles
|
||||
* Setiap user memiliki satu Role.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
/**
|
||||
* Relasi role memakai nama roleModel agar tidak bertabrakan dengan kolom legacy `role`.
|
||||
*/
|
||||
public function roleModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
return $this->belongsTo(Role::class, 'role_id');
|
||||
}
|
||||
|
||||
public function hasAccess($module, $permission)
|
||||
{
|
||||
// Jika admin, berikan akses penuh secara otomatis
|
||||
if ($this->role && $this->role->slug === 'admin') {
|
||||
return true;
|
||||
}
|
||||
public function hasAccess($module, $permission): bool
|
||||
{
|
||||
$role = $this->roleModel;
|
||||
if ($role?->slug === 'admin' || $this->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cek di tabel role_permissions melalui relasi role
|
||||
return $this->role->permissions()
|
||||
->where('module_slug', $module)
|
||||
->where($permission, 1) // $permission berisi 'can_read', 'can_update', dll
|
||||
->exists();
|
||||
}
|
||||
return $role?->permissions()
|
||||
->where('module_slug', $module)
|
||||
->where($permission, 1)
|
||||
->exists() ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,5 +6,27 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WeightHistory extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
// Pastikan fillable sesuai dengan kolom di database
|
||||
protected $fillable = [
|
||||
'cat_id',
|
||||
'berat',
|
||||
'tanggal_timbang', // Pastikan baris ini ada
|
||||
'created_at',
|
||||
'updated_at'
|
||||
];
|
||||
|
||||
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tanggal_timbang' => 'date',
|
||||
'berat' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function cat()
|
||||
{
|
||||
return $this->belongsTo(Cat::class);
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,18 @@ use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate; // Import Gate untuk sistem permission
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void { /* ... */ }
|
||||
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
|
||||
if (config('app.env') === 'production') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
// 1. DAFTARKAN OBSERVERS (Tetap Utuh & Aman)
|
||||
if (Schema::hasTable('medicines')) {
|
||||
Medicine::observe(MedicineObserver::class);
|
||||
@@ -51,29 +56,33 @@ class AppServiceProvider extends ServiceProvider
|
||||
$view->with($stockData);
|
||||
});
|
||||
|
||||
// 4. SISTEM OTORISASI GLOBAL (Gate Permission)
|
||||
// 4. SISTEM OTORISASI GLOBAL (Gate Permission)
|
||||
Gate::define('check-access', function ($user, $module, $permission) {
|
||||
// Hindari pengecekan jika user tidak memiliki role_id atau data role
|
||||
if (!$user->role_id && !$user->role) return false;
|
||||
|
||||
// Deteksi Role: Apakah string (lama) atau object (baru)
|
||||
// Jika $user->role adalah string, ambil langsung. Jika object, ambil slug-nya.
|
||||
$roleIdentifier = is_object($user->role) ? $user->role->slug : $user->role;
|
||||
|
||||
// Admin Bypass: Role admin memiliki akses ke seluruh fitur
|
||||
if ($roleIdentifier === 'admin') {
|
||||
// Dukungan akun legacy yang masih menyimpan role sebagai string.
|
||||
if ($user->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$user->role_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->roleModel;
|
||||
if ($role?->slug === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Menyelaraskan nama kolom (can_read, can_create, can_update, can_delete)
|
||||
$column = str_starts_with($permission, 'can_') ? $permission : 'can_' . $permission;
|
||||
|
||||
// Cek matriks di tabel role_permissions berdasarkan role_id
|
||||
return \App\Models\RolePermission::where('role_id', $user->role_id)
|
||||
->where('module_slug', $module)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
try {
|
||||
return \App\Models\RolePermission::where('role_id', $user->role_id)
|
||||
->where('module_slug', $module)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
trait HasPermissions {
|
||||
public function hasPermission($moduleSlug, $action) {
|
||||
// Jika user tidak punya role, akses ditolak
|
||||
if (!$this->role) return false;
|
||||
|
||||
// Cek ke tabel permission_role
|
||||
return $this->role->permissions()
|
||||
trait HasPermissions
|
||||
{
|
||||
public function hasPermission($moduleSlug, $action): bool
|
||||
{
|
||||
if ($this->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
$role = $this->roleModel;
|
||||
if (!$role) return false;
|
||||
$column = str_starts_with($action, 'can_') ? $action : 'can_' . $action;
|
||||
return $role->permissions()
|
||||
->where('module_slug', $moduleSlug)
|
||||
->where($action, 1)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-7
@@ -3,28 +3,39 @@
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
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__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
|
||||
// 1. Mendaftarkan alias middleware (Existing & Optimized)
|
||||
// 1. Mendaftarkan alias middleware
|
||||
$middleware->alias([
|
||||
'admin' => AdminMiddleware::class,
|
||||
'mobile.token' => AuthenticateApiToken::class,
|
||||
]);
|
||||
|
||||
// 2. Mengarahkan Tamu (Guest) secara global ke halaman login jika mencoba akses rute terproteksi
|
||||
// 2. Mengarahkan Tamu ke halaman login
|
||||
$middleware->redirectGuestsTo(fn () => route('login'));
|
||||
|
||||
// 3. Mengarahkan User yang sudah login ke Dashboard jika mencoba mengakses halaman login kembali
|
||||
// 3. Mengarahkan User login ke Dashboard
|
||||
$middleware->redirectUsersTo(fn () => route('dashboard'));
|
||||
|
||||
// 4. FIX HTTPS & PROXY (Sangat penting untuk Hostinger agar tidak 500/Loop)
|
||||
$middleware->trustProxies(at: '*');
|
||||
|
||||
// 5. PENGECEK CSRF (Buka ini jika masih error 419 saat pendaftaran biometrik)
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'webauthn/*',
|
||||
]);
|
||||
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
// Di sini Anda bisa menambahkan penanganan error kustom jika diperlukan di masa depan
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
})->create();
|
||||
@@ -10,6 +10,7 @@
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"intervention/image": "^3.11",
|
||||
"intervention/image-laravel": "^1.5",
|
||||
"laragear/webauthn": "^5.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"maatwebsite/excel": "^3.1",
|
||||
|
||||
Generated
+166
-11
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "caa17cbea90f285d9dc19b2eb78ab1e9",
|
||||
"content-hash": "a293add0741bc2bc83272d5a80311add",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
@@ -1833,6 +1833,161 @@
|
||||
],
|
||||
"time": "2025-04-04T15:09:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laragear/meta-model",
|
||||
"version": "v3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Laragear/MetaModel.git",
|
||||
"reference": "5f6f15e390c934cb10068117555242c2d29e9caa"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Laragear/MetaModel/zipball/5f6f15e390c934cb10068117555242c2d29e9caa",
|
||||
"reference": "5f6f15e390c934cb10068117555242c2d29e9caa",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"illuminate/container": "12.*|13.*",
|
||||
"illuminate/contracts": "12.*|13.*",
|
||||
"illuminate/database": "12.*|13.*",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"phpunit/phpunit": "12.*|13.*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laragear\\MetaModel\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Italo Israel Baeza Cabrera",
|
||||
"email": "DarkGhostHunter@Gmail.com",
|
||||
"homepage": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Let other developers customize your package model and migrations",
|
||||
"keywords": [
|
||||
"database",
|
||||
"eloquent",
|
||||
"laravel",
|
||||
"model"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Laragear/MetaModel/issues",
|
||||
"source": "https://github.com/Laragear/MetaModel"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"type": "Github Sponsorship"
|
||||
},
|
||||
{
|
||||
"url": "https://paypal.me/darkghosthunter",
|
||||
"type": "Paypal"
|
||||
}
|
||||
],
|
||||
"time": "2026-03-04T16:50:11+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laragear/webauthn",
|
||||
"version": "v5.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Laragear/WebAuthn.git",
|
||||
"reference": "134eec9e6655b56e6ea5fb63f8174195c5ef55d3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Laragear/WebAuthn/zipball/134eec9e6655b56e6ea5fb63f8174195c5ef55d3",
|
||||
"reference": "134eec9e6655b56e6ea5fb63f8174195c5ef55d3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"illuminate/auth": "12.*|13.*",
|
||||
"illuminate/config": "12.*|13.*",
|
||||
"illuminate/database": "12.*|13.*",
|
||||
"illuminate/encryption": "12.*|13.*",
|
||||
"illuminate/http": "12.*|13.*",
|
||||
"illuminate/session": "12.*|13.*",
|
||||
"illuminate/support": "12.*|13.*",
|
||||
"laragear/meta-model": "3.*",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-sodium": "*",
|
||||
"orchestra/testbench": "10.*|11.*"
|
||||
},
|
||||
"suggest": {
|
||||
"paragonie/sodium_compat": "To enable EdDSA 25519 keys from authenticators, if `ext-sodium` is unavailable."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laragear\\WebAuthn\\WebAuthnServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laragear\\WebAuthn\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Lukas Buchs",
|
||||
"role": "Original developer"
|
||||
},
|
||||
{
|
||||
"name": "Italo Israel Baeza Cabrera",
|
||||
"email": "DarkGhostHunter@Gmail.com",
|
||||
"homepage": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Authenticate users with Passkeys: fingerprints, patterns and biometric data.",
|
||||
"homepage": "https://github.com/laragear/webauthn",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"faceid",
|
||||
"laravel",
|
||||
"passkeys",
|
||||
"touchid",
|
||||
"webauthn",
|
||||
"windows hello"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Laragear/WebAuthn/issues",
|
||||
"source": "https://github.com/Laragear/WebAuthn"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/sponsors/DarkGhostHunter",
|
||||
"type": "Github Sponsorship"
|
||||
},
|
||||
{
|
||||
"url": "https://paypal.me/darkghosthunter",
|
||||
"type": "Paypal"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-06T22:00:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.48.1",
|
||||
@@ -2802,29 +2957,29 @@
|
||||
},
|
||||
{
|
||||
"name": "maatwebsite/excel",
|
||||
"version": "3.1.67",
|
||||
"version": "3.1.68",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
|
||||
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
|
||||
"reference": "1854739267d81d38eae7d8c623caf523f30f256b"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
|
||||
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/1854739267d81d38eae7d8c623caf523f30f256b",
|
||||
"reference": "1854739267d81d38eae7d8c623caf523f30f256b",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/semver": "^3.3",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
|
||||
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0",
|
||||
"php": "^7.0||^8.0",
|
||||
"phpoffice/phpspreadsheet": "^1.30.0",
|
||||
"psr/simple-cache": "^1.0||^2.0||^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
|
||||
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
|
||||
"laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0",
|
||||
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0",
|
||||
"predis/predis": "^1.1"
|
||||
},
|
||||
"type": "library",
|
||||
@@ -2867,7 +3022,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
|
||||
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
|
||||
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.68"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2879,7 +3034,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-08-26T09:13:16+00:00"
|
||||
"time": "2026-03-17T20:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
@@ -9931,5 +10086,5 @@
|
||||
"php": "^8.2"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
"plugin-api-version": "2.6.0"
|
||||
}
|
||||
|
||||
+9
-1
@@ -61,8 +61,9 @@ return [
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'driver' => 'eloquent-webauthn',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
'password_fallback' => true,
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
@@ -71,6 +72,13 @@ return [
|
||||
// ],
|
||||
],
|
||||
|
||||
|
||||
'relying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', 'Ily Cattery'),
|
||||
'id' => env('WEBAUTHN_ID', 'ilycats.earrinasia.com'), // Pastikan ini pas
|
||||
'icon' => null,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Relying Party
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| We will use your application information to inform the device who is the
|
||||
| relying party. While only the name is enough, you can further set the
|
||||
| a custom domain as ID and even an icon image data encoded as BASE64.
|
||||
|
|
||||
*/
|
||||
|
||||
'relying_party' => [
|
||||
'name' => env('WEBAUTHN_NAME', config('app.name')),
|
||||
'id' => env('WEBAUTHN_ID'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Origins
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, only your application domain is used as a valid origin for
|
||||
| all ceremonies. If you are using your app as a backend for an app or
|
||||
| UI you may set additional origins to check against the ceremonies.
|
||||
|
|
||||
| For multiple origins, separate them using comma, like `foo,bar`.
|
||||
*/
|
||||
|
||||
'origins' => env('WEBAUTHN_ORIGINS'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Challenge configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When making challenges your application needs to push at least 16 bytes
|
||||
| of randomness. Since we need to later check them, we'll also store the
|
||||
| bytes for a small amount of time inside this current request session.
|
||||
|
|
||||
| @see https://www.w3.org/TR/webauthn-2/#sctn-cryptographic-challenges
|
||||
|
|
||||
*/
|
||||
|
||||
'challenge' => [
|
||||
'bytes' => 16,
|
||||
'timeout' => 60,
|
||||
'key' => '_webauthn',
|
||||
],
|
||||
];
|
||||
@@ -20,8 +20,6 @@ return new class extends Migration
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
// No-op: kolom deleted_at dimiliki migration legacy sebelumnya.
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ return new class extends Migration
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('breeding_tables');
|
||||
Schema::dropIfExists('kittens');
|
||||
Schema::dropIfExists('mattings');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void {
|
||||
Schema::create('medical_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Relasi ke kucing
|
||||
$table->foreignId('cat_id')->constrained()->onDelete('cascade');
|
||||
|
||||
// Jenis tindakan: Vaksin, Obat Cacing, Kutu, Grooming
|
||||
$table->enum('type', ['Vaccine', 'Deworming', 'Flea Treatment', 'Grooming', 'Medical Checkup']);
|
||||
|
||||
$table->string('treatment_name'); // Misal: Felocell 4, Drontal, NexGard
|
||||
$table->date('date_given'); // Tanggal pemberian
|
||||
$table->date('next_schedule')->nullable(); // Tanggal jadwal berikutnya
|
||||
|
||||
$table->text('notes')->nullable(); // Catatan tambahan (misal: reaksi setelah vaksin)
|
||||
|
||||
// PIC yang memberikan tindakan
|
||||
$table->foreignId('performed_by')->nullable()->constrained('users')->onDelete('set null');
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
Schema::dropIfExists('medical_records');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('medical_records', function (Blueprint $table) {
|
||||
$table->string('dosage')->nullable()->after('treatment_name'); // Menyimpan "1/2", "0.5", atau "1"
|
||||
$table->string('unit')->nullable()->after('dosage'); // Menyimpan "pcs", "tablet", atau "ml"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('medical_records', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
$table->string('sertifikat_ped_file')->nullable()->after('pedigree');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Laragear\WebAuthn\Models\WebAuthnCredential;
|
||||
|
||||
return WebAuthnCredential::migration()->with(function (Blueprint $table) {
|
||||
// Here you can add custom columns to the Two Factor table.
|
||||
//
|
||||
// $table->string('alias')->nullable();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Migration legacy duplikat. Tabel medical_records sudah dibuat oleh migration sebelumnya.
|
||||
* Sengaja no-op agar fresh migration dan rollback tidak menghapus tabel yang benar.
|
||||
*/
|
||||
public function up(): void {}
|
||||
|
||||
public function down(): void {}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('medicines', function (Blueprint $table) {
|
||||
// Tambahkan kolom tanggal expired setelah unit
|
||||
$table->date('expired_at')->nullable()->after('unit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('medicines', function (Blueprint $table) {
|
||||
$table->dropColumn('expired_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('foods', function (Blueprint $table) {
|
||||
$table->date('expired_at')->nullable()->after('unit');
|
||||
});
|
||||
|
||||
Schema::table('supplies', function (Blueprint $table) {
|
||||
$table->date('expired_at')->nullable()->after('unit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('foods', function (Blueprint $table) {
|
||||
$table->dropColumn('expired_at');
|
||||
});
|
||||
|
||||
Schema::table('supplies', function (Blueprint $table) {
|
||||
$table->dropColumn('expired_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
// Menambahkan kolom treatment_status
|
||||
// Default 2 (Healthy/Sehat)
|
||||
// Tipe integer agar ringan, diletakkan setelah kolom status yang sudah ada
|
||||
$table->integer('treatment_status')->default(2)->after('status')->comment('1: Under Treatment, 2: Healthy');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cats', function (Blueprint $table) {
|
||||
$table->dropColumn('treatment_status');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('treatment_details', function (Blueprint $table) {
|
||||
$table->id();
|
||||
// Menghubungkan ke tabel rekam medis utama
|
||||
$table->foreignId('treatment_id')->constrained('treatments')->onDelete('cascade');
|
||||
|
||||
// Menghubungkan ke tabel inventory obat (medicines)
|
||||
// Jika kolom di tabel medicines adalah 'id', gunakan foreignId
|
||||
$table->foreignId('medicine_id')->constrained('medicines');
|
||||
|
||||
$table->string('dosis')->nullable(); // Contoh: 0.5ml atau 1 tablet
|
||||
$table->string('keterangan_pakai')->nullable(); // Contoh: Campur Nebu, Sesudah makan
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('treatment_details');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('treatments', function (Blueprint $table) {
|
||||
// Menghapus kolom obat lama karena sudah digantikan oleh tabel detail
|
||||
// Pastikan kolom ini memang ada sebelum dijalankan
|
||||
if (Schema::hasColumn('treatments', 'nama_obat')) {
|
||||
$table->dropColumn(['nama_obat', 'dosis', 'keterangan']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('treatments', function (Blueprint $table) {
|
||||
// Mengembalikan kolom jika rollback (opsional)
|
||||
$table->string('nama_obat')->nullable();
|
||||
$table->string('dosis')->nullable();
|
||||
$table->text('keterangan')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('treatments', function (Blueprint $table) {
|
||||
$table->integer('status_perawatan')->default(1);
|
||||
$table->text('remark_pasca_sakit')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('treatments', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cat_associations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code', 30)->unique();
|
||||
$table->string('name');
|
||||
$table->string('country')->nullable();
|
||||
$table->string('website')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('cat_registrations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cat_id')->constrained('cats')->cascadeOnDelete();
|
||||
$table->foreignId('association_id')->constrained('cat_associations')->restrictOnDelete();
|
||||
$table->string('registration_number')->nullable();
|
||||
$table->string('pedigree_number')->nullable();
|
||||
$table->string('certificate_number')->nullable();
|
||||
$table->date('issue_date')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('ownership_status', 40)->nullable();
|
||||
$table->boolean('is_primary')->default(false);
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(['cat_id', 'association_id'], 'cat_registration_association_unique');
|
||||
$table->index(['cat_id', 'is_primary']);
|
||||
});
|
||||
|
||||
Schema::create('cat_registration_documents', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('cat_registration_id')->constrained('cat_registrations')->cascadeOnDelete();
|
||||
$table->string('label')->default('Sertifikat Pedigree');
|
||||
$table->string('document_type', 40)->default('pedigree');
|
||||
$table->string('file_path');
|
||||
$table->date('issued_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
$associations = [
|
||||
['code' => 'ICA', 'name' => 'Indonesian Cat Association', 'country' => 'Indonesia'],
|
||||
['code' => 'CFA', 'name' => 'The Cat Fanciers\' Association', 'country' => 'Amerika Serikat'],
|
||||
['code' => 'WCF', 'name' => 'World Cat Federation', 'country' => 'Internasional'],
|
||||
['code' => 'FIFe', 'name' => 'Fédération Internationale Féline', 'country' => 'Internasional'],
|
||||
['code' => 'FFF', 'name' => 'Federasi Feline Indonesia', 'country' => 'Indonesia'],
|
||||
];
|
||||
|
||||
foreach ($associations as $association) {
|
||||
DB::table('cat_associations')->updateOrInsert(
|
||||
['code' => $association['code']],
|
||||
array_merge($association, ['is_active' => 1, 'created_at' => now(), 'updated_at' => now()])
|
||||
);
|
||||
}
|
||||
|
||||
// Migrasi aman dari field pedigree/registry lama. Field lama tidak dihapus agar rollback mudah.
|
||||
if (Schema::hasTable('cats')) {
|
||||
DB::table('cats')->orderBy('id')->chunkById(100, function ($cats) {
|
||||
foreach ($cats as $cat) {
|
||||
$rawRegistry = $cat->registry ?? null;
|
||||
$registries = [];
|
||||
if (is_string($rawRegistry) && $rawRegistry !== '') {
|
||||
$decoded = json_decode($rawRegistry, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
$registries = is_array($decoded) ? $decoded : [$decoded];
|
||||
} else {
|
||||
$registries = preg_split('/[,;|]+/', $rawRegistry) ?: [];
|
||||
}
|
||||
}
|
||||
$registries = array_values(array_unique(array_filter(array_map(fn($v) => trim((string)$v), $registries), fn($v) => $v && strtoupper($v) !== 'NON-PED')));
|
||||
|
||||
// Bila nomor pedigree lama ada tetapi registry kosong, simpan di asosiasi LEGACY.
|
||||
if (!$registries && !empty($cat->pedigree)) {
|
||||
$registries = ['LEGACY'];
|
||||
}
|
||||
|
||||
foreach ($registries as $index => $code) {
|
||||
$code = strtoupper($code);
|
||||
$associationId = DB::table('cat_associations')->whereRaw('UPPER(code) = ?', [$code])->value('id');
|
||||
if (!$associationId) {
|
||||
$associationId = DB::table('cat_associations')->insertGetId([
|
||||
'code' => $code,
|
||||
'name' => $code === 'LEGACY' ? 'Registrasi Lama' : $code,
|
||||
'is_active' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$registrationId = DB::table('cat_registrations')->insertGetId([
|
||||
'cat_id' => $cat->id,
|
||||
'association_id' => $associationId,
|
||||
'registration_number' => $index === 0 ? ($cat->pedigree ?? null) : null,
|
||||
'pedigree_number' => $index === 0 ? ($cat->pedigree ?? null) : null,
|
||||
'is_primary' => $index === 0 ? 1 : 0,
|
||||
'notes' => 'Dimigrasikan otomatis dari data registrasi lama.',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if ($index === 0 && !empty($cat->sertifikat_ped_file)) {
|
||||
DB::table('cat_registration_documents')->insert([
|
||||
'cat_registration_id' => $registrationId,
|
||||
'label' => 'Sertifikat lama',
|
||||
'document_type' => 'pedigree',
|
||||
'file_path' => $cat->sertifikat_ped_file,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 'id');
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cat_registration_documents');
|
||||
Schema::dropIfExists('cat_registrations');
|
||||
Schema::dropIfExists('cat_associations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('api_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->string('name')->default('Android');
|
||||
$table->string('token_hash', 64)->unique();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('api_tokens');
|
||||
}
|
||||
};
|
||||
Generated
-6
@@ -1409,7 +1409,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2670,7 +2669,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3095,7 +3093,6 @@
|
||||
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"arg": "^5.0.2",
|
||||
@@ -3216,7 +3213,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3305,7 +3301,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -3410,7 +3405,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
Vendored
+373
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Italo Israel Baeza Cabrera
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use Webpass instead
|
||||
* @see https://github.com/Laragear/webpass
|
||||
*/
|
||||
class WebAuthn {
|
||||
/**
|
||||
* Routes for WebAuthn assertion (login) and attestation (register).
|
||||
*
|
||||
* @type {{registerOptions: string, register: string, loginOptions: string, login: string, }}
|
||||
*/
|
||||
#routes = {
|
||||
registerOptions: "webauthn/register/options",
|
||||
register: "webauthn/register",
|
||||
loginOptions: "webauthn/login/options",
|
||||
login: "webauthn/login",
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to use in ALL requests done.
|
||||
*
|
||||
* @type {{Accept: string, "Content-Type": string, "X-Requested-With": string}}
|
||||
*/
|
||||
#headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
|
||||
/**
|
||||
* If set to true, the credentials option will be set to 'include' on all fetch calls,
|
||||
* or else it will use the default 'same-origin'. Use this if the backend is not the
|
||||
* same origin as the client or the XSRF protection will break without the session.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#includeCredentials = false
|
||||
|
||||
/**
|
||||
* Create a new WebAuthn instance.
|
||||
*
|
||||
* @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}}
|
||||
* @param headers {{string}}
|
||||
* @param includeCredentials {boolean}
|
||||
* @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars)
|
||||
*/
|
||||
constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) {
|
||||
console.warn('This WebAuthn Helper is deprecated and will be removed in the future. Consider migrating to @laragear/webpass')
|
||||
|
||||
Object.assign(this.#routes, routes);
|
||||
Object.assign(this.#headers, headers);
|
||||
|
||||
this.#includeCredentials = includeCredentials;
|
||||
|
||||
let xsrfToken;
|
||||
let csrfToken;
|
||||
|
||||
if (xcsrfToken === null) {
|
||||
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
||||
xsrfToken = WebAuthn.#XsrfToken;
|
||||
csrfToken = WebAuthn.#firstInputWithCsrfToken;
|
||||
} else {
|
||||
// Check if it is a CSRF or XSRF token
|
||||
if (xcsrfToken.length === 40) {
|
||||
csrfToken = xcsrfToken;
|
||||
} else if (xcsrfToken.length === 224) {
|
||||
xsrfToken = xcsrfToken;
|
||||
} else {
|
||||
throw new TypeError('CSRF token or XSRF token provided does not match requirements. Must be 40 or 224 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
if (xsrfToken !== null) {
|
||||
this.#headers["X-XSRF-TOKEN"] ??= xsrfToken;
|
||||
} else if (csrfToken !== null) {
|
||||
this.#headers["X-CSRF-TOKEN"] ??= csrfToken;
|
||||
} else {
|
||||
// We didn't find it, and since is required, we will bail out.
|
||||
throw new TypeError('Ensure a CSRF/XSRF token is manually set, or provided in a cookie "XSRF-TOKEN" or or there is meta tag named "csrf-token".');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSRF token if it exists as a form input tag.
|
||||
*
|
||||
* @returns string
|
||||
* @throws TypeError
|
||||
*/
|
||||
static get #firstInputWithCsrfToken() {
|
||||
// First, try finding an CSRF Token in the head.
|
||||
let token = Array.from(document.head.getElementsByTagName("meta"))
|
||||
.find(element => element.name === "csrf-token");
|
||||
|
||||
if (token) {
|
||||
return token.content;
|
||||
}
|
||||
|
||||
// Then, try to find a hidden input containing the CSRF token.
|
||||
token = Array.from(document.getElementsByTagName('input'))
|
||||
.find(input => input.name === "_token" && input.type === "hidden")
|
||||
|
||||
if (token) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the XSRF token if it exists in a cookie.
|
||||
*
|
||||
* Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2
|
||||
*
|
||||
* @returns {?string}
|
||||
*/
|
||||
static get #XsrfToken() {
|
||||
const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row));
|
||||
// We must remove all '%3D' from the end of the string.
|
||||
// Background:
|
||||
// The actual binary value of the CSFR value is encoded in Base64.
|
||||
// If the length of original, binary value is not a multiple of 3 bytes,
|
||||
// the encoding gets padded with `=` on the right; i.e. there might be
|
||||
// zero, one or two `=` at the end of the encoded value.
|
||||
// If the value is sent from the server to the client as part of a cookie,
|
||||
// the `=` character is URL-encoded as `%3D`, because `=` is already used
|
||||
// to separate a cookie key from its value.
|
||||
// When we send back the value to the server as part of an AJAX request,
|
||||
// Laravel expects an unpadded value.
|
||||
// Hence, we must remove the `%3D`.
|
||||
return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a fetch promise to resolve later.
|
||||
*
|
||||
* @param data {Object}
|
||||
* @param route {string}
|
||||
* @param headers {{string}}
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
#fetch(data, route, headers = {}) {
|
||||
const url = new URL(route, window.location.origin).href;
|
||||
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
credentials: this.#includeCredentials ? "include" : "same-origin",
|
||||
redirect: "error",
|
||||
headers: { ...this.#headers, ...headers },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
static #base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return atob(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param useAtob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static #uint8Array(input, useAtob = false) {
|
||||
return Uint8Array.from(
|
||||
useAtob ? atob(input) : WebAuthn.#base64UrlDecode(input), c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
static #arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
#parseIncomingServerOptions(publicKey) {
|
||||
console.debug(publicKey);
|
||||
|
||||
publicKey.challenge = WebAuthn.#uint8Array(publicKey.challenge);
|
||||
|
||||
if ('user' in publicKey) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: WebAuthn.#uint8Array(publicKey.user.id)
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
"excludeCredentials",
|
||||
"allowCredentials"
|
||||
]
|
||||
.filter(key => key in publicKey)
|
||||
.forEach(key => {
|
||||
publicKey[key] = publicKey[key].map(data => {
|
||||
return { ...data, id: WebAuthn.#uint8Array(data.id) };
|
||||
});
|
||||
});
|
||||
|
||||
console.log(publicKey);
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
#parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: WebAuthn.#arrayToBase64String(credentials.rawId),
|
||||
authenticatorAttachment: credentials.authenticatorAttachment,
|
||||
clientExtensionResults: credentials.getClientExtensionResults(),
|
||||
response: {},
|
||||
}
|
||||
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter(key => key in credentials.response)
|
||||
.forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key]));
|
||||
|
||||
return parseCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the Server.
|
||||
*
|
||||
* Throws the entire response if is not OK (HTTP 2XX).
|
||||
*
|
||||
* @param response {Response}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
* @throws Response
|
||||
*/
|
||||
static #handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Here we will do a small trick. Since most of the responses from the server
|
||||
// are JSON, we will automatically parse the JSON body from the response. If
|
||||
// it's not JSON, we will push the body verbatim and let the dev handle it.
|
||||
return new Promise((resolve) => {
|
||||
response
|
||||
.json()
|
||||
.then((json) => resolve(json))
|
||||
.catch(() => resolve(response.body));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user credentials from the browser/device.
|
||||
*
|
||||
* You can add request input if you are planning to register a user with WebAuthn from scratch.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async register(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.registerOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.create({ publicKey });
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
Object.assign(publicKeyCredential, request);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in a user with his credentials.
|
||||
*
|
||||
* If no credentials are given, the app may return a blank assertion for userless login.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async login(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.loginOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.get({ publicKey });
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
|
||||
console.log(publicKeyCredential);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.login, response).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser supports WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static supportsWebAuthn() {
|
||||
return typeof PublicKeyCredential != "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser doesn't support WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static doesntSupportWebAuthn() {
|
||||
return !this.supportsWebAuthn();
|
||||
}
|
||||
|
||||
}
|
||||
export default WebAuthn;
|
||||
@@ -1,3 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
|
||||
@layer components {
|
||||
.field-label { @apply mb-1.5 block text-xs font-medium text-slate-600; }
|
||||
.field-input { @apply w-full border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-800 shadow-none focus:border-indigo-500 focus:ring-indigo-500; }
|
||||
}
|
||||
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) Italo Israel Baeza Cabrera
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated Use Webpass instead
|
||||
* @see https://github.com/Laragear/webpass
|
||||
*/
|
||||
class WebAuthn {
|
||||
/**
|
||||
* Routes for WebAuthn assertion (login) and attestation (register).
|
||||
*
|
||||
* @type {{registerOptions: string, register: string, loginOptions: string, login: string, }}
|
||||
*/
|
||||
#routes = {
|
||||
registerOptions: "webauthn/register/options",
|
||||
register: "webauthn/register",
|
||||
loginOptions: "webauthn/login/options",
|
||||
login: "webauthn/login",
|
||||
}
|
||||
|
||||
/**
|
||||
* Headers to use in ALL requests done.
|
||||
*
|
||||
* @type {{Accept: string, "Content-Type": string, "X-Requested-With": string}}
|
||||
*/
|
||||
#headers = {
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
|
||||
/**
|
||||
* If set to true, the credentials option will be set to 'include' on all fetch calls,
|
||||
* or else it will use the default 'same-origin'. Use this if the backend is not the
|
||||
* same origin as the client or the XSRF protection will break without the session.
|
||||
*
|
||||
* @type {boolean}
|
||||
*/
|
||||
#includeCredentials = false
|
||||
|
||||
/**
|
||||
* Create a new WebAuthn instance.
|
||||
*
|
||||
* @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}}
|
||||
* @param headers {{string}}
|
||||
* @param includeCredentials {boolean}
|
||||
* @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars)
|
||||
*/
|
||||
constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) {
|
||||
console.warn('This WebAuthn Helper is deprecated and will be removed in the future. Consider migrating to @laragear/webpass')
|
||||
|
||||
Object.assign(this.#routes, routes);
|
||||
Object.assign(this.#headers, headers);
|
||||
|
||||
this.#includeCredentials = includeCredentials;
|
||||
|
||||
let xsrfToken;
|
||||
let csrfToken;
|
||||
|
||||
if (xcsrfToken === null) {
|
||||
// If the developer didn't issue an XSRF token, we will find it ourselves.
|
||||
xsrfToken = WebAuthn.#XsrfToken;
|
||||
csrfToken = WebAuthn.#firstInputWithCsrfToken;
|
||||
} else{
|
||||
// Check if it is a CSRF or XSRF token
|
||||
if (xcsrfToken.length === 40) {
|
||||
csrfToken = xcsrfToken;
|
||||
} else if (xcsrfToken.length === 224) {
|
||||
xsrfToken = xcsrfToken;
|
||||
} else {
|
||||
throw new TypeError('CSRF token or XSRF token provided does not match requirements. Must be 40 or 224 characters.');
|
||||
}
|
||||
}
|
||||
|
||||
if (xsrfToken !== null) {
|
||||
this.#headers["X-XSRF-TOKEN"] ??= xsrfToken;
|
||||
} else if (csrfToken !== null) {
|
||||
this.#headers["X-CSRF-TOKEN"] ??= csrfToken;
|
||||
} else {
|
||||
// We didn't find it, and since is required, we will bail out.
|
||||
throw new TypeError('Ensure a CSRF/XSRF token is manually set, or provided in a cookie "XSRF-TOKEN" or or there is meta tag named "csrf-token".');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CSRF token if it exists as a form input tag.
|
||||
*
|
||||
* @returns string
|
||||
* @throws TypeError
|
||||
*/
|
||||
static get #firstInputWithCsrfToken() {
|
||||
// First, try finding an CSRF Token in the head.
|
||||
let token = Array.from(document.head.getElementsByTagName("meta"))
|
||||
.find(element => element.name === "csrf-token");
|
||||
|
||||
if (token) {
|
||||
return token.content;
|
||||
}
|
||||
|
||||
// Then, try to find a hidden input containing the CSRF token.
|
||||
token = Array.from(document.getElementsByTagName('input'))
|
||||
.find(input => input.name === "_token" && input.type === "hidden")
|
||||
|
||||
if (token) {
|
||||
return token.value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the XSRF token if it exists in a cookie.
|
||||
*
|
||||
* Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2
|
||||
*
|
||||
* @returns {?string}
|
||||
*/
|
||||
static get #XsrfToken() {
|
||||
const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row));
|
||||
// We must remove all '%3D' from the end of the string.
|
||||
// Background:
|
||||
// The actual binary value of the CSFR value is encoded in Base64.
|
||||
// If the length of original, binary value is not a multiple of 3 bytes,
|
||||
// the encoding gets padded with `=` on the right; i.e. there might be
|
||||
// zero, one or two `=` at the end of the encoded value.
|
||||
// If the value is sent from the server to the client as part of a cookie,
|
||||
// the `=` character is URL-encoded as `%3D`, because `=` is already used
|
||||
// to separate a cookie key from its value.
|
||||
// When we send back the value to the server as part of an AJAX request,
|
||||
// Laravel expects an unpadded value.
|
||||
// Hence, we must remove the `%3D`.
|
||||
return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a fetch promise to resolve later.
|
||||
*
|
||||
* @param data {Object}
|
||||
* @param route {string}
|
||||
* @param headers {{string}}
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
#fetch(data, route, headers = {}) {
|
||||
const url = new URL(route, window.location.origin).href;
|
||||
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
credentials: this.#includeCredentials ? "include" : "same-origin",
|
||||
redirect: "error",
|
||||
headers: {...this.#headers, ...headers},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a BASE64 URL string into a normal string.
|
||||
*
|
||||
* @param input {string}
|
||||
* @returns {string|Iterable}
|
||||
*/
|
||||
static #base64UrlDecode(input) {
|
||||
input = input.replace(/-/g, "+").replace(/_/g, "/");
|
||||
|
||||
const pad = input.length % 4;
|
||||
|
||||
if (pad) {
|
||||
if (pad === 1) {
|
||||
throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding");
|
||||
}
|
||||
|
||||
input += new Array(5 - pad).join("=");
|
||||
}
|
||||
|
||||
return atob(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a string into Uint8Array instance.
|
||||
*
|
||||
* @param input {string}
|
||||
* @param useAtob {boolean}
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
static #uint8Array(input, useAtob = false) {
|
||||
return Uint8Array.from(
|
||||
useAtob ? atob(input) : WebAuthn.#base64UrlDecode(input), c => c.charCodeAt(0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an array of bytes to a BASE64 URL string
|
||||
*
|
||||
* @param arrayBuffer {ArrayBuffer|Uint8Array}
|
||||
* @returns {string}
|
||||
*/
|
||||
static #arrayToBase64String(arrayBuffer) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Public Key Options received from the Server for the browser.
|
||||
*
|
||||
* @param publicKey {Object}
|
||||
* @returns {Object}
|
||||
*/
|
||||
#parseIncomingServerOptions(publicKey) {
|
||||
console.debug(publicKey);
|
||||
|
||||
publicKey.challenge = WebAuthn.#uint8Array(publicKey.challenge);
|
||||
|
||||
if ('user' in publicKey) {
|
||||
publicKey.user = {
|
||||
...publicKey.user,
|
||||
id: WebAuthn.#uint8Array(publicKey.user.id)
|
||||
};
|
||||
}
|
||||
|
||||
[
|
||||
"excludeCredentials",
|
||||
"allowCredentials"
|
||||
]
|
||||
.filter(key => key in publicKey)
|
||||
.forEach(key => {
|
||||
publicKey[key] = publicKey[key].map(data => {
|
||||
return {...data, id: WebAuthn.#uint8Array(data.id)};
|
||||
});
|
||||
});
|
||||
|
||||
console.log(publicKey);
|
||||
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the outgoing credentials from the browser to the server.
|
||||
*
|
||||
* @param credentials {Credential|PublicKeyCredential}
|
||||
* @return {{response: {string}, rawId: string, id: string, type: string}}
|
||||
*/
|
||||
#parseOutgoingCredentials(credentials) {
|
||||
let parseCredentials = {
|
||||
id: credentials.id,
|
||||
type: credentials.type,
|
||||
rawId: WebAuthn.#arrayToBase64String(credentials.rawId),
|
||||
authenticatorAttachment: credentials.authenticatorAttachment,
|
||||
clientExtensionResults: credentials.getClientExtensionResults(),
|
||||
response: {},
|
||||
}
|
||||
|
||||
[
|
||||
"clientDataJSON",
|
||||
"attestationObject",
|
||||
"authenticatorData",
|
||||
"signature",
|
||||
"userHandle"
|
||||
]
|
||||
.filter(key => key in credentials.response)
|
||||
.forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key]));
|
||||
|
||||
return parseCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the Server.
|
||||
*
|
||||
* Throws the entire response if is not OK (HTTP 2XX).
|
||||
*
|
||||
* @param response {Response}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
* @throws Response
|
||||
*/
|
||||
static #handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// Here we will do a small trick. Since most of the responses from the server
|
||||
// are JSON, we will automatically parse the JSON body from the response. If
|
||||
// it's not JSON, we will push the body verbatim and let the dev handle it.
|
||||
return new Promise((resolve) => {
|
||||
response
|
||||
.json()
|
||||
.then((json) => resolve(json))
|
||||
.catch(() => resolve(response.body));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the user credentials from the browser/device.
|
||||
*
|
||||
* You can add request input if you are planning to register a user with WebAuthn from scratch.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async register(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.registerOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.create({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
Object.assign(publicKeyCredential, request);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in a user with his credentials.
|
||||
*
|
||||
* If no credentials are given, the app may return a blank assertion for userless login.
|
||||
*
|
||||
* @param request {{string}}
|
||||
* @param response {{string}}
|
||||
* @returns Promise<JSON|ReadableStream>
|
||||
*/
|
||||
async login(request = {}, response = {}) {
|
||||
const optionsResponse = await this.#fetch(request, this.#routes.loginOptions);
|
||||
const json = await optionsResponse.json();
|
||||
const publicKey = this.#parseIncomingServerOptions(json);
|
||||
const credentials = await navigator.credentials.get({publicKey});
|
||||
const publicKeyCredential = this.#parseOutgoingCredentials(credentials);
|
||||
|
||||
Object.assign(publicKeyCredential, response);
|
||||
|
||||
console.log(publicKeyCredential);
|
||||
|
||||
return await this.#fetch(publicKeyCredential, this.#routes.login, response).then(WebAuthn.#handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser supports WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static supportsWebAuthn() {
|
||||
return typeof PublicKeyCredential != "undefined";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the browser doesn't support WebAuthn.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static doesntSupportWebAuthn() {
|
||||
return !this.supportsWebAuthn();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<x-guest-layout>
|
||||
<div class="mb-4 text-sm text-gray-600">
|
||||
{{ __('Forgot your password? No problem. Just let us know your email address and we will email you a password reset link that will allow you to choose a new one.') }}
|
||||
{{ __('Lupa kata sandi? Masukkan alamat email Anda dan kami akan mengirimkan tautan untuk mengatur ulang kata sandi.') }}
|
||||
</div>
|
||||
|
||||
<!-- Session Status -->
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Email Password Reset Link') }}
|
||||
{{ __('Kirim tautan atur ulang kata sandi') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,139 +1,39 @@
|
||||
<x-guest-layout>
|
||||
{{-- 1. ANIMASI CUSTOM: DYNAMIC ZOOM + NEON GLOW --}}
|
||||
<style>
|
||||
@keyframes logo-breathing-glow {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 20px rgba(99, 102, 241, 0.2), inset 0 0 10px rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 60px rgba(79, 70, 229, 0.6), inset 0 0 20px rgba(99, 102, 241, 0.4);
|
||||
border-color: rgba(129, 140, 248, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-logo-glow {
|
||||
animation: logo-breathing-glow 4s ease-in-out infinite;
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
</style>
|
||||
|
||||
<x-slot name="title">Masuk | {{ $setting->app_name ?? config('app.name') }}</x-slot>
|
||||
|
||||
<div class="min-h-screen flex bg-white overflow-hidden">
|
||||
|
||||
{{-- SISI KIRI: BRANDING --}}
|
||||
<div class="hidden lg:flex lg:w-[35%] relative bg-slate-900 justify-center items-center p-8">
|
||||
<div class="absolute inset-0 bg-cover bg-center opacity-30" style="background-image: url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop');"></div>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900 opacity-95"></div>
|
||||
|
||||
<div class="relative z-10 text-center w-full max-w-xs">
|
||||
<div class="inline-flex p-6 mb-8 bg-white/5 backdrop-blur-2xl rounded-[3rem] border border-white/10 animate-logo-glow transition-all">
|
||||
@if(isset($setting) && isset($setting->app_logo))
|
||||
<img src="{{ asset('storage/' . $setting->app_logo) }}" alt="Logo" class="h-24 w-auto max-w-[160px] object-contain filter drop-shadow-2xl">
|
||||
<div class="min-h-screen bg-slate-50 px-4 py-10 sm:flex sm:items-center sm:justify-center">
|
||||
<div class="mx-auto grid w-full max-w-4xl overflow-hidden border border-slate-200 bg-white shadow-sm lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<div class="hidden border-r border-slate-200 bg-slate-100 p-10 lg:flex lg:flex-col lg:justify-between">
|
||||
<div>
|
||||
@if(isset($setting->app_logo) && $setting->app_logo)
|
||||
<img src="{{ asset('storage/'.$setting->app_logo) }}" alt="Logo" class="mb-8 h-16 w-auto object-contain">
|
||||
@else
|
||||
<div class="w-24 h-24 bg-indigo-600 rounded-[2rem] flex items-center justify-center text-4xl font-black text-white italic">I</div>
|
||||
<div class="mb-8 flex h-14 w-14 items-center justify-center rounded-lg bg-indigo-600 text-xl text-white"><i class="fas fa-cat"></i></div>
|
||||
@endif
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ $setting->app_name ?? 'ILY Cattery' }}</h1>
|
||||
<p class="mt-3 max-w-sm text-sm leading-6 text-slate-600">Kelola data kucing, riwayat pembiakan, kesehatan, pedigree, dan persediaan dalam satu aplikasi.</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-3xl font-black text-white tracking-tighter mb-2 uppercase">{{ $setting->app_name ?? 'Ily Cattery' }}</h2>
|
||||
<p class="text-indigo-300 text-[10px] font-bold uppercase tracking-[0.4em] leading-relaxed italic opacity-70">{{ $setting->app_slogan ?? 'Intelligence Cattery System' }}</p>
|
||||
<p class="text-xs text-slate-500">Akses hanya untuk pengguna yang terdaftar.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- SISI KANAN: FORM LOGIN --}}
|
||||
<div class="flex-1 flex items-center justify-center p-6 sm:p-12 lg:p-16 bg-slate-50/50">
|
||||
<div class="w-full max-w-md space-y-10">
|
||||
|
||||
<div class="space-y-2 text-center lg:text-left">
|
||||
<div class="lg:hidden flex justify-center mb-6">
|
||||
@if(isset($setting) && isset($setting->app_logo))
|
||||
<img src="{{ asset('storage/' . $setting->app_logo) }}" class="h-14 w-auto">
|
||||
@endif
|
||||
</div>
|
||||
<h3 class="text-3xl font-black text-slate-900 tracking-tight">Access Portal</h3>
|
||||
<p class="text-slate-500 font-medium text-sm">Gunakan kredensial Anda untuk masuk.</p>
|
||||
<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>
|
||||
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-6">
|
||||
@csrf
|
||||
|
||||
<div class="space-y-4">
|
||||
{{-- Email --}}
|
||||
<div class="group">
|
||||
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block ml-1">Email / Username</label>
|
||||
<div class="relative">
|
||||
<span class="absolute inset-y-0 left-0 pl-5 flex items-center text-slate-300 group-focus-within:text-indigo-600 transition-colors">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
</span>
|
||||
<input type="email" name="email" :value="old('email')" required autofocus
|
||||
class="block w-full pl-12 pr-4 py-4 bg-white border border-slate-200 rounded-2xl text-slate-900 text-sm font-semibold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/30 transition-all outline-none shadow-sm"
|
||||
placeholder="admin@ilycats.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Password dengan Tombol View yang Proporsional --}}
|
||||
<div class="group">
|
||||
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 block ml-1">Password</label>
|
||||
<div class="relative flex items-center">
|
||||
<span class="absolute left-0 pl-5 flex items-center text-slate-300 group-focus-within:text-indigo-600 transition-colors">
|
||||
<i class="fas fa-lock"></i>
|
||||
</span>
|
||||
|
||||
<input id="password-field" type="password" name="password" required
|
||||
class="block w-full pl-12 pr-12 py-4 bg-white border border-slate-200 rounded-2xl text-slate-900 text-sm font-semibold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/30 transition-all outline-none shadow-sm"
|
||||
placeholder="••••••••">
|
||||
|
||||
{{-- Tombol Toggle View: Diposisikan lebih proporsional --}}
|
||||
<button type="button" onclick="togglePassword()"
|
||||
class="absolute right-4 p-2 text-slate-300 hover:text-indigo-600 transition-colors focus:outline-none flex items-center justify-center">
|
||||
<i id="toggle-icon" class="fas fa-eye text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label class="inline-flex items-center cursor-pointer group">
|
||||
<input type="checkbox" name="remember" class="w-5 h-5 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500/20">
|
||||
<span class="ml-3 text-xs font-bold text-slate-500 group-hover:text-slate-800 transition-colors">Ingat Sesi</span>
|
||||
</label>
|
||||
@if (Route::has('password.request'))
|
||||
<!-- <a href="{{ route('password.request') }}" class="text-xs font-bold text-indigo-600 hover:text-indigo-700">Lupa Sandi?</a> -->
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full py-4 bg-slate-900 hover:bg-indigo-700 text-white text-xs font-black uppercase tracking-[0.2em] rounded-2xl shadow-xl shadow-indigo-200 transition-all hover:-translate-y-1">
|
||||
Masuk Sistem
|
||||
</button>
|
||||
<x-auth-session-status class="mb-4" :status="session('status')" />
|
||||
<form method="POST" action="{{ route('login') }}" class="space-y-5">@csrf
|
||||
<div><label for="email" class="mb-2 block text-sm font-medium text-slate-700">Email</label><input id="email" type="email" name="email" value="{{ old('email') }}" required autofocus autocomplete="username" placeholder="nama@contoh.com" class="w-full border-slate-300 px-3 py-3 text-sm focus:border-indigo-500 focus:ring-indigo-500"></div>
|
||||
<div x-data="{show:false}"><label for="password-field" class="mb-2 block text-sm font-medium text-slate-700">Kata sandi</label><div class="relative"><input id="password-field" :type="show?'text':'password'" name="password" required autocomplete="current-password" class="w-full border-slate-300 px-3 py-3 pr-12 text-sm focus:border-indigo-500 focus:ring-indigo-500"><button type="button" @click="show=!show" class="absolute inset-y-0 right-0 px-4 text-slate-400" :aria-label="show?'Sembunyikan kata sandi':'Tampilkan kata sandi'"><i class="fas" :class="show?'fa-eye-slash':'fa-eye'"></i></button></div></div>
|
||||
<div class="flex items-center justify-between gap-4"><label class="flex items-center gap-2 text-sm text-slate-600"><input type="checkbox" name="remember" class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">Ingat saya di perangkat ini</label>@if(Route::has('password.request'))<a href="{{ route('password.request') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-700">Lupa kata sandi?</a>@endif</div>
|
||||
<button type="submit" class="w-full bg-indigo-600 px-4 py-3 text-sm font-semibold text-white hover:bg-indigo-700">Masuk</button>
|
||||
<button type="button" id="login-passkey" class="w-full border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50"><i class="fas fa-fingerprint mr-2 text-slate-500"></i>Masuk dengan biometrik</button>
|
||||
</form>
|
||||
|
||||
<div class="pt-8 text-center border-t border-slate-100">
|
||||
<p class="text-[9px] text-slate-400 font-bold uppercase tracking-widest opacity-60">
|
||||
{{ date('Y') }} {{ $setting->app_name ?? 'ILY CATTERY' }} • Encrypted Intelligence Access
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-8 border-t border-slate-200 pt-5 text-center text-xs text-slate-400">{{ date('Y') }} {{ $setting->app_name ?? 'ILY Cattery' }} · Akses aman</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 2. JAVASCRIPT: TOGGLE PASSWORD LOGIC --}}
|
||||
<script>
|
||||
function togglePassword() {
|
||||
const passwordField = document.getElementById('password-field');
|
||||
const toggleIcon = document.getElementById('toggle-icon');
|
||||
|
||||
if (passwordField.type === 'password') {
|
||||
passwordField.type = 'text';
|
||||
toggleIcon.classList.remove('fa-eye');
|
||||
toggleIcon.classList.add('fa-eye-slash');
|
||||
} else {
|
||||
passwordField.type = 'password';
|
||||
toggleIcon.classList.remove('fa-eye-slash');
|
||||
toggleIcon.classList.add('fa-eye');
|
||||
}
|
||||
}
|
||||
<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>
|
||||
|
||||
@@ -28,9 +28,9 @@
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<!-- Konfirmasi Kata Sandi -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-input-label for="password_confirmation" :value="__('Konfirmasi Kata Sandi')" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
@@ -41,11 +41,11 @@
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<a class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" href="{{ route('login') }}">
|
||||
{{ __('Already registered?') }}
|
||||
{{ __('Sudah memiliki akun?') }}
|
||||
</a>
|
||||
|
||||
<x-primary-button class="ms-4">
|
||||
{{ __('Register') }}
|
||||
{{ __('Daftar') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<!-- Konfirmasi Kata Sandi -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-input-label for="password_confirmation" :value="__('Konfirmasi Kata Sandi')" />
|
||||
|
||||
<x-text-input id="password_confirmation" class="block mt-1 w-full"
|
||||
type="password"
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<x-primary-button>
|
||||
{{ __('Reset Password') }}
|
||||
{{ __('Atur ulang kata sandi') }}
|
||||
</x-primary-button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
@csrf
|
||||
|
||||
<button type="submit" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ __('Log Out') }}
|
||||
{{ __('Keluar') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@php
|
||||
// Mengambil data dari database dengan fallback jika data kosong
|
||||
$appName = \App\Models\Setting::where('key', 'app_name')->value('value') ?? 'Ily Cattery';
|
||||
$appSlogan = \App\Models\Setting::where('key', 'app_slogan')->value('value') ?? 'Premium Breeding Management';
|
||||
$appSlogan = \App\Models\Setting::where('key', 'app_slogan')->value('value') ?? 'Pengelolaan Cattery ILY';
|
||||
@endphp
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
|
||||
@@ -1,26 +1,43 @@
|
||||
@props(['active', 'icon'])
|
||||
@props([
|
||||
'active' => false,
|
||||
'icon' => null,
|
||||
'tone' => 'indigo',
|
||||
])
|
||||
|
||||
@php
|
||||
// Logika untuk menentukan class CSS jika link sedang aktif atau tidak
|
||||
$classes = ($active ?? false)
|
||||
? 'flex items-center px-4 py-2.5 text-sm font-bold text-indigo-700 bg-indigo-50 rounded-xl border border-indigo-100 transition-all'
|
||||
: 'flex items-center px-4 py-2.5 text-sm font-medium text-slate-600 hover:bg-slate-50 hover:text-indigo-600 rounded-xl transition-all';
|
||||
$classes = ($active ?? false)
|
||||
? 'group flex items-center rounded-xl bg-slate-100 px-2.5 py-2 text-sm font-bold text-slate-900 transition-all'
|
||||
: 'group flex items-center rounded-xl px-2.5 py-2 text-sm font-medium text-slate-600 transition-all hover:bg-slate-50 hover:text-slate-900';
|
||||
|
||||
$iconClasses = ($active ?? false)
|
||||
? 'text-indigo-600'
|
||||
: 'text-slate-400 group-hover:text-indigo-500';
|
||||
$tones = [
|
||||
'indigo' => ['idle' => 'bg-indigo-50 text-indigo-600', 'active' => 'bg-indigo-600 text-white', 'paw' => 'text-indigo-500'],
|
||||
'blue' => ['idle' => 'bg-blue-50 text-blue-600', 'active' => 'bg-blue-600 text-white', 'paw' => 'text-blue-500'],
|
||||
'sky' => ['idle' => 'bg-sky-50 text-sky-600', 'active' => 'bg-sky-600 text-white', 'paw' => 'text-sky-500'],
|
||||
'rose' => ['idle' => 'bg-rose-50 text-rose-600', 'active' => 'bg-rose-600 text-white', 'paw' => 'text-rose-500'],
|
||||
'emerald' => ['idle' => 'bg-emerald-50 text-emerald-600', 'active' => 'bg-emerald-600 text-white', 'paw' => 'text-emerald-500'],
|
||||
'amber' => ['idle' => 'bg-amber-50 text-amber-600', 'active' => 'bg-amber-500 text-white', 'paw' => 'text-amber-500'],
|
||||
'violet' => ['idle' => 'bg-violet-50 text-violet-600', 'active' => 'bg-violet-600 text-white', 'paw' => 'text-violet-500'],
|
||||
'cyan' => ['idle' => 'bg-cyan-50 text-cyan-600', 'active' => 'bg-cyan-600 text-white', 'paw' => 'text-cyan-500'],
|
||||
'slate' => ['idle' => 'bg-slate-100 text-slate-600', 'active' => 'bg-slate-700 text-white', 'paw' => 'text-slate-500'],
|
||||
];
|
||||
|
||||
$toneSet = $tones[$tone] ?? $tones['indigo'];
|
||||
$iconClasses = ($active ?? false) ? $toneSet['active'] : $toneSet['idle'];
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
@if($icon)
|
||||
<i class="fas {{ $icon }} mr-3 w-5 text-center {{ $iconClasses }}"></i>
|
||||
<span class="relative mr-3 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl {{ $iconClasses }} transition-transform group-hover:-rotate-3 group-hover:scale-105">
|
||||
<i class="fas {{ $icon }} text-sm"></i>
|
||||
<span class="absolute -bottom-1 -right-1 flex h-3.5 w-3.5 items-center justify-center rounded-full border-2 border-white bg-white shadow-sm {{ $toneSet['paw'] }}">
|
||||
<i class="fas fa-paw text-[6px]"></i>
|
||||
</span>
|
||||
</span>
|
||||
@endif
|
||||
|
||||
<span class="truncate">
|
||||
{{ $slot }}
|
||||
</span>
|
||||
|
||||
<span class="truncate">{{ $slot }}</span>
|
||||
|
||||
@if($active ?? false)
|
||||
<span class="ml-auto w-1.5 h-1.5 rounded-full bg-indigo-600"></span>
|
||||
<span class="ml-auto h-1.5 w-1.5 rounded-full bg-slate-700"></span>
|
||||
@endif
|
||||
</a>
|
||||
</a>
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
$cleanStatus = strtolower(trim($status ?? ''));
|
||||
$safeWords = ['sehat', 'normal', 'aktif', 'ok', 'good', 'sembuh', '-', ''];
|
||||
|
||||
$isHealthy = in_array($cleanStatus, $safeWords);
|
||||
$isSehat = in_array($cleanStatus, $safeWords);
|
||||
|
||||
$classes = $isHealthy
|
||||
$classes = $isSehat
|
||||
? 'bg-green-100 text-green-700 border-green-200'
|
||||
: 'bg-red-100 text-red-700 border-red-200';
|
||||
@endphp
|
||||
|
||||
<span {{ $attributes->merge(['class' => "px-3 py-1 rounded-full text-xs font-bold border $classes"]) }}>
|
||||
<i class="fas {{ $isHealthy ? 'fa-check-circle' : 'fa-pills' }} mr-1"></i>
|
||||
{{ $isHealthy ? 'Sehat / Normal' : $status }}
|
||||
<i class="fas {{ $isSehat ? 'fa-check-circle' : 'fa-pills' }} mr-1"></i>
|
||||
{{ $isSehat ? 'Sehat / Normal' : $status }}
|
||||
</span>
|
||||
@@ -1,7 +1,7 @@
|
||||
<x-app-layout>
|
||||
<x-slot name="header">
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
|
||||
{{ __('Dashboard') }}
|
||||
{{ __('Beranda') }}
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
|
||||
@@ -1,182 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
@php
|
||||
// Optimasi: Gunakan try-catch agar jika tabel settings belum dimigrasi, app tidak crash
|
||||
try {
|
||||
$settings = \App\Models\Setting::whereIn('key', ['app_name', 'app_slogan', 'app_favicon', 'app_version'])
|
||||
->pluck('value', 'key');
|
||||
} catch (\Exception $e) {
|
||||
$settings = collect();
|
||||
}
|
||||
|
||||
$appName = $settings['app_name'] ?? config('app.name', 'Ily Cattery');
|
||||
$appSlogan = $settings['app_slogan'] ?? 'Dedikasi untuk Kesehatan dan Kebahagiaan Anabul';
|
||||
$appVersion = $settings['app_version'] ?? 'v1.0.0';
|
||||
$favicon = $settings['app_favicon'] ?? null;
|
||||
@endphp
|
||||
|
||||
<link rel="icon" type="image/x-icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}">
|
||||
|
||||
<title>{{ $appName }}</title>
|
||||
|
||||
{{-- Fonts & Icons --}}
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,800,900&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||
|
||||
{{-- Scripts & Styles --}}
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
@stack('styles')
|
||||
|
||||
<style>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #e2e8f0; border-radius: 10px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #cbd5e1; }
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* Animasi Fade In untuk Main Content */
|
||||
.page-fade-in { animation: fadeIn 0.4s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans antialiased text-slate-900 bg-slate-50 h-full overflow-hidden">
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
|
||||
{{-- Sidebar Navigation --}}
|
||||
@include('layouts.navigation-sidebar')
|
||||
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-slate-50 relative">
|
||||
|
||||
{{-- Top Navigation --}}
|
||||
@include('layouts.navigation')
|
||||
|
||||
{{-- Main Content Area --}}
|
||||
<main class="flex-1 overflow-y-auto custom-scrollbar flex flex-col">
|
||||
<div class="max-w-7xl mx-auto w-full p-4 md:p-8 flex-grow page-fade-in">
|
||||
|
||||
{{-- Notifikasi Error/Validasi (Hanya muncul jika bukan AJAX) --}}
|
||||
@if ($errors->any())
|
||||
<div x-data="{ show: true }" x-show="show" x-cloak
|
||||
class="mb-6 p-4 bg-rose-50 border-2 border-rose-100 text-rose-700 rounded-3xl flex items-start shadow-sm transition-all">
|
||||
<i class="fas fa-exclamation-circle mt-0.5 mr-3 text-lg"></i>
|
||||
<div>
|
||||
<h4 class="text-[11px] font-black uppercase tracking-widest mb-1">Terjadi Kesalahan Input:</h4>
|
||||
<ul class="list-disc list-inside text-[10px] font-bold opacity-80">
|
||||
@foreach ($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
<button @click="show = false" class="ml-auto text-rose-300 hover:text-rose-500"><i class="fas fa-times"></i></button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Main Slot (Halaman Konten Anda) --}}
|
||||
{{ $slot }}
|
||||
|
||||
</div>
|
||||
|
||||
{{-- Footer --}}
|
||||
<footer class="w-full py-8 border-t border-slate-100 mt-auto bg-white/50 backdrop-blur-md">
|
||||
<div class="max-w-7xl mx-auto px-8 text-center">
|
||||
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-1">
|
||||
{{ $appName }}
|
||||
</p>
|
||||
<p class="text-[9px] text-slate-300 font-bold tracking-widest italic uppercase">
|
||||
{{ $appSlogan }} — {{ $appVersion }}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
<html lang="id" class="h-full">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
@php
|
||||
$appName = \App\Models\Setting::where('key','app_name')->value('value') ?? config('app.name','ILY Cattery');
|
||||
$favicon = \App\Models\Setting::where('key','app_favicon')->value('value');
|
||||
@endphp
|
||||
<title>{{ $appName }}</title>
|
||||
<link rel="icon" href="{{ $favicon ? Storage::url($favicon) : asset('favicon.ico') }}">
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<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">
|
||||
@vite(['resources/css/app.css','resources/js/app.js'])
|
||||
@stack('styles')
|
||||
<style>[x-cloak]{display:none!important}</style>
|
||||
</head>
|
||||
<body class="h-full bg-slate-50 font-sans text-slate-800 antialiased" x-data="{sidebarOpen: window.innerWidth >= 1024}" @resize.window="if(window.innerWidth>=1024) sidebarOpen=true">
|
||||
<div class="flex min-h-screen">
|
||||
@include('layouts.navigation-sidebar')
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
@include('layouts.navigation')
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full max-w-[1500px] p-4 md:p-6 lg:p-8">
|
||||
@if($errors->any())
|
||||
<div 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>
|
||||
@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
|
||||
@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 }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{{-- SWEETALERT 2 INTEGRATION --}}
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// 1. KONFIGURASI TOAST
|
||||
const IlyToast = Swal.mixin({
|
||||
toast: true,
|
||||
position: 'top-end',
|
||||
showConfirmButton: false,
|
||||
timer: 3500,
|
||||
timerProgressBar: true,
|
||||
didOpen: (toast) => {
|
||||
toast.addEventListener('mouseenter', Swal.stopTimer)
|
||||
toast.addEventListener('mouseleave', Swal.resumeTimer)
|
||||
},
|
||||
customClass: {
|
||||
popup: 'rounded-2xl shadow-2xl border border-slate-50 p-4',
|
||||
title: 'text-[11px] font-black uppercase tracking-widest ml-2'
|
||||
}
|
||||
});
|
||||
|
||||
// 2. FUNGSI GLOBAL TOAST (Bisa dipanggil di file JS manapun)
|
||||
window.showToast = function(message, icon = 'success') {
|
||||
IlyToast.fire({
|
||||
icon: icon,
|
||||
title: message,
|
||||
iconColor: icon === 'success' ? '#4f46e5' : '#e11d48',
|
||||
});
|
||||
}
|
||||
|
||||
// 3. AUTO-TRIGGER FLASH MESSAGE
|
||||
@if(session('success'))
|
||||
showToast("{{ session('success') }}", 'success');
|
||||
@endif
|
||||
|
||||
@if(session('error'))
|
||||
showToast("{{ session('error') }}", 'error');
|
||||
@endif
|
||||
|
||||
// 4. FUNGSI GLOBAL KONFIRMASI DELETE (Universal)
|
||||
// Mendukung pemanggilan via ID Form (confirmDelete('formId', 'Nama'))
|
||||
// Atau pemanggilan via Element (confirmDelete(this, 'Nama'))
|
||||
window.confirmDelete = function(target, itemName = 'Data ini') {
|
||||
const finalName = itemName || 'Data terpilih';
|
||||
|
||||
Swal.fire({
|
||||
title: 'HAPUS DATA?',
|
||||
html: "<div class='text-sm text-slate-500 font-medium'>Anda akan menghapus:<br><b class='text-slate-800 uppercase tracking-tighter'>" + finalName + "</b><br><br><span class='text-rose-500 font-bold italic'>Peringatan: Data yang dihapus tidak dapat dipulihkan!</span></div>",
|
||||
icon: 'warning',
|
||||
iconColor: '#e11d48',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#e11d48',
|
||||
cancelButtonColor: '#f1f5f9',
|
||||
confirmButtonText: 'YA, HAPUS PERMANEN',
|
||||
cancelButtonText: 'BATAL',
|
||||
reverseButtons: true,
|
||||
customClass: {
|
||||
popup: 'rounded-[2.5rem] p-10 border-none shadow-2xl',
|
||||
confirmButton: 'rounded-2xl font-black uppercase text-[10px] px-8 py-4 ml-3 tracking-widest shadow-lg shadow-rose-100',
|
||||
cancelButton: 'rounded-2xl font-black uppercase text-[10px] px-8 py-4 text-slate-500 tracking-widest'
|
||||
}
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
// Logic: Jika target adalah string, cari by ID. Jika object, cari closest form.
|
||||
if (typeof target === 'string') {
|
||||
const form = document.getElementById(target);
|
||||
if(form) form.submit();
|
||||
} else {
|
||||
const form = target.closest('form');
|
||||
if(form) form.submit();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script>
|
||||
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();}})
|
||||
}
|
||||
</script>
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,167 +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
|
||||
$appSettings = \Illuminate\Support\Facades\Cache::remember('app_sidebar_settings', 3600, function() {
|
||||
return \App\Models\Setting::whereIn('key', ['app_logo', 'app_name'])->pluck('value', 'key');
|
||||
});
|
||||
$appName = $appSettings['app_name'] ?? 'Ily Cattery';
|
||||
@php
|
||||
$appName = \App\Models\Setting::where('key', 'app_name')->value('value') ?? 'ILY Cattery';
|
||||
$appLogo = \App\Models\Setting::where('key', 'app_logo')->value('value');
|
||||
$lowObatsCount = \App\Models\Medicine::where('qty', '<', 5)->count();
|
||||
$lowMakananCount = \App\Models\Food::where('qty', '<', 5)->count();
|
||||
@endphp
|
||||
|
||||
$lowMedicinesCount = \App\Models\Medicine::where('qty', '<', 5)->count();
|
||||
$lowFoodsCount = \App\Models\Food::where('qty', '<', 5)->count();
|
||||
@endphp
|
||||
|
||||
{{-- Brand Header --}}
|
||||
<div class="p-8 flex items-center gap-4">
|
||||
<div class="w-12 h-12 bg-indigo-600 rounded-[1.25rem] flex items-center justify-center shadow-xl shadow-indigo-100">
|
||||
<i class="fas fa-cat text-white text-xl"></i>
|
||||
</div>
|
||||
<div x-show="sidebarOpen" x-cloak @click="sidebarOpen=false" class="fixed inset-0 z-30 bg-slate-900/30 lg:hidden"></div>
|
||||
<aside :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'"
|
||||
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">
|
||||
<div class="flex h-16 items-center gap-3 border-b border-slate-200 px-5">
|
||||
@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">
|
||||
<h1 class="text-sm font-black text-slate-800 uppercase tracking-tighter truncate leading-none mb-1">{{ $appName }}</h1>
|
||||
<p class="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] leading-none">Management System</p>
|
||||
<div class="truncate text-sm font-bold text-slate-900">{{ $appName }}</div>
|
||||
<div class="text-xs text-slate-500">Manajemen cattery</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>
|
||||
|
||||
{{-- Navigation Links --}}
|
||||
<nav class="flex-1 px-4 space-y-1 overflow-y-auto custom-scrollbar">
|
||||
|
||||
<x-sidebar-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" icon="fa-th-large">
|
||||
Dashboard
|
||||
</x-sidebar-link>
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-4">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{{-- 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>
|
||||
@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'])
|
||||
<x-sidebar-link :href="route('cats.index')" :active="request()->routeIs('cats.*')" icon="fa-cat" tone="sky">Data kucing</x-sidebar-link>
|
||||
@endcan
|
||||
@can('check-access', ['matting','read'])
|
||||
<x-sidebar-link :href="route('matting.index')" :active="request()->routeIs('matting.*')" icon="fa-paw" tone="rose">Perkawinan & pembiakan</x-sidebar-link>
|
||||
@endcan
|
||||
@can('check-access', ['treatments','read'])
|
||||
<x-sidebar-link :href="route('treatments.index')" :active="request()->routeIs('treatments.*')" icon="fa-notes-medical" tone="emerald">Rekam medis</x-sidebar-link>
|
||||
@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>
|
||||
|
||||
@can('check-access', ['cats', 'read'])
|
||||
<x-sidebar-link :href="route('cats.index')" :active="request()->routeIs('cats.*')" icon="fa-paw">
|
||||
Data Kucing
|
||||
</x-sidebar-link>
|
||||
@endcan
|
||||
|
||||
@can('check-access', ['matting', 'read'])
|
||||
<x-sidebar-link :href="route('matting.index')" :active="request()->routeIs('matting.*')" icon="fa-venus-mars">
|
||||
<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
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- LOGISTIK (CONTOH DROPDOWN) --}}
|
||||
@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">
|
||||
<span>Inventori</span>
|
||||
<div class="h-px bg-slate-100 flex-1"></div>
|
||||
@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="mt-6">
|
||||
<div class="flex items-center justify-between px-3 pb-2">
|
||||
<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>
|
||||
|
||||
{{-- Parent Dropdown Logistik --}}
|
||||
<div x-data="{ open: {{ request()->routeIs('inventory.*') || request()->routeIs('categories.*') ? 'true' : 'false' }} }">
|
||||
<button @click="open = !open"
|
||||
class="w-full flex items-center gap-3 px-5 py-3 text-xs font-bold rounded-2xl transition-all {{ request()->routeIs('inventory.*') ? 'bg-indigo-50 text-indigo-600' : 'text-slate-500 hover:bg-slate-50' }}">
|
||||
<i class="fas fa-box-open w-5 text-center"></i>
|
||||
<span class="flex-1 text-left">Gudang Logistik</span>
|
||||
<i class="fas fa-chevron-down text-[10px] transition-transform duration-200" :class="open ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
|
||||
{{-- Sub-menu Items --}}
|
||||
<div x-show="open" x-cloak x-collapse class="mt-1 ml-4 pl-4 border-l border-slate-100 space-y-1">
|
||||
@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
|
||||
</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>
|
||||
@endif
|
||||
|
||||
{{-- INTELLIGENCE --}}
|
||||
@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">
|
||||
<span>Intelligence</span>
|
||||
<div class="h-px bg-indigo-50 flex-1"></div>
|
||||
@if(Gate::allows('check-access', ['reports','read']) || Gate::allows('check-access', ['matting_reports','read']))
|
||||
<div class="mt-6">
|
||||
<p class="px-3 pb-2 text-xs font-semibold text-slate-400">Laporan</p>
|
||||
<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>
|
||||
|
||||
@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
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- KONFIGURASI (DROPDOWN) --}}
|
||||
@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">
|
||||
<span>Konfigurasi</span>
|
||||
<div class="h-px bg-rose-50 flex-1"></div>
|
||||
</div>
|
||||
|
||||
<div x-data="{ open: {{ request()->routeIs('admin.users.*') || request()->routeIs('admin.roles.*') || request()->routeIs('admin.settings.*') ? 'true' : 'false' }} }">
|
||||
<button @click="open = !open"
|
||||
class="w-full flex items-center gap-3 px-5 py-3 text-xs font-bold rounded-2xl transition-all {{ request()->routeIs('admin.*') ? 'bg-rose-50 text-rose-600' : 'text-slate-500 hover:bg-slate-50' }}">
|
||||
<i class="fas fa-cog w-5 text-center"></i>
|
||||
<span class="flex-1 text-left">Sistem Control</span>
|
||||
<i class="fas fa-chevron-down text-[10px] transition-transform duration-200" :class="open ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
|
||||
<div x-show="open" x-cloak x-collapse class="mt-1 ml-4 pl-4 border-l border-rose-100 space-y-1">
|
||||
@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
|
||||
</div>
|
||||
@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="mt-6">
|
||||
<p class="px-3 pb-2 text-xs font-semibold text-slate-400">Administrasi</p>
|
||||
<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>
|
||||
@endif
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,210 +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">
|
||||
@php
|
||||
$appLogo = \App\Models\Setting::where('key', 'app_logo')->value('value');
|
||||
$appName = \App\Models\Setting::where('key', 'app_name')->value('value') ?? 'ILY Aregau';
|
||||
@endphp
|
||||
|
||||
<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">
|
||||
{{-- Logo Section --}}
|
||||
<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-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center text-white font-black text-xs 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-base font-black text-slate-800 uppercase tracking-tighter leading-none">{{ $appName }}</span>
|
||||
<span class="text-[9px] font-bold text-indigo-600 uppercase tracking-[0.2em] mt-1 italic">Cattery Management</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MENU DESKTOP (USER PROFILE DROPDOWN) --}}
|
||||
<div class="hidden lg:flex lg:items-center lg:ms-6">
|
||||
<x-dropdown align="right" width="48">
|
||||
<x-slot name="trigger">
|
||||
<button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-black rounded-xl text-slate-500 bg-slate-50 hover:text-indigo-600 hover:bg-indigo-50 transition ease-in-out duration-150">
|
||||
<div class="w-8 h-8 bg-indigo-600 text-white rounded-lg flex items-center justify-center text-[10px] mr-2 shadow-sm">
|
||||
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
|
||||
</div>
|
||||
<div>{{ Auth::user()->name }}</div>
|
||||
<div class="ms-1">
|
||||
<svg class="fill-current h-4 w-4" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div class="px-4 py-2 border-b border-slate-50">
|
||||
<p class="text-[10px] font-black text-slate-800 uppercase tracking-tighter">{{ Auth::user()->name }}</p>
|
||||
<p class="text-[8px] font-bold text-slate-400 truncate">{{ Auth::user()->email }}</p>
|
||||
</div>
|
||||
<x-dropdown-link :href="route('profile.edit')" class="text-[10px] font-bold uppercase tracking-widest">
|
||||
<i class="fas fa-user-circle mr-2 opacity-50"></i> {{ __('My Profile') }}
|
||||
</x-dropdown-link>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<x-dropdown-link :href="route('logout')" class="text-[10px] font-black uppercase tracking-widest text-rose-500"
|
||||
onclick="event.preventDefault(); this.closest('form').submit();">
|
||||
<i class="fas fa-sign-out-alt mr-2"></i> {{ __('Log Out') }}
|
||||
</x-dropdown-link>
|
||||
</form>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
</div>
|
||||
|
||||
{{-- Hamburger Button (Hanya Mobile) --}}
|
||||
<div class="-me-2 flex items-center lg:hidden">
|
||||
<button @click="open = ! open" class="inline-flex items-center justify-center p-2 rounded-xl text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 transition-all duration-200 focus:outline-none">
|
||||
<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>
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="sidebarOpen=true" class="p-2 text-slate-600 lg:hidden" aria-label="Buka menu"><i class="fas fa-bars"></i></button>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">{{ \App\Models\Setting::where('key','app_name')->value('value') ?? 'ILY Cattery' }}</p>
|
||||
<p class="hidden text-xs text-slate-500 sm:block">Data cattery, pembiakan, kesehatan, dan persediaan</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- MOBILE MENU ACCORDION --}}
|
||||
<div x-show="open"
|
||||
x-cloak
|
||||
x-data="{ activeMenu: 'dashboard' }"
|
||||
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="lg:hidden bg-white border-t border-slate-100 shadow-xl overflow-y-auto max-h-[85vh]">
|
||||
|
||||
<div class="pt-2 pb-3 space-y-0.5 px-3">
|
||||
{{-- 1. DASHBOARD --}}
|
||||
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')" class="rounded-lg font-black text-[8.5px] uppercase tracking-widest py-2">
|
||||
<i class="fas fa-th-large mr-2 w-4"></i> Dashboard
|
||||
</x-responsive-nav-link>
|
||||
|
||||
{{-- 2. CATTERY & MEDICAL --}}
|
||||
<div class="border-t border-slate-50">
|
||||
<button @click="activeMenu = activeMenu === 'cattery' ? '' : 'cattery'" class="w-full flex justify-between items-center px-3 py-2.5 text-[7px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
<span>Cattery & Medical</span>
|
||||
<i class="fas fa-chevron-down transition-transform" :class="activeMenu === 'cattery' ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="activeMenu === 'cattery'" x-collapse class="bg-slate-50/50 rounded-lg">
|
||||
<x-responsive-nav-link :href="route('cats.index')" :active="request()->routeIs('cats.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-cat mr-2 w-4"></i> Data Kucing
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('matting.index')" :active="request()->routeIs('matting.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-heart mr-2 w-4"></i> Breeding
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('treatments.index')" :active="request()->routeIs('treatments.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-notes-medical mr-2 w-4"></i> Rekam Medis
|
||||
</x-responsive-nav-link>
|
||||
@if(Route::has('medical.dashboard'))
|
||||
<x-responsive-nav-link :href="route('medical.dashboard')" :active="request()->routeIs('medical.dashboard')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-microscope mr-2 w-4"></i> Medical Insight
|
||||
</x-responsive-nav-link>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 3. LOGISTIK & MASTER --}}
|
||||
<div class="border-t border-slate-50">
|
||||
<button @click="activeMenu = activeMenu === 'logistik' ? '' : 'logistik'" class="w-full flex justify-between items-center px-3 py-2.5 text-[7px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
<span>Logistik & Master</span>
|
||||
<i class="fas fa-chevron-down transition-transform" :class="activeMenu === 'logistik' ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="activeMenu === 'logistik'" x-collapse class="bg-slate-50/50 rounded-lg">
|
||||
@if(Route::has('categories.index'))
|
||||
<x-responsive-nav-link :href="route('categories.index')" :active="request()->routeIs('categories.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-tags mr-2 w-4"></i> Master Kategori
|
||||
</x-responsive-nav-link>
|
||||
@endif
|
||||
<x-responsive-nav-link :href="route('inventory.medicines.index')" :active="request()->routeIs('inventory.medicines.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-pills mr-2 w-4"></i> Obat-obatan
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('inventory.foods.index')" :active="request()->routeIs('inventory.foods.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-utensils mr-2 w-4"></i> Makanan
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('inventory.supplies.index')" :active="request()->routeIs('inventory.supplies.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-boxes mr-2 w-4"></i> Perlengkapan
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 4. PUSAT LAPORAN --}}
|
||||
<div class="border-t border-slate-50">
|
||||
<button @click="activeMenu = activeMenu === 'reports' ? '' : 'reports'" class="w-full flex justify-between items-center px-3 py-2.5 text-[7px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
<span>Pusat Laporan</span>
|
||||
<i class="fas fa-chevron-down transition-transform" :class="activeMenu === 'reports' ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="activeMenu === 'reports'" x-collapse class="bg-slate-50/50 rounded-lg">
|
||||
<x-responsive-nav-link :href="route('admin.reports.index')" :active="request()->routeIs('admin.reports.index')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-chart-pie mr-2 w-4"></i> Intelligence Center
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link :href="route('admin.reports.matting.index')" :active="request()->routeIs('admin.reports.matting.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-scroll mr-2 w-4"></i> Laporan Breeding
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 5. KONFIGURASI --}}
|
||||
@if(Auth::user()->role === 'admin')
|
||||
<div class="border-t border-slate-50">
|
||||
<button @click="activeMenu = activeMenu === 'config' ? '' : 'config'" class="w-full flex justify-between items-center px-3 py-2.5 text-[7px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
<span>Konfigurasi & Sistem</span>
|
||||
<i class="fas fa-chevron-down transition-transform" :class="activeMenu === 'config' ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
<div x-show="activeMenu === 'config'" x-collapse class="bg-slate-50/50 rounded-lg">
|
||||
<x-responsive-nav-link :href="route('admin.users.index')" :active="request()->routeIs('admin.users.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-user-shield mr-2 w-4"></i> Management User
|
||||
</x-responsive-nav-link>
|
||||
@if(Route::has('admin.roles.index'))
|
||||
<x-responsive-nav-link :href="route('admin.roles.index')" :active="request()->routeIs('admin.roles.*')" class="text-[8.5px] font-bold py-2">
|
||||
<i class="fas fa-key mr-2 w-4"></i> Role Management
|
||||
</x-responsive-nav-link>
|
||||
@endif
|
||||
@if(Route::has('admin.logs.index'))
|
||||
<x-responsive-nav-link :href="route('admin.logs.index')" :active="request()->routeIs('admin.logs.*')" class="text-[8.5px] font-bold py-2 text-amber-600">
|
||||
<i class="fas fa-history mr-2 w-4"></i> Log Audit
|
||||
</x-responsive-nav-link>
|
||||
@endif
|
||||
<x-responsive-nav-link :href="route('admin.settings.index')" :active="request()->routeIs('admin.settings.*')" class="text-[8.5px] font-black py-2 text-rose-500 border-t border-slate-100">
|
||||
<i class="fas fa-cogs mr-2 w-4"></i> Pengaturan Sistem
|
||||
</x-responsive-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- USER INFO & LOGOUT (MOBILE) --}}
|
||||
<div class="pt-4 pb-8 border-t border-slate-100 bg-slate-50/50">
|
||||
<div class="flex items-center px-6 gap-3 mb-4 text-left">
|
||||
<div class="w-10 h-10 bg-indigo-600 text-white rounded-xl flex items-center justify-center font-black text-[12px] shadow-md">
|
||||
{{ strtoupper(substr(Auth::user()->name, 0, 1)) }}
|
||||
</div>
|
||||
<div class="overflow-hidden">
|
||||
<div class="font-black text-[10px] text-slate-800 uppercase leading-none">{{ Auth::user()->name }}</div>
|
||||
<div class="font-bold text-[8px] text-slate-400 truncate mt-1">{{ Auth::user()->email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 space-y-1">
|
||||
<x-responsive-nav-link :href="route('profile.edit')" class="rounded-xl font-bold text-[9px] uppercase tracking-widest py-2.5">
|
||||
<i class="fas fa-user-circle mr-3 w-4"></i> Edit Profile
|
||||
</x-responsive-nav-link>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="w-full flex items-center justify-center gap-2 rounded-xl font-black text-[9px] uppercase tracking-widest text-rose-500 py-3 bg-white border border-rose-100 shadow-sm transition-all active:scale-95">
|
||||
<i class="fas fa-sign-out-alt"></i> Keluar Aplikasi
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -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>
|
||||
@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
|
||||
</form>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
{{ substr($log->user->name ?? 'S', 0, 1) }}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
{{-- Header --}}
|
||||
<div class="p-8 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<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>
|
||||
</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>
|
||||
@@ -15,9 +15,9 @@
|
||||
<form action="{{ route('admin.roles.store') }}" method="POST" class="p-8 space-y-6">
|
||||
@csrf
|
||||
|
||||
{{-- Nama Role --}}
|
||||
{{-- Nama Hak Akses --}}
|
||||
<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"
|
||||
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" />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-900 tracking-tight">Konfigurasi Hak Akses</h2>
|
||||
<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>
|
||||
</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">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<i class="fas fa-shield-alt text-[10px]"></i>
|
||||
<span class="text-[9px] font-black uppercase tracking-widest">Security Module</span>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<table class="w-full text-left border-collapse min-w-[800px]">
|
||||
<thead class="bg-slate-50 border-b border-slate-100">
|
||||
<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 text-right">Otoritas Matriks</th>
|
||||
</tr>
|
||||
@@ -37,7 +37,7 @@
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@foreach($roles as $role)
|
||||
<tr class="hover:bg-slate-50/50 transition-all group">
|
||||
{{-- Nama Role --}}
|
||||
{{-- Nama Hak Akses --}}
|
||||
<td class="px-10 py-6">
|
||||
<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">
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</td>
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -145,7 +145,7 @@
|
||||
<div>
|
||||
<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' }}">
|
||||
{{ $emailStatus['active'] ? 'Service Online' : 'Konfigurasi Salah' }}
|
||||
{{ $emailStatus['active'] ? 'Layanan Aktif' : 'Konfigurasi Salah' }}
|
||||
</p>
|
||||
</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>
|
||||
@@ -6,8 +6,8 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-800 tracking-tight uppercase">Tambah Kucing Baru</h2>
|
||||
<p class="text-sm text-slate-500 font-medium italic tracking-tight">Registrasi identitas permanen & silsilah ke database Ilycats.</p>
|
||||
<h2 class="text-2xl font-black text-slate-800 tracking-tight uppercase leading-none mb-1">Tambah Kucing Baru</h2>
|
||||
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-[0.2em]">Registrasi Identitas Permanen • Ilycats Database</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,31 +30,38 @@
|
||||
@csrf
|
||||
|
||||
{{-- SECTION 1: IDENTITAS UTAMA --}}
|
||||
<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">
|
||||
<i class="fas fa-id-card"></i> Identitas & Dokumen Resmi
|
||||
</h4>
|
||||
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm space-y-8">
|
||||
<div class="flex items-center gap-4 mb-2 border-b border-slate-100 pb-4">
|
||||
<div class="w-8 h-8 bg-indigo-600 text-white rounded-xl flex items-center justify-center text-xs shadow-lg">
|
||||
<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">
|
||||
{{-- Nama Kucing --}}
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{{-- No. Pedigree, Microchip, & EMS (GRID 3 KOLOM) --}}
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{{-- GRID IDENTITAS --}}
|
||||
<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">
|
||||
<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 class="space-y-2">
|
||||
<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 class="space-y-2">
|
||||
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Kode EMS</label>
|
||||
<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">
|
||||
<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
|
||||
</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>
|
||||
|
||||
@@ -62,9 +69,9 @@
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
<option value="Persian" {{ old('ras') == 'Persian' ? 'selected' : '' }}>Persian</option>
|
||||
<option value="British Shorthair" {{ old('ras') == 'British Shorthair' ? 'selected' : '' }}>British Shorthair</option>
|
||||
<option value="Lainnya" {{ old('ras') == 'Lainnya' ? 'selected' : '' }}>Lainnya</option>
|
||||
@foreach(['Persian', 'British Shorthair', 'Scottish Fold', 'Maine Coon', 'Munchkin', 'Lainnya'] as $ras)
|
||||
<option value="{{ $ras }}" {{ old('ras') == $ras ? 'selected' : '' }}>{{ $ras }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -77,11 +84,11 @@
|
||||
{{-- Registry --}}
|
||||
<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>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
@foreach(['ICA', 'CFA', 'WCF', 'FFF'] as $reg)
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
@foreach(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'] as $reg)
|
||||
<label class="block cursor-pointer group">
|
||||
<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">
|
||||
<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
|
||||
@@ -97,15 +104,15 @@
|
||||
<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>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex-1 cursor-pointer">
|
||||
<input type="radio" name="jenis_kelamin" value="Jantan" {{ old('jenis_kelamin', 'Jantan') == 'Jantan' ? 'checked' : '' }} class="hidden 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">
|
||||
<label class="flex-1 cursor-pointer group">
|
||||
<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 group-hover:border-slate-200">
|
||||
<i class="fas fa-mars mr-2"></i> JANTAN (SIRE)
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex-1 cursor-pointer">
|
||||
<input type="radio" name="jenis_kelamin" value="Betina" {{ old('jenis_kelamin') == 'Betina' ? 'checked' : '' }} class="hidden 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">
|
||||
<label class="flex-1 cursor-pointer group">
|
||||
<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 group-hover:border-slate-200">
|
||||
<i class="fas fa-venus mr-2"></i> BETINA (DAM)
|
||||
</div>
|
||||
</label>
|
||||
@@ -117,42 +124,46 @@
|
||||
{{-- SECTION 2: GENETIK & LOKASI --}}
|
||||
<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">
|
||||
<i class="fas fa-dna"></i> Penempatan & Silsilah Induk
|
||||
<i class="fas fa-dna text-sm"></i> Penempatan & Silsilah Induk
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{{-- Searchable Sire --}}
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Jantan</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">
|
||||
<option value="">-- Tanpa Sire --</option>
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Jantan (Sire)</label>
|
||||
<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">
|
||||
<datalist id="sire_list">
|
||||
@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
|
||||
</select>
|
||||
</datalist>
|
||||
<input type="hidden" name="induk_jantan_id" id="induk_jantan_id" value="{{ old('induk_jantan_id') }}">
|
||||
</div>
|
||||
|
||||
{{-- Searchable Dam --}}
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Betina</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">
|
||||
<option value="">-- Tanpa Dam --</option>
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Induk Betina (Dam)</label>
|
||||
<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">
|
||||
<datalist id="dam_list">
|
||||
@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
|
||||
</select>
|
||||
</datalist>
|
||||
<input type="hidden" name="induk_betina_id" id="induk_betina_id" value="{{ old('induk_betina_id') }}">
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
@foreach(['A1', 'A2', 'Lorong', 'E1', 'E2'] as $loc)
|
||||
<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', 'Karantina'] as $loc)
|
||||
<option value="{{ $loc }}" {{ old('lokasi_kandang') == $loc ? 'selected' : '' }}>{{ $loc }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">PIC (Penanggung Jawab)</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">
|
||||
<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 focus:ring-2 focus:ring-indigo-500">
|
||||
@foreach($users as $user)
|
||||
<option value="{{ $user->id }}" {{ old('pic_id', auth()->id()) == $user->id ? 'selected' : '' }}>{{ $user->name }}</option>
|
||||
@endforeach
|
||||
@@ -162,34 +173,33 @@
|
||||
</div>
|
||||
|
||||
{{-- SECTION 3: JADWAL & PERTUMBUHAN --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{{-- Jadwal Medis --}}
|
||||
<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-rose-600 uppercase tracking-widest border-b border-slate-100 pb-4 mb-2 flex items-center gap-2 italic">
|
||||
<i class="fas fa-calendar-alt"></i> Jadwal Perawatan
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="lg:col-span-2 bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm">
|
||||
<div class="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
|
||||
<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>
|
||||
</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">
|
||||
@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">
|
||||
<label class="text-[9px] font-bold text-slate-400 uppercase ml-1">{{ str_replace('_', ' ', $task) }}</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">
|
||||
<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-2 focus:ring-rose-200 transition-all">
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Monitoring Berat --}}
|
||||
<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-2 flex items-center gap-2 italic">
|
||||
<i class="fas fa-weight-scale"></i> Vitalitas & Berat
|
||||
<div class="bg-white border border-slate-200 rounded-[2.5rem] p-8 shadow-sm">
|
||||
<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">
|
||||
<i class="fas fa-weight-scale text-sm"></i> Vitalitas
|
||||
</h4>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-[10px] font-black text-slate-400 uppercase ml-1">Berat Badan Awal</label>
|
||||
<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">
|
||||
<span class="absolute right-6 top-1/2 -translate-y-1/2 text-xs font-black text-slate-400">KG</span>
|
||||
</div>
|
||||
<label class="text-[10px] font-black text-slate-400 uppercase ml-1">Berat Badan Awal</label>
|
||||
<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-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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -197,9 +207,9 @@
|
||||
|
||||
{{-- SECTION 4: FOTO --}}
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
@@ -210,7 +220,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
@@ -220,8 +230,8 @@
|
||||
{{-- Action Button --}}
|
||||
<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">
|
||||
<i class="fas fa-save text-xl"></i>
|
||||
<span class="tracking-[0.2em] uppercase">Simpan Record Baru</span>
|
||||
<i class="fas fa-save text-xl group-hover:rotate-12 transition-transform"></i>
|
||||
<span class="tracking-[0.2em] uppercase">Simpan Record Baru Ilycats</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -233,6 +243,29 @@
|
||||
</style>
|
||||
|
||||
<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) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function() {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</a>
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-800 tracking-tight">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>
|
||||
<h2 class="text-2xl font-black text-slate-800 tracking-tight leading-none mb-1">Edit Profil: {{ $cat->nama_kucing }}</h2>
|
||||
<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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<form action="{{ route('cats.update', $cat->id) }}" method="POST" enctype="multipart/form-data" class="space-y-6">
|
||||
@csrf
|
||||
@method('PATCH')
|
||||
@method('PUT')
|
||||
|
||||
{{-- SECTION 1: IDENTITAS UTAMA & DOKUMEN --}}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-6 items-start">
|
||||
|
||||
{{-- FOTO SECTION (THUMBNAIL) --}}
|
||||
<div class="flex flex-col md:flex-row gap-8 items-start">
|
||||
{{-- FOTO SECTION --}}
|
||||
<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">
|
||||
</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">
|
||||
<i class="fas fa-camera text-[10px]"></i>
|
||||
<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-xs"></i>
|
||||
<input type="file" name="foto" id="foto" class="hidden" accept="image/*" onchange="previewImage(event)">
|
||||
</label>
|
||||
</div>
|
||||
@@ -56,32 +55,30 @@
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{{-- 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">
|
||||
{{-- Input Pedigree --}}
|
||||
<div class="space-y-2">
|
||||
<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"
|
||||
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>
|
||||
|
||||
{{-- Input Microchip --}}
|
||||
<div class="space-y-2">
|
||||
<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"
|
||||
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>
|
||||
|
||||
{{-- Input Kode EMS (Highlight Indigo) --}}
|
||||
<div class="space-y-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
|
||||
</label>
|
||||
{{-- PERBAIKAN: Menghapus class uppercase --}}
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
</div>
|
||||
|
||||
{{-- Registry / Federasi --}}
|
||||
<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>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
@foreach(['ICA', 'CFA', 'WCF', 'FFF'] as $reg)
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
@foreach(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'] as $reg)
|
||||
@php
|
||||
$isChecked = (is_array(old('registry')) && in_array($reg, old('registry'))) ||
|
||||
(is_array($cat->registry) && in_array($reg, $cat->registry));
|
||||
$isChecked = old('registry')
|
||||
? in_array($reg, old('registry'))
|
||||
: (is_array($cat->registry) && in_array($reg, $cat->registry));
|
||||
@endphp
|
||||
<label class="block cursor-pointer group">
|
||||
<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 }}
|
||||
</div>
|
||||
</label>
|
||||
@@ -118,20 +115,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Jenis Kelamin --}}
|
||||
<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>
|
||||
<div class="flex gap-4">
|
||||
<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">
|
||||
<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>
|
||||
</label>
|
||||
<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">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
@@ -147,24 +143,36 @@
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{{-- Searchable Sire --}}
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
<option value="">-- Tidak Diketahui --</option>
|
||||
<input type="text" list="sire_list" id="sire_search"
|
||||
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)
|
||||
<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
|
||||
</select>
|
||||
</datalist>
|
||||
<input type="hidden" name="induk_jantan_id" id="induk_jantan_id" value="{{ old('induk_jantan_id', $cat->induk_jantan_id) }}">
|
||||
</div>
|
||||
|
||||
{{-- Searchable Dam --}}
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
<option value="">-- Tidak Diketahui --</option>
|
||||
<input type="text" list="dam_list" id="dam_search"
|
||||
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)
|
||||
<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
|
||||
</select>
|
||||
</datalist>
|
||||
<input type="hidden" name="induk_betina_id" id="induk_betina_id" value="{{ old('induk_betina_id', $cat->induk_betina_id) }}">
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
@@ -173,6 +181,7 @@
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<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">
|
||||
@@ -210,27 +219,51 @@
|
||||
</h4>
|
||||
<label class="text-[10px] font-bold text-slate-400 uppercase ml-1">Berat Badan</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{{-- 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">
|
||||
<i class="fas fa-cloud-upload-alt text-lg group-hover:animate-bounce transition-transform"></i>
|
||||
<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-save text-lg group-hover:rotate-12 transition-transform"></i>
|
||||
<span class="tracking-[0.2em] uppercase">Simpan Perubahan Data Ilycats</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<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) {
|
||||
const reader = new FileReader();
|
||||
const preview = document.getElementById('image-preview');
|
||||
|
||||
@@ -1,185 +1,51 @@
|
||||
<x-app-layout>
|
||||
<div class="space-y-6 pb-8">
|
||||
{{-- 1. STATS OVERVIEW --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<x-stats-card title="Total Kucing" :value="$totalCats" icon="fa-cat" color="indigo" />
|
||||
<x-stats-card title="Kucing Sehat" :value="$totalCats - $sickCats" icon="fa-heart" color="emerald" />
|
||||
<x-stats-card title="Dalam Perawatan" :value="$sickCats" icon="fa-stethoscope" color="rose" />
|
||||
</div>
|
||||
|
||||
{{-- 2. ACTION HEADER --}}
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-800 tracking-tight leading-none mb-1 uppercase">Manajemen Kucing</h2>
|
||||
<p class="text-[10px] text-slate-400 font-bold uppercase tracking-[0.2em]">Cattery Management System • Ilycats</p>
|
||||
</div>
|
||||
|
||||
{{-- PROTEKSI TOMBOL CREATE --}}
|
||||
@can('check-access', ['cats', 'create'])
|
||||
<div class="flex items-center gap-3">
|
||||
<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">
|
||||
<i class="fas fa-plus-circle mr-2"></i> REGISTRASI KUCING BARU
|
||||
</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>
|
||||
|
||||
<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 class="overflow-x-auto">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead class="bg-slate-50 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">
|
||||
<tr>
|
||||
<th class="px-6 py-5">Informasi Kucing</th>
|
||||
<th class="px-6 py-5">Usia & Silsilah</th>
|
||||
<th class="px-6 py-5 text-center">Lokasi Kandang</th>
|
||||
<th class="px-6 py-5 text-center">Penanggung Jawab</th>
|
||||
<th class="px-6 py-5 text-center">Monitoring Berat</th>
|
||||
<th class="px-6 py-5 text-right">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100 font-medium text-slate-700">
|
||||
@forelse($cats as $cat)
|
||||
<tr class="hover:bg-slate-50/80 transition-all group">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative shrink-0">
|
||||
<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">
|
||||
<img src="{{ $cat->foto_url }}" class="w-full h-full object-cover">
|
||||
</div>
|
||||
@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>
|
||||
@empty
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="p-8 border-t border-slate-100 bg-slate-50/30">
|
||||
{{ $cats->links() }}
|
||||
</div>
|
||||
</div>
|
||||
@php
|
||||
$currentSort=request('sort','created_at'); $currentDir=request('direction','desc'); $nextDir=$currentDir==='asc'?'desc':'asc';
|
||||
$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');};
|
||||
@endphp
|
||||
<div class="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<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>
|
||||
</x-app-layout>
|
||||
|
||||
<div class="mb-5 grid gap-3 sm:grid-cols-3">
|
||||
<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 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<section class="border border-slate-200 bg-white">
|
||||
<div class="flex flex-col gap-3 border-b border-slate-200 p-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<form method="GET" action="{{ route('cats.index') }}" class="flex w-full max-w-lg gap-2">
|
||||
<input type="hidden" name="status" value="{{ request('status') }}"><input type="hidden" name="sort" value="{{ request('sort') }}"><input type="hidden" name="direction" value="{{ request('direction') }}">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-slate-200 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs font-semibold text-slate-500"><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-4 py-3">Identitas & pedigree</th><th class="px-4 py-3">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-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-4 py-3 text-right">Aksi</th>
|
||||
</tr></thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
@forelse($cats as $cat)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
@empty<tr><td colspan="6" class="px-4 py-12 text-center text-sm text-slate-500">Data kucing tidak ditemukan.</td></tr>@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="border-t border-slate-200 p-4">{{ $cats->links() }}</div>
|
||||
</section>
|
||||
</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 --}}
|
||||
<img src="data:image/svg+xml;base64,{{ $qrCode }}" width="90">
|
||||
</div>
|
||||
<div class="qr-text">Scan Medical History</div>
|
||||
<div class="qr-text">Scan Medis History</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
Generated by ILY Cattery Management System<br>
|
||||
Generated by ILY Cattery Management Sistem<br>
|
||||
{{ now()->format('d M Y H:i') }}
|
||||
</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>
|
||||
@@ -2,34 +2,110 @@
|
||||
@push('styles')
|
||||
<style>
|
||||
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-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>
|
||||
@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">
|
||||
{{-- 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">
|
||||
<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>
|
||||
<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 !!}
|
||||
</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 class="flex 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">
|
||||
<i class="fas fa-file-pdf mr-2 text-sm"></i> CETAK MEDICAL PASS
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<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-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>
|
||||
|
||||
{{-- 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']))
|
||||
<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
|
||||
@@ -38,26 +114,19 @@
|
||||
</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 --}}
|
||||
<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="absolute top-0 left-0 p-6">
|
||||
{!! $cat->status_badge !!}
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 left-0 p-6 z-10">{!! $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">
|
||||
<img src="{{ $cat->foto_url }}" class="w-full h-full object-cover">
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{!! $cat->registry_badges !!}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">{!! $cat->registry_badges !!}</div>
|
||||
</div>
|
||||
<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>
|
||||
@@ -77,25 +146,22 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase">Lokasi Kandang</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 justify-between items-center pt-3 border-t border-slate-50 mt-1">
|
||||
<span class="text-xs font-bold text-slate-400 uppercase">Sertifikat Ped</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 class="flex justify-between items-center">
|
||||
<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>
|
||||
</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>
|
||||
<span class="text-xs font-black text-slate-700">{{ $cat->pic?->name ?? 'Sistem' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,33 +170,29 @@
|
||||
{{-- Column 2: Lineage & Health Routine --}}
|
||||
<div class="space-y-8">
|
||||
<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">
|
||||
{{-- SIRE --}}
|
||||
<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">
|
||||
<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>
|
||||
<div @if($cat->sire_id) onclick="window.location.href='{{ route('cats.show', $cat->sire_id) }}'" @endif
|
||||
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"><i class="fas fa-mars text-lg"></i></div>
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
{{-- DAM --}}
|
||||
<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">
|
||||
<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>
|
||||
<div @if($cat->dam_id) onclick="window.location.href='{{ route('cats.show', $cat->dam_id) }}'" @endif
|
||||
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"><i class="fas fa-venus text-lg"></i></div>
|
||||
<div class="flex-1">
|
||||
<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 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">
|
||||
@foreach([
|
||||
['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>
|
||||
<p class="text-[10px] text-slate-400 font-black uppercase mt-1">Growth Index (Kg)</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="relative h-[250px]">
|
||||
<canvas id="weightChart"></canvas>
|
||||
<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>
|
||||
</div>
|
||||
<div class="relative h-[250px]"><canvas id="weightChart"></canvas></div>
|
||||
</div>
|
||||
|
||||
{{-- PROTEKSI QUICK ACTION: Hanya jika bisa Create Treatment --}}
|
||||
@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-[250px] group overflow-hidden relative">
|
||||
@can('check-access', ['cats', 'update'])
|
||||
<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="relative z-10">
|
||||
<h4 class="text-xs font-black uppercase tracking-widest mb-2 border-b border-white/20 pb-3">Quick Action</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>
|
||||
<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">Log tindakan kesehatan cepat atau pengobatan klinis.</p>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
<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
|
||||
</a>
|
||||
<i class="fas fa-heartbeat absolute -bottom-10 -left-10 text-[10rem] text-white/5"></i>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Log Section: Medical History Timeline --}}
|
||||
<div class="bg-white border border-slate-200 rounded-[3rem] overflow-hidden shadow-sm">
|
||||
{{-- Comprehensive Health History --}}
|
||||
<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="flex items-center gap-3">
|
||||
<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>
|
||||
<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 class="p-10 max-h-[800px] overflow-y-auto custom-scrollbar">
|
||||
<div class="p-10 max-h-[600px] overflow-y-auto custom-scrollbar">
|
||||
<div class="relative space-y-12">
|
||||
<div class="absolute left-[11px] top-2 bottom-2 w-0.5 bg-slate-100"></div>
|
||||
|
||||
@forelse($cat->treatments->sortByDesc('tanggal') as $treatment)
|
||||
<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 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>
|
||||
<span class="text-[10px] text-slate-400 font-bold italic">{{ $treatment->tanggal->diffForHumans() }}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{{-- PROTEKSI EDIT LOG: Hanya jika bisa Update Treatment --}}
|
||||
@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
|
||||
{{-- LABEL STATUS PADA KARTU HISTORY --}}
|
||||
@if($treatment->status_perawatan == 2)
|
||||
<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 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="bg-white rounded-[2.5rem] p-8 border {{ $treatment->status_perawatan == 2 ? 'border-emerald-100' : 'border-rose-100' }} shadow-sm transition-all duration-300">
|
||||
<div class="flex flex-col md:flex-row gap-10">
|
||||
<div class="flex-1">
|
||||
<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">
|
||||
<div class="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center text-rose-500">
|
||||
<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 class="flex items-center gap-4 p-4 bg-slate-50/50 rounded-2xl mb-4">
|
||||
<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><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>
|
||||
|
||||
@if($treatment->foto_bukti)
|
||||
<div class="shrink-0">
|
||||
<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>
|
||||
{{-- 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>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@if($treatment->foto_bukti)
|
||||
<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>
|
||||
@endif
|
||||
</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>
|
||||
@empty
|
||||
<div class="text-center py-32 bg-slate-50/30 rounded-[4rem] border-4 border-dashed border-slate-100">
|
||||
<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>
|
||||
<div class="text-center py-10 text-slate-300 uppercase font-black text-xs">No Klinis Cases</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
@@ -287,65 +371,31 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('weightChart');
|
||||
|
||||
{{-- Data diurutkan dari yang terlama ke terbaru untuk Chart --}}
|
||||
const weightHistories = {!! json_encode($cat->weightHistories->sortBy('created_at')->values() ?? []) !!};
|
||||
|
||||
if(!weightHistories || weightHistories.length === 0) {
|
||||
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;
|
||||
if(ctx && weightHistories && weightHistories.length > 0) {
|
||||
const labels = weightHistories.map(d => new Date(d.created_at).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }));
|
||||
const data = weightHistories.map(d => d.berat);
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Berat (Kg)',
|
||||
data: data,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
borderWidth: 5,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointBackgroundColor: '#fff',
|
||||
pointBorderColor: '#10b981',
|
||||
pointBorderWidth: 3,
|
||||
pointRadius: 4
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: false }, x: { grid: { display: false } } } }
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Weight (Kg)',
|
||||
data: data,
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: (context) => {
|
||||
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,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
pointBackgroundColor: '#fff',
|
||||
pointBorderColor: '#10b981',
|
||||
pointBorderWidth: 3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 8
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
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>
|
||||
@endpush
|
||||
|
||||
@@ -1,141 +1,548 @@
|
||||
<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="flex gap-3">
|
||||
{{-- Tombol Laporan: Cek modul 'reports' dengan akses 'read' --}}
|
||||
@can('check-access', ['reports', 'read'])
|
||||
<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>
|
||||
@endcan
|
||||
|
||||
{{-- Tombol Tambah Kucing: Cek modul 'cats' dengan akses 'create' --}}
|
||||
@can('check-access', ['cats', 'create'])
|
||||
<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
|
||||
</a>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
{{-- Birth Watch Alert --}}
|
||||
@if($upcomingBirths->count() > 0)
|
||||
<div class="bg-white rounded-[3rem] border-2 border-rose-100 shadow-xl shadow-rose-50 overflow-hidden">
|
||||
<div class="bg-rose-600 px-8 py-4 flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-baby-carriage text-white animate-bounce"></i>
|
||||
<h3 class="text-[10px] font-black text-white uppercase tracking-[0.3em]">Birth Watch: Persalinan Mendatang</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@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
|
||||
</div>
|
||||
<div class="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-900">Beranda</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Ringkasan operasional cattery dan pemantauan kondisi kucing.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Stats Cards --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<x-stats-card title="Total Kucing" :value="$stats['total_cats']" icon="fa-cat" color="indigo" />
|
||||
<x-stats-card title="Indukan Hamil" :value="$stats['total_mating']" icon="fa-venus" color="rose" />
|
||||
<x-stats-card title="Sedang Sakit" :value="$stats['sick_cats']" icon="fa-stethoscope" color="amber" />
|
||||
<x-stats-card title="Stok Kritis" :value="$totalLowStock" icon="fa-exclamation-triangle" color="rose" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{{-- 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 class="text-xs font-black text-slate-800 uppercase leading-none">{{ $t->cat->nama_kucing }}</div>
|
||||
<div class="text-[9px] text-slate-400 font-bold mt-1">{{ $t->created_at->diffForHumans() }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-8 py-5 text-xs italic text-slate-500">"{{ $t->keluhan }}"</td>
|
||||
<td class="px-8 py-5 text-right font-black text-indigo-600 text-[10px] uppercase">{{ $t->nama_obat }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Charts --}}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="bg-white rounded-[3rem] border border-slate-100 p-8">
|
||||
<h4 class="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-6">Sebaran Ras</h4>
|
||||
<canvas id="breedChart" class="max-h-[200px]"></canvas>
|
||||
</div>
|
||||
<div class="bg-white rounded-[3rem] border border-slate-100 p-8">
|
||||
<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>
|
||||
|
||||
{{-- Right Content: Reminders --}}
|
||||
<div class="space-y-8">
|
||||
<div class="bg-slate-900 rounded-[3rem] p-8 text-white relative overflow-hidden shadow-2xl">
|
||||
<h3 class="text-[10px] font-black text-indigo-400 uppercase tracking-[0.3em] mb-6">Medical Reminders</h3>
|
||||
<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>
|
||||
@endforeach
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Breed Chart
|
||||
new Chart(document.getElementById('breedChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: {!! json_encode($chartData->pluck('ras')) !!},
|
||||
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 } } } }
|
||||
});
|
||||
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach([
|
||||
['Total kucing',$stats['total_cats'] ?? 0,'fa-cat','cats.index'],
|
||||
['Dalam perawatan',$stats['sick_cats'] ?? 0,'fa-heart-pulse','cats.index'],
|
||||
['Pembiakan aktif',$stats['total_mating'] ?? 0,'fa-venus-mars','matting.index'],
|
||||
['Anakan tercatat',$stats['total_kittens'] ?? 0,'fa-paw','matting.index']
|
||||
] 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>
|
||||
<i class="fas {{ $card[2] }} mt-1 text-slate-300"></i>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
// Weight Chart
|
||||
new Chart(document.getElementById('weightChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: {!! json_encode($weightTrends->pluck('month')) !!},
|
||||
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)' }]
|
||||
},
|
||||
options: { plugins: { legend: { display: false } }, scales: { y: { beginAtZero: false, grid: { display: false } }, x: { grid: { display: false } } } }
|
||||
});
|
||||
</script>
|
||||
{{-- PEMANTAUAN BERAT BADAN - REVISION 3 --}}
|
||||
<section id="pemantauan-berat" class="mt-6 scroll-mt-6 rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||
<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">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="relative flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-blue-50 text-blue-600">
|
||||
<i class="fas fa-weight-scale text-lg"></i>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-900">Pemantauan Berat Badan</h2>
|
||||
<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>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@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>
|
||||
@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>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<div class="mt-6 grid gap-6 xl:grid-cols-3">
|
||||
<section class="border border-slate-200 bg-white xl:col-span-2">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 px-5 py-4">
|
||||
<div>
|
||||
<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>
|
||||
<a href="{{ route('matting.index') }}" class="text-sm font-medium text-indigo-600">Lihat semua</a>
|
||||
</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>
|
||||
<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>
|
||||
</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">Jadwal yang terlambat</h2>
|
||||
<p class="text-xs text-slate-500">Perlu ditindaklanjuti dari data kucing.</p>
|
||||
</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
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-6 xl:grid-cols-2">
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const formatKg = (value) => `${Number(value).toFixed(2).replace('.', ',')} kg`;
|
||||
const formatMonth = (month) => {
|
||||
const d = new Date(`${month}-01T00:00:00`);
|
||||
return Number.isNaN(d.getTime()) ? month : d.toLocaleDateString('id-ID', { month: 'short', year: '2-digit' });
|
||||
};
|
||||
|
||||
if (typeof Chart !== 'undefined') {
|
||||
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',
|
||||
data: {
|
||||
labels: avgData.labels.map(formatMonth),
|
||||
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: {
|
||||
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>
|
||||
@endpush
|
||||
</x-app-layout>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="space-y-8 pb-12">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-3xl font-black text-slate-800 tracking-tight leading-none mb-1">Medical Insights</h2>
|
||||
<h2 class="text-3xl font-black text-slate-800 tracking-tight leading-none mb-1">Ringkasan Kesehatans</h2>
|
||||
<p class="text-sm text-slate-500 font-medium italic uppercase tracking-tighter">Analisis Kesehatan & Konsumsi Obat Ily Cattery</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
@@ -51,7 +51,7 @@
|
||||
<i class="fas fa-pills text-emerald-400"></i>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
@foreach($topMedicines as $medicine)
|
||||
@foreach($topObats as $medicine)
|
||||
<div class="relative">
|
||||
<div class="flex justify-between text-[10px] font-black uppercase mb-2">
|
||||
<span>{{ $medicine->nama_obat }}</span>
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
<div class="w-full bg-slate-800 rounded-full h-1.5">
|
||||
@php
|
||||
$maxUsed = $topMedicines->first()->total_used ?? 1;
|
||||
$maxUsed = $topObats->first()->total_used ?? 1;
|
||||
$percentage = ($medicine->total_used / $maxUsed) * 100;
|
||||
@endphp
|
||||
<div class="bg-emerald-400 h-1.5 rounded-full" style="width: {{ $percentage }}%"></div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</a>
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-slate-800 tracking-tight">Edit Master Kategori</h2>
|
||||
<p class="text-sm text-slate-500 font-medium italic uppercase tracking-tighter">Penyesuaian Kata Kunci Smart Categorization</p>
|
||||
<p class="text-sm text-slate-500 font-medium italic uppercase tracking-tighter">Penyesuaian kata kunci kategori</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user