initial commit

This commit is contained in:
2026-09-23 12:45:02 +07:00
parent d16f454083
commit 507fc6ccdf
235 changed files with 19587 additions and 2835 deletions
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace App\Exports;
use App\Models\Cat;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
use PhpOffice\PhpSpreadsheet\Shared\Date;
class CatWeightTemplateExport implements FromCollection, WithHeadings, WithMapping, WithStyles, ShouldAutoSize, WithColumnFormatting
{
/**
* Ambil data kucing yang aktif
*/
public function collection()
{
// Tetap mengambil semua kucing yang tidak dihapus
return Cat::whereNull('deleted_at')->get(['id', 'nama_kucing']);
}
/**
* Header Tabel
*/
public function headings(): array
{
return [
"Kode Kucing",
"Nama Kucing",
"Berat Badan (Kg)",
"Tanggal & Waktu" // Ubah header agar staf tahu ini mendukung jam
];
}
/**
* Mapping baris data
*/
public function map($cat): array
{
return [
$cat->id,
$cat->nama_kucing,
'', // Kosongkan untuk diisi staf
// Konversi tanggal PHP murni ke Serial Number Date milik Excel
// Ini mencegah Excel mengubah tanggal menjadi format teks yang berantakan
Date::dateTimeToExcel(now())
];
}
/**
* Format Kolom secara spesifik
*/
public function columnFormats(): array
{
return [
// Memaksa kolom D (Tanggal & Waktu) menggunakan format kalender bawaan Excel
// Formatnya akan menjadi YYYY-MM-DD HH:MM:SS saat staf membuka filenya
'D' => NumberFormat::FORMAT_DATE_DATETIME,
];
}
/**
* Styling Header agar lebih cantik
*/
public function styles(Worksheet $sheet)
{
return [
1 => [
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => '4F81BD']
],
],
];
}
}
+2 -2
View File
@@ -54,7 +54,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
($item->sire?->nama_kucing ?? '-') . ' x ' . ($item->dam?->nama_kucing ?? '-'),
($item->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');
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Exports;
use App\Models\Treatment;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
class TreatmentExport implements FromQuery, WithHeadings, WithMapping, WithStyles, ShouldAutoSize
{
protected $request;
public function __construct($request)
{
// Kita bisa menerima objek Request atau Query Builder hasil clone dari Controller
$this->request = $request;
}
/**
* Query data dengan filter yang sinkron dengan tampilan UI
*/
public function query()
{
$query = Treatment::query()->with(['cat', 'user', 'details.medicine']);
// Filter 1: Pencarian Nama Kucing
if ($this->request->filled('search')) {
$query->whereHas('cat', function($q) {
$q->where('nama_kucing', 'like', '%' . $this->request->search . '%');
});
}
// Filter 2: Nama PIC (Staf)
if ($this->request->filled('pic_id')) {
$query->where('user_id', $this->request->pic_id);
}
// Filter 3: Rentang Tanggal
if ($this->request->filled('start_date') && $this->request->filled('end_date')) {
$query->whereBetween('tanggal', [$this->request->start_date, $this->request->end_date]);
}
// Filter 4: Kondisi (Healthy / Under Treatment)
if ($this->request->filled('status')) {
$query->whereHas('cat', function($q) {
$q->where('treatment_status', $this->request->status);
});
}
return $query->latest('tanggal');
}
/**
* Header Tabel Excel
*/
public function headings(): array
{
return [
"TANGGAL",
"NAMA KUCING",
"STATUS KESEHATAN",
"KELUHAN",
"DIAGNOSA & TINDAKAN",
"DAFTAR OBAT (QTY)",
"DOSIS & KET",
"PIC / STAF"
];
}
/**
* Transformasi Data per Baris
*/
public function map($t): array
{
// Menggabungkan daftar obat menjadi satu string jika ada multi-medicine
$medicineList = $t->details->map(function($d) {
$namaObat = $d->medicine->nama_obat ?? 'Obat Terhapus';
return "{$namaObat} ({$d->qty_used})";
})->implode(", ");
// Jika data detail kosong, gunakan data fallback (legacy support)
if ($medicineList === "") {
$medicineList = $t->nama_obat ? "{$t->nama_obat} ({$t->qty_used})" : "-";
}
// Mengambil deskripsi dosis/keterangan dari detail pertama atau header
$dosisKet = $t->details->map(function($d) {
return "{$d->dosis} [{$d->keterangan}]";
})->implode(" | ");
return [
$t->tanggal->format('d/m/Y'),
$t->cat->nama_kucing ?? 'N/A',
($t->cat->treatment_status == 1) ? 'UNDER TREATMENT' : 'HEALTHY',
$t->keluhan,
$t->diagnosa,
$medicineList,
$dosisKet ?: $t->dosis,
$t->user->name ?? 'System'
];
}
/**
* Memberikan Styling pada file Excel
*/
public function styles(Worksheet $sheet)
{
return [
// Style Header (Baris 1)
1 => [
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
'size' => 11
],
'fill' => [
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
'startColor' => ['rgb' => '4F46E5'] // Indigo-600
],
'alignment' => [
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
],
],
// Memberikan border ke seluruh data yang ada
'A1:H' . ($sheet->getHighestRow()) => [
'borders' => [
'allBorders' => [
'borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN,
'color' => ['rgb' => 'E2E8F0'], // Slate-200
],
],
],
];
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ class UnifiedReportExport implements FromCollection, WithHeadings, WithMapping,
$sheet = $event->sheet;
// 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');
+419 -138
View File
@@ -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'),
]);
}
}
+39 -11
View File
@@ -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.
+423 -56
View File
@@ -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';
}
}
+59 -7
View File
@@ -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'));
}
}
+65 -8
View File
@@ -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.
*/
+7 -8
View File
@@ -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
};
}
+6
View File
@@ -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',
+93 -18
View File
@@ -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.');
}
}
}
+228 -80
View File
@@ -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.',
]);
}
}
+5 -4
View File
@@ -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();
}
}
+1 -1
View File
@@ -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);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Imports;
use App\Models\WeightHistory;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\WithStartRow;
use PhpOffice\PhpSpreadsheet\Shared\Date;
class CatWeightImport implements ToCollection, WithStartRow
{
// Tambahkan variabel untuk menghitung data yang sukses
private int $importedCount = 0;
public function startRow(): int
{
return 2;
}
public function collection(Collection $rows)
{
foreach ($rows as $row) {
$catId = $row[0] ?? null;
$berat = $row[2] ?? null;
$tanggalExcel = $row[3] ?? null;
if (empty($catId) || empty($berat) || empty($tanggalExcel)) {
continue;
}
$parsedDate = null;
try {
if (is_numeric($tanggalExcel)) {
$parsedDate = Date::excelToDateTimeObject($tanggalExcel);
} else {
$tanggalExcel = str_ireplace(
['januari', 'februari', 'maret', 'april', 'mei', 'juni', 'juli', 'agustus', 'september', 'oktober', 'november', 'desember'],
['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'],
$tanggalExcel
);
$parsedDate = Carbon::parse($tanggalExcel);
}
} catch (\Exception $e) {
continue;
}
$carbonDate = Carbon::instance($parsedDate);
$dateOnly = $carbonDate->format('Y-m-d');
$timeOnly = $carbonDate->format('H:i:s');
$existingRecord = WeightHistory::where('cat_id', $catId)
->whereDate('tanggal_timbang', $dateOnly)
->first();
if ($existingRecord) {
$finalTime = ($timeOnly === '00:00:00') ? Carbon::parse($existingRecord->tanggal_timbang)->format('H:i:s') : $timeOnly;
$existingRecord->update([
'berat' => $berat,
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
]);
// Tambah hitungan jika berhasil update
$this->importedCount++;
} else {
$finalTime = ($timeOnly === '00:00:00') ? now()->format('H:i:s') : $timeOnly;
WeightHistory::create([
'cat_id' => $catId,
'berat' => $berat,
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
]);
// Tambah hitungan jika berhasil insert baru
$this->importedCount++;
}
}
}
// Fungsi untuk melempar total hitungan ke Controller
public function getImportedCount(): int
{
return $this->importedCount;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ApiToken extends Model
{
protected $fillable = ['user_id', 'name', 'token_hash', 'last_used_at', 'expires_at'];
protected function casts(): array { return ['last_used_at' => 'datetime', 'expires_at' => 'datetime']; }
public function user() { return $this->belongsTo(User::class); }
}
+106 -44
View File
@@ -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);
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CatAssociation extends Model
{
protected $fillable = ['code', 'name', 'country', 'website', 'is_active'];
protected $casts = ['is_active' => 'boolean'];
public function registrations()
{
return $this->hasMany(CatRegistration::class, 'association_id');
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class CatRegistration extends Model
{
protected $fillable = [
'cat_id', 'association_id', 'registration_number', 'pedigree_number',
'certificate_number', 'issue_date', 'title', 'ownership_status',
'is_primary', 'notes',
];
protected function casts(): array
{
return ['issue_date' => 'date', 'is_primary' => 'boolean'];
}
protected static function booted(): void
{
static::deleting(function (CatRegistration $registration) {
$registration->loadMissing('documents');
foreach ($registration->documents as $document) {
if ($document->file_path) {
Storage::disk('public')->delete($document->file_path);
}
}
});
}
public function cat() { return $this->belongsTo(Cat::class); }
public function association() { return $this->belongsTo(CatAssociation::class, 'association_id'); }
public function documents() { return $this->hasMany(CatRegistrationDocument::class)->latest(); }
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class CatRegistrationDocument extends Model
{
protected $fillable = ['cat_registration_id', 'label', 'document_type', 'file_path', 'issued_at'];
protected function casts(): array { return ['issued_at' => 'date']; }
public function registration() { return $this->belongsTo(CatRegistration::class, 'cat_registration_id'); }
public function getUrlAttribute(): string { return Storage::disk('public')->url($this->file_path); }
}
+47 -14
View File
@@ -3,19 +3,41 @@
namespace App\Models;
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;
}
}
+2 -2
View File
@@ -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>';
}
);
}
+5 -7
View File
@@ -25,7 +25,7 @@ class Matting extends Model
}
/**
* SINKRONISASI OTOMATIS (The Engine)
* SINKRONISASI OTOMATIS (Mesin sinkronisasi)
* Saat data Matting disimpan, otomatis update data di tabel Cats.
*/
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'
]);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; // 1. TAMBAHKAN BARIS INI
class MedicalRecord extends Model
{
use HasFactory;
protected $fillable = [
'cat_id',
'user_id',
'type',
'treatment_name',
'dosage',
'unit',
'date_given',
'next_schedule',
'notes',
];
protected $casts = [
'date_given' => 'date',
'next_schedule' => 'date',
];
public function cat(): BelongsTo
{
return $this->belongsTo(Cat::class);
}
// 2. PASTIKAN RELASI INI BENAR
public function performer(): BelongsTo
{
// Karena kolomnya sekarang user_id (bukan performed_by),
// kita beri tahu Laravel secara eksplisit:
return $this->belongsTo(User::class, 'user_id');
}
}
+73 -30
View File
@@ -4,58 +4,77 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\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
View File
@@ -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
View File
@@ -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);
});
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TreatmentDetail extends Model
{
use HasFactory;
// Menentukan kolom yang boleh diisi secara massal
protected $fillable = [
'treatment_id',
'medicine_id',
'dosis',
'keterangan_pakai',
];
/**
* Relasi balik ke Header Treatment (Rekam Medis Utama)
*/
public function treatment()
{
return $this->belongsTo(Treatment::class);
}
/**
* Relasi ke data Obat (Inventory)
*/
public function medicine()
{
return $this->belongsTo(Medicine::class);
}
}
+20 -14
View File
@@ -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;
}
}
+24 -2
View File
@@ -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);
}
}
+27 -18
View File
@@ -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;
}
});
}
}
}
+13 -9
View File
@@ -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();
}
}
}