initial commit
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Cat;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
|
||||
class CatWeightTemplateExport implements FromCollection, WithHeadings, WithMapping, WithStyles, ShouldAutoSize, WithColumnFormatting
|
||||
{
|
||||
/**
|
||||
* Ambil data kucing yang aktif
|
||||
*/
|
||||
public function collection()
|
||||
{
|
||||
// Tetap mengambil semua kucing yang tidak dihapus
|
||||
return Cat::whereNull('deleted_at')->get(['id', 'nama_kucing']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header Tabel
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
"Kode Kucing",
|
||||
"Nama Kucing",
|
||||
"Berat Badan (Kg)",
|
||||
"Tanggal & Waktu" // Ubah header agar staf tahu ini mendukung jam
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping baris data
|
||||
*/
|
||||
public function map($cat): array
|
||||
{
|
||||
return [
|
||||
$cat->id,
|
||||
$cat->nama_kucing,
|
||||
'', // Kosongkan untuk diisi staf
|
||||
|
||||
// Konversi tanggal PHP murni ke Serial Number Date milik Excel
|
||||
// Ini mencegah Excel mengubah tanggal menjadi format teks yang berantakan
|
||||
Date::dateTimeToExcel(now())
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Kolom secara spesifik
|
||||
*/
|
||||
public function columnFormats(): array
|
||||
{
|
||||
return [
|
||||
// Memaksa kolom D (Tanggal & Waktu) menggunakan format kalender bawaan Excel
|
||||
// Formatnya akan menjadi YYYY-MM-DD HH:MM:SS saat staf membuka filenya
|
||||
'D' => NumberFormat::FORMAT_DATE_DATETIME,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Styling Header agar lebih cantik
|
||||
*/
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => [
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4F81BD']
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
|
||||
($item->sire?->nama_kucing ?? '-') . ' x ' . ($item->dam?->nama_kucing ?? '-'),
|
||||
($item->dam?->pedigree ?? '-'),
|
||||
'S: ' . ($item->ems_bapak ?? '-') . ' / D: ' . ($item->ems_ibu ?? '-'),
|
||||
'S: ' . ($item->no_pedigree_bapak ?? 'Non-Ped') . ' / D: ' . ($item->no_pedigree_ibu ?? 'Non-Ped'),
|
||||
'S: ' . ($item->no_pedigree_bapak ?? 'Non-pedigree') . ' / D: ' . ($item->no_pedigree_ibu ?? 'Non-pedigree'),
|
||||
'S: ' . ($item->no_microchip_bapak ?? '-') . ' / D: ' . ($item->no_microchip_ibu ?? '-'),
|
||||
$tglKawin,
|
||||
$eddDate,
|
||||
@@ -66,7 +66,7 @@ class MattingExport implements FromCollection, WithHeadings, WithMapping, Should
|
||||
return [
|
||||
AfterSheet::class => function(AfterSheet $event) {
|
||||
$sheet = $event->sheet;
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC BREEDING REPORT');
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - LAPORAN PEMBIAKAN');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use App\Models\Treatment;
|
||||
use Maatwebsite\Excel\Concerns\FromQuery;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
|
||||
class TreatmentExport implements FromQuery, WithHeadings, WithMapping, WithStyles, ShouldAutoSize
|
||||
{
|
||||
protected $request;
|
||||
|
||||
public function __construct($request)
|
||||
{
|
||||
// Kita bisa menerima objek Request atau Query Builder hasil clone dari Controller
|
||||
$this->request = $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query data dengan filter yang sinkron dengan tampilan UI
|
||||
*/
|
||||
public function query()
|
||||
{
|
||||
$query = Treatment::query()->with(['cat', 'user', 'details.medicine']);
|
||||
|
||||
// Filter 1: Pencarian Nama Kucing
|
||||
if ($this->request->filled('search')) {
|
||||
$query->whereHas('cat', function($q) {
|
||||
$q->where('nama_kucing', 'like', '%' . $this->request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
// Filter 2: Nama PIC (Staf)
|
||||
if ($this->request->filled('pic_id')) {
|
||||
$query->where('user_id', $this->request->pic_id);
|
||||
}
|
||||
|
||||
// Filter 3: Rentang Tanggal
|
||||
if ($this->request->filled('start_date') && $this->request->filled('end_date')) {
|
||||
$query->whereBetween('tanggal', [$this->request->start_date, $this->request->end_date]);
|
||||
}
|
||||
|
||||
// Filter 4: Kondisi (Healthy / Under Treatment)
|
||||
if ($this->request->filled('status')) {
|
||||
$query->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', $this->request->status);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->latest('tanggal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Header Tabel Excel
|
||||
*/
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
"TANGGAL",
|
||||
"NAMA KUCING",
|
||||
"STATUS KESEHATAN",
|
||||
"KELUHAN",
|
||||
"DIAGNOSA & TINDAKAN",
|
||||
"DAFTAR OBAT (QTY)",
|
||||
"DOSIS & KET",
|
||||
"PIC / STAF"
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformasi Data per Baris
|
||||
*/
|
||||
public function map($t): array
|
||||
{
|
||||
// Menggabungkan daftar obat menjadi satu string jika ada multi-medicine
|
||||
$medicineList = $t->details->map(function($d) {
|
||||
$namaObat = $d->medicine->nama_obat ?? 'Obat Terhapus';
|
||||
return "{$namaObat} ({$d->qty_used})";
|
||||
})->implode(", ");
|
||||
|
||||
// Jika data detail kosong, gunakan data fallback (legacy support)
|
||||
if ($medicineList === "") {
|
||||
$medicineList = $t->nama_obat ? "{$t->nama_obat} ({$t->qty_used})" : "-";
|
||||
}
|
||||
|
||||
// Mengambil deskripsi dosis/keterangan dari detail pertama atau header
|
||||
$dosisKet = $t->details->map(function($d) {
|
||||
return "{$d->dosis} [{$d->keterangan}]";
|
||||
})->implode(" | ");
|
||||
|
||||
return [
|
||||
$t->tanggal->format('d/m/Y'),
|
||||
$t->cat->nama_kucing ?? 'N/A',
|
||||
($t->cat->treatment_status == 1) ? 'UNDER TREATMENT' : 'HEALTHY',
|
||||
$t->keluhan,
|
||||
$t->diagnosa,
|
||||
$medicineList,
|
||||
$dosisKet ?: $t->dosis,
|
||||
$t->user->name ?? 'System'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Memberikan Styling pada file Excel
|
||||
*/
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
// Style Header (Baris 1)
|
||||
1 => [
|
||||
'font' => [
|
||||
'bold' => true,
|
||||
'color' => ['rgb' => 'FFFFFF'],
|
||||
'size' => 11
|
||||
],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '4F46E5'] // Indigo-600
|
||||
],
|
||||
'alignment' => [
|
||||
'horizontal' => \PhpOffice\PhpSpreadsheet\Style\Alignment::HORIZONTAL_CENTER,
|
||||
],
|
||||
],
|
||||
|
||||
// Memberikan border ke seluruh data yang ada
|
||||
'A1:H' . ($sheet->getHighestRow()) => [
|
||||
'borders' => [
|
||||
'allBorders' => [
|
||||
'borderStyle' => \PhpOffice\PhpSpreadsheet\Style\Border::BORDER_THIN,
|
||||
'color' => ['rgb' => 'E2E8F0'], // Slate-200
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,7 @@ class UnifiedReportExport implements FromCollection, WithHeadings, WithMapping,
|
||||
$sheet = $event->sheet;
|
||||
|
||||
// Menulis Metadata
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - STRATEGIC REPORT');
|
||||
$sheet->setCellValue('A1', 'ILY CATTERY - LAPORAN TERPADU');
|
||||
$sheet->setCellValue('A2', 'Dicetak Oleh: ' . Auth::user()->name);
|
||||
$sheet->setCellValue('A3', 'Tanggal Cetak: ' . now()->translatedFormat('d F Y H:i'));
|
||||
$sheet->setCellValue('A4', 'Total Data: ' . $this->data->count() . ' Records');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ApiToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function login(Request $request)
|
||||
{
|
||||
$data = $request->validate(['email' => 'required|email', 'password' => 'required|string', 'device_name' => 'nullable|string|max:100']);
|
||||
$user = User::where('email', strtolower($data['email']))->first();
|
||||
if (!$user || !Hash::check($data['password'], $user->password)) {
|
||||
return response()->json(['message' => 'Email atau kata sandi tidak sesuai.'], 422);
|
||||
}
|
||||
$plain = Str::random(80);
|
||||
ApiToken::create([
|
||||
'user_id' => $user->id,
|
||||
'name' => $data['device_name'] ?? 'Android',
|
||||
'token_hash' => hash('sha256', $plain),
|
||||
'expires_at' => now()->addDays(90),
|
||||
]);
|
||||
return response()->json(['token' => $plain, 'token_type' => 'Bearer', 'user' => $this->userPayload($user)]);
|
||||
}
|
||||
public function me(Request $request) { return response()->json(['user' => $this->userPayload($request->user())]); }
|
||||
public function logout(Request $request) { $request->attributes->get('api_token')?->delete(); return response()->json(['message' => 'Sesi berhasil diakhiri.']); }
|
||||
private function userPayload(User $user): array { return ['id'=>$user->id,'name'=>$user->name,'email'=>$user->email,'role'=>$user->roleModel?->name ?? $user->getRawOriginal('role')]; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Matting;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BreedingController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$items=Matting::with(['sire:id,nama_kucing,foto','dam:id,nama_kucing,foto','kittens.cat:id,nama_kucing,foto'])
|
||||
->when($request->status,fn($q,$v)=>$q->where('status',$v))->latest('tgl_kawin_awal')->paginate(15);
|
||||
$items->getCollection()->transform(fn($m)=>$this->payload($m));
|
||||
return response()->json($items);
|
||||
}
|
||||
public function show(Matting $matting) { Gate::authorize('check-access', ['matting', 'read']); $matting->load(['sire','dam','kittens.cat.registrations.association']); return response()->json(['data'=>$this->payload($matting,true)]); }
|
||||
private function payload(Matting $m,bool $detail=false): array
|
||||
{
|
||||
$edd=$m->tgl_kawin_awal ? Carbon::parse($m->tgl_kawin_awal)->addDays(63) : null;
|
||||
return ['id'=>$m->id,'sire'=>['id'=>$m->sire_id,'nama'=>$m->sire?->nama_kucing],'dam'=>['id'=>$m->dam_id,'nama'=>$m->dam?->nama_kucing],'tanggal_kawin_awal'=>$m->tgl_kawin_awal?->format('Y-m-d'),'tanggal_kawin_akhir'=>$m->tgl_kawin_akhir?->format('Y-m-d'),'perkiraan_lahir'=>$edd?->format('Y-m-d'),'status'=>$m->status,'jumlah_anak_hidup'=>$m->jumlah_anak_hidup,'anakan'=>$m->kittens->map(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'foto'=>$k->cat?->foto_url])->values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class CatController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cats = Cat::with(['registrations.association'])
|
||||
->when($request->search, fn($q,$s)=>$q->where(fn($x)=>$x->where('nama_kucing','like',"%$s%")->orWhere('microchip','like',"%$s%")->orWhere('kode_ems','like',"%$s%")))
|
||||
->when($request->gender, fn($q,$v)=>$q->where('jenis_kelamin',$v))
|
||||
->orderBy('nama_kucing')->paginate(min((int)$request->get('per_page',20),50));
|
||||
$cats->getCollection()->transform(fn($c)=>$this->payload($c));
|
||||
return response()->json($cats);
|
||||
}
|
||||
public function show(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cat->load(['sire:id,nama_kucing','dam:id,nama_kucing','registrations.association','registrations.documents','weightHistories'=>fn($q)=>$q->latest('tanggal_timbang')->take(30)]);
|
||||
return response()->json(['data'=>$this->payload($cat,true)]);
|
||||
}
|
||||
private function payload(Cat $c, bool $detail=false): array
|
||||
{
|
||||
$base=['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'ras'=>$c->ras,'jenis_kelamin'=>$c->jenis_kelamin,'tanggal_lahir'=>$c->tanggal_lahir?->format('Y-m-d'),'usia'=>$c->age,'kode_ems'=>$c->kode_ems,'microchip'=>$c->microchip,'status'=>$c->status,'berat_terakhir'=>(float)($c->berat_badan ?? $c->berat_terakhir ?? 0),'lokasi'=>$c->lokasi_kandang,'registrasi'=>$c->registrations->map(fn($r)=>['id'=>$r->id,'asosiasi'=>$r->association?->code,'nama_asosiasi'=>$r->association?->name,'nomor_registrasi'=>$r->registration_number,'nomor_pedigree'=>$r->pedigree_number,'nomor_sertifikat'=>$r->certificate_number,'title'=>$r->title,'utama'=>$r->is_primary])->values()];
|
||||
if($detail){$base['induk_jantan']=$c->sire?->nama_kucing;$base['induk_betina']=$c->dam?->nama_kucing;$base['riwayat_berat']=$c->weightHistories->map(fn($w)=>['id'=>$w->id,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang])->values();}
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use App\Models\Kitten;
|
||||
use App\Models\Matting;
|
||||
use App\Models\WeightHistory;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$now = now();
|
||||
$upcoming = Matting::with(['sire:id,nama_kucing','dam:id,nama_kucing'])
|
||||
->where('status', 'proses')->whereNotNull('tgl_kawin_awal')->latest('tgl_kawin_awal')->take(20)->get()
|
||||
->map(function ($m) use ($now) {
|
||||
$edd = Carbon::parse($m->tgl_kawin_awal)->addDays(63);
|
||||
return ['id'=>$m->id,'sire'=>$m->sire?->nama_kucing,'dam'=>$m->dam?->nama_kucing,'tanggal_kawin'=>$m->tgl_kawin_awal?->format('Y-m-d'),'perkiraan_lahir'=>$edd->format('Y-m-d'),'sisa_hari'=>$now->startOfDay()->diffInDays($edd->copy()->startOfDay(), false)];
|
||||
})->filter(fn($v) => $v['sisa_hari'] >= -3 && $v['sisa_hari'] <= 30)->values();
|
||||
|
||||
return response()->json([
|
||||
'ringkasan' => [
|
||||
'total_kucing' => Cat::count(),
|
||||
'jantan' => Cat::where('jenis_kelamin','Jantan')->count(),
|
||||
'betina' => Cat::where('jenis_kelamin','Betina')->count(),
|
||||
'breeding_aktif' => Matting::where('status','proses')->count(),
|
||||
'total_anakan' => Kitten::count(),
|
||||
],
|
||||
'perkiraan_kelahiran' => $upcoming,
|
||||
'berat_terbaru' => WeightHistory::with('cat:id,nama_kucing,foto')->latest('tanggal_timbang')->take(8)->get()->map(fn($w)=>['cat_id'=>$w->cat_id,'nama'=>$w->cat?->nama_kucing,'foto'=>$w->cat?->foto_url,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang]),
|
||||
'kucing_terbaru' => Cat::latest()->take(6)->get(['id','nama_kucing','foto','jenis_kelamin','kode_ems','status'])->map(fn($c)=>['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'jenis_kelamin'=>$c->jenis_kelamin,'kode_ems'=>$c->kode_ems,'status'=>$c->status]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Kitten;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class KittenController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$items=Kitten::with(['cat:id,nama_kucing,foto,induk_jantan_id,induk_betina_id','matting.sire:id,nama_kucing','matting.dam:id,nama_kucing'])
|
||||
->when($request->search,fn($q,$s)=>$q->where('nama','like',"%$s%"))->latest('tgl_lahir')->paginate(20);
|
||||
$items->getCollection()->transform(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'foto'=>$k->cat?->foto_url,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'sire'=>$k->matting?->sire?->nama_kucing,'dam'=>$k->matting?->dam?->nama_kucing]);
|
||||
return response()->json($items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Cat;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class WeightController extends Controller
|
||||
{
|
||||
public function index(Cat $cat) { Gate::authorize('check-access', ['cats', 'read']); return response()->json(['data'=>$cat->weightHistories()->orderByDesc('tanggal_timbang')->paginate(30)]); }
|
||||
public function store(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$data=$request->validate(['berat'=>'required|numeric|min:0.05|max:30','tanggal'=>'nullable|date|before_or_equal:today']);
|
||||
$history=$cat->weightHistories()->create(['berat'=>$data['berat'],'tanggal_timbang'=>$data['tanggal'] ?? today()]);
|
||||
DB::table('cats')->where('id',$cat->id)->update(['berat_badan'=>$data['berat'],'berat_terakhir'=>$data['berat'],'updated_at'=>now()]);
|
||||
return response()->json(['message'=>'Berat badan berhasil disimpan.','data'=>$history],201);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\{Matting, Cat, Kitten};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\{Request, RedirectResponse, JsonResponse};
|
||||
use Illuminate\Support\Facades\{DB, Storage};
|
||||
use Illuminate\Support\Facades\{DB, Storage, Gate};
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MattingController extends Controller
|
||||
@@ -15,6 +15,7 @@ class MattingController extends Controller
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$mattings = Matting::with(['sire', 'dam'])
|
||||
->withCount('kittens')
|
||||
->latest()
|
||||
@@ -25,6 +26,7 @@ class MattingController extends Controller
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'create']);
|
||||
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']);
|
||||
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']);
|
||||
return view('pages.matting.create', compact('sires', 'dams'));
|
||||
@@ -32,6 +34,7 @@ class MattingController extends Controller
|
||||
|
||||
public function getCatData($id): JsonResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'read']);
|
||||
$cat = Cat::find($id);
|
||||
if (!$cat) return response()->json(['error' => 'Kucing tidak ditemukan'], 404);
|
||||
|
||||
@@ -48,6 +51,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'create']);
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request) {
|
||||
@@ -79,6 +83,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function edit(Matting $matting): View
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
// Load relasi agar data anak muncul di form edit
|
||||
$matting->load('kittens.cat');
|
||||
|
||||
@@ -93,6 +98,7 @@ class MattingController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'update']);
|
||||
$this->validateMatting($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $matting) {
|
||||
@@ -157,14 +163,12 @@ class MattingController extends Controller
|
||||
private function syncParentData(Request $request)
|
||||
{
|
||||
Cat::where('id', $request->sire_id)->update([
|
||||
'pedigree' => $request->no_pedigree_bapak,
|
||||
'microchip' => $request->no_microchip_bapak,
|
||||
'tanggal_lahir' => $request->tgl_lahir_sire,
|
||||
'kode_ems' => $request->ems_bapak // Sinkron EMS Bapak
|
||||
]);
|
||||
|
||||
Cat::where('id', $request->dam_id)->update([
|
||||
'pedigree' => $request->no_pedigree_ibu,
|
||||
'microchip' => $request->no_microchip_ibu,
|
||||
'tanggal_lahir' => $request->tgl_lahir_dam,
|
||||
'kode_ems' => $request->ems_ibu // Sinkron EMS Ibu
|
||||
@@ -218,6 +222,7 @@ class MattingController extends Controller
|
||||
|
||||
public function destroy(Matting $matting): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['matting', 'delete']);
|
||||
return DB::transaction(function () use ($matting) {
|
||||
try {
|
||||
$pairName = ($matting->sire->nama_kucing ?? 'Sire') . ' x ' . ($matting->dam->nama_kucing ?? 'Dam');
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\{Cat, User, Matting};
|
||||
use App\Models\{Cat, User, Matting,Medicine};
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\{Storage, DB, Gate}; // Tambahkan Gate
|
||||
use Illuminate\Support\Facades\{Storage, DB, Gate,Cache}; // Tambahkan Gate
|
||||
use Illuminate\Support\Carbon;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Imports\CatWeightImport;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Exports\CatWeightTemplateExport;
|
||||
use Maatwebsite\Excel\Validators\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class CatController extends Controller
|
||||
{
|
||||
@@ -21,47 +28,106 @@ class CatController extends Controller
|
||||
/**
|
||||
* Menampilkan daftar kucing dengan filter cerdas.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses: Read
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// Proteksi Akses: Read
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$query = Cat::query()->with(['pic', 'sire', 'dam']);
|
||||
$now = now();
|
||||
$query = Cat::query()->with(['pic:id,name', 'sire', 'dam', 'registrations.association']);
|
||||
|
||||
// 1. Pencarian Global
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_kucing', 'like', "%{$search}%")
|
||||
->orWhere('ras', 'like', "%{$search}%")
|
||||
->orWhere('microchip', 'like', "%{$search}%")
|
||||
->orWhere('pedigree', 'like', "%{$search}%")
|
||||
->orWhere('kode_ems', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Filter Spesifik
|
||||
if ($request->filled('status')) $query->where('status', $request->status);
|
||||
if ($request->filled('ras')) $query->where('ras', $request->ras);
|
||||
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
|
||||
|
||||
$cats = $query->latest()->paginate(15)->withQueryString();
|
||||
|
||||
// 3. Statistik Dashboard (Tetap Utuh)
|
||||
$totalCats = Cat::count();
|
||||
$safeWords = ['sehat', 'normal', 'aktif', 'sembuh', '-', 'tidak ada', 'clean'];
|
||||
$sickCats = Cat::whereNotNull('riwayat_sakit_terakhir')
|
||||
->where(function($q) use ($safeWords) {
|
||||
foreach ($safeWords as $word) {
|
||||
$q->where('riwayat_sakit_terakhir', 'NOT LIKE', "%{$word}%");
|
||||
}
|
||||
})->count();
|
||||
|
||||
$breeds = Cat::distinct()->pluck('ras');
|
||||
|
||||
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
|
||||
// 1. PENCARIAN GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_kucing', 'like', "%{$search}%")
|
||||
->orWhere('ras', 'like', "%{$search}%")
|
||||
->orWhere('microchip', 'like', "%{$search}%")
|
||||
->orWhere('pedigree', 'like', "%{$search}%")
|
||||
->orWhere('kode_ems', 'like', "%{$search}%")
|
||||
->orWhere('lokasi_kandang', 'like', "%{$search}%")
|
||||
->orWhereHas('pic', function($sub) use ($search) {
|
||||
$sub->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 2. FILTER OVERDUE (Link dari Dashboard)
|
||||
if ($request->filled('filter_overdue')) {
|
||||
$filter = $request->get('filter_overdue');
|
||||
|
||||
$query->where(function($q) use ($filter, $now) {
|
||||
if ($filter === 'vaccine') {
|
||||
$q->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now);
|
||||
} elseif ($filter === 'flea' || $filter === 'obat_kutu') {
|
||||
$q->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now);
|
||||
} elseif ($filter === 'deworming' || $filter === 'obat_cacing') {
|
||||
$q->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now);
|
||||
} elseif ($filter === 'grooming') {
|
||||
$q->whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- TAMBAHAN: 2.1 FILTER KESEHATAN (Link dari Dashboard Under Treatment) ---
|
||||
if ($request->get('filter_health') === 'treatment') {
|
||||
// Menggunakan kolom treatment_status: 1 (Treatment), 2 (Healthy)
|
||||
$query->where('treatment_status', 1);
|
||||
}
|
||||
|
||||
// 3. FILTER STATUS & LOGIKA USIA DINAMIS
|
||||
if ($request->filled('status')) {
|
||||
$status = $request->status;
|
||||
|
||||
if (in_array(strtolower($status), ['adult', 'dewasa'])) {
|
||||
// Dewasa: > 12 bulan
|
||||
$query->where('tanggal_lahir', '<=', $now->copy()->subMonths(12));
|
||||
} elseif ($status === 'Junior') {
|
||||
// Junior: 7 bulan - 12 bulan
|
||||
$query->whereBetween('tanggal_lahir', [
|
||||
$now->copy()->subMonths(12)->addDay(),
|
||||
$now->copy()->subMonths(7)
|
||||
]);
|
||||
} elseif ($status === 'Kitten') {
|
||||
// Kitten: < 7 bulan
|
||||
$query->where('tanggal_lahir', '>', $now->copy()->subMonths(7));
|
||||
} else {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. FILTER STANDAR (Ras & Gender)
|
||||
if ($request->filled('ras')) $query->where('ras', $request->ras);
|
||||
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
|
||||
|
||||
// 5. VALIDASI SORTING
|
||||
$allowedSorts = ['nama_kucing', 'lokasi_kandang', 'berat_badan', 'created_at', 'pic_name'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'created_at';
|
||||
$direction = strtolower($request->get('direction')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
if ($sort === 'pic_name') {
|
||||
$query->leftJoin('users', 'cats.pic_id', '=', 'users.id')
|
||||
->select('cats.*')
|
||||
->orderBy('users.name', $direction);
|
||||
} else {
|
||||
$query->orderBy("cats.$sort", $direction);
|
||||
}
|
||||
|
||||
// 6. EKSEKUSI DENGAN PAGINASI
|
||||
$cats = $query->paginate(15)->withQueryString();
|
||||
|
||||
// 7. STATISTIK (DIPERBAIKI: Menggunakan treatment_status)
|
||||
$totalCats = Cat::count();
|
||||
|
||||
// Perbaikan: Tidak lagi menggunakan string search yang tidak akurat (15 vs 4)
|
||||
// Sekarang langsung merujuk pada kolom treatment_status bernilai 1
|
||||
$sickCats = Cat::where('treatment_status', 1)->count();
|
||||
|
||||
$breeds = Cat::distinct()->whereNotNull('ras')->pluck('ras');
|
||||
|
||||
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
// Proteksi Akses: Create
|
||||
@@ -75,47 +141,93 @@ class CatController extends Controller
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'create']);
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'create']);
|
||||
|
||||
$validated = $this->validateCat($request);
|
||||
// 1. Validasi
|
||||
$validated = $this->validateCat($request);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated) {
|
||||
if ($request->hasFile('foto')) {
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
return DB::transaction(function () use ($request, $validated) {
|
||||
// 2. Handling Upload Foto (Fix: Encoding Intervention v3)
|
||||
if ($request->hasFile('foto')) {
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if (isset($validated['berat_badan'])) {
|
||||
$validated['berat_terakhir'] = $validated['berat_badan'];
|
||||
}
|
||||
// 3. Handling Berat Badan (Sederhanakan)
|
||||
// Cukup isi berat_badan, Model Cat akan otomatis mengisi berat_terakhir (via static::saving)
|
||||
if ($request->filled('berat_badan')) {
|
||||
$validated['berat_badan'] = $request->berat_badan;
|
||||
}
|
||||
|
||||
if (empty($validated['status'])) {
|
||||
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
|
||||
}
|
||||
// 4. Handling Status Otomatis
|
||||
if (empty($validated['status'])) {
|
||||
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
|
||||
}
|
||||
|
||||
// Memastikan registry disimpan sebagai JSON jika model tidak melakukan casting
|
||||
if (isset($validated['registry'])) {
|
||||
$validated['registry'] = $validated['registry'];
|
||||
}
|
||||
// 5. Simpan Data
|
||||
// Pastikan kolom-kolom induk juga masuk ke validated jika ada di request
|
||||
$validated['induk_jantan_id'] = $request->induk_jantan_id;
|
||||
$validated['induk_betina_id'] = $request->induk_betina_id;
|
||||
$validated['pic_id'] = $request->pic_id ?? auth()->id();
|
||||
|
||||
$cat = Cat::create($validated);
|
||||
$this->logAction('CREATED', $cat, "Mendaftarkan pasien baru: {$cat->nama_kucing}", 'Cat');
|
||||
$cat = Cat::create($validated);
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
|
||||
});
|
||||
}
|
||||
// 6. Logging (Pastikan Trait LogsActivity & Tabel Log sudah punya kolom 'model')
|
||||
try {
|
||||
$this->logAction('CREATED', $cat, "Mendaftarkan kucing baru: {$cat->nama_kucing}", 'Cat');
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::warning("Log gagal dicatat: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix: Perbaikan cara simpan gambar pada Intervention Image v3
|
||||
*/
|
||||
private function handleImageUpload($file): string
|
||||
{
|
||||
$filename = 'cat_' . uniqid() . '.png';
|
||||
$path = 'cats/' . $filename;
|
||||
|
||||
// Intervention v3: Gunakan encode() atau toPng() lalu simpan sebagai string
|
||||
$img = Image::read($file)->cover(500, 500, 'center');
|
||||
|
||||
// Simpan ke disk public
|
||||
Storage::disk('public')->put($path, $img->toPng()->toString());
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
public function show(Cat $cat): View
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
{
|
||||
// 1. Otorisasi (TETAP ADA)
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load([
|
||||
'treatments' => fn($q) => $q->latest(),
|
||||
'sire', 'dam', 'pic',
|
||||
'weightHistories' => fn($q) => $q->latest()->limit(12)
|
||||
]);
|
||||
return view('pages.cats.show', compact('cat'));
|
||||
}
|
||||
// 2. Eager Loading (DITAMBAHKAN medicalRecords & performer)
|
||||
// Kita memuat medicalRecords agar tidak terjadi query berulang di dalam Loop Blade (N+1 Problem)
|
||||
$cat->load([
|
||||
'treatments' => fn($q) => $q->latest(),
|
||||
'medicalRecords' => fn($q) => $q->latest(), // Baru: Riwayat Vaksin/Obat Cacing
|
||||
'medicalRecords.performer', // Baru: Siapa yang melakukan tindakan
|
||||
'sire',
|
||||
'dam',
|
||||
'pic',
|
||||
'weightHistories' => fn($q) => $q->latest()->limit(12),
|
||||
'registrations.association',
|
||||
'registrations.documents'
|
||||
]);
|
||||
|
||||
// 3. Ambil Data Inventori (Baru: Untuk Datalist di Modal Input)
|
||||
// Menampilkan semua obat yang stoknya masih tersedia
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)
|
||||
->orderBy('nama_obat', 'asc')
|
||||
->get(['nama_obat', 'qty', 'unit']);
|
||||
|
||||
// 4. Return View dengan Compact (DITAMBAHKAN availableMedicines)
|
||||
return view('pages.cats.show', compact('cat', 'availableMedicines'));
|
||||
}
|
||||
|
||||
public function edit(Cat $cat): View
|
||||
{
|
||||
@@ -130,30 +242,46 @@ class CatController extends Controller
|
||||
}
|
||||
|
||||
public function update(Request $request, Cat $cat): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
{
|
||||
// 1. Otorisasi Akses
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$validated = $this->validateCat($request, $cat->id);
|
||||
// 2. Validasi (Mengirimkan $cat->id agar Rule:unique mengabaikan record ini sendiri)
|
||||
$validated = $this->validateCat($request, $cat->id);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
if ($request->hasFile('foto')) {
|
||||
if ($cat->foto) Storage::disk('public')->delete($cat->foto);
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
|
||||
// 3. Handling Foto
|
||||
if ($request->hasFile('foto')) {
|
||||
// Hapus foto lama jika ada
|
||||
if ($cat->foto && Storage::disk('public')->exists($cat->foto)) {
|
||||
Storage::disk('public')->delete($cat->foto);
|
||||
}
|
||||
// Upload foto baru menggunakan helper yang sudah diperbaiki
|
||||
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
|
||||
}
|
||||
|
||||
if ($request->filled('berat_badan')) {
|
||||
$validated['berat_terakhir'] = $request->berat_badan;
|
||||
if ($cat->berat_badan != $request->berat_badan) {
|
||||
$cat->weightHistories()->create(['berat' => $request->berat_badan]);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 4. PENYEMPURNAAN REGISTRY (FIX UPDATE)
|
||||
* Jika di form semua checkbox tidak dicentang, $request->registry akan kosong.
|
||||
* Kita paksa nilainya menjadi ['NON-PED'] agar data lama di DB terhapus/terganti.
|
||||
*/
|
||||
$validated['registry'] = $request->input('registry', ['NON-PED']);
|
||||
|
||||
$cat->update($validated);
|
||||
$this->logAction('UPDATED', $cat, "Update data medis/profil: {$cat->nama_kucing}", 'Cat');
|
||||
// 5. Update Data Utama
|
||||
// Logika berat_badan sudah otomatis dihandle oleh Model (static::saving/updated)
|
||||
$cat->update($validated);
|
||||
|
||||
return redirect()->route('cats.index')->with('success', 'Data berhasil diperbarui.');
|
||||
});
|
||||
}
|
||||
// 6. Activity Log (Dibalut Try-Catch agar proses update tidak gagal hanya karena log)
|
||||
try {
|
||||
$this->logAction('UPDATED', $cat, "Memperbarui profil medis & identitas: {$cat->nama_kucing}", 'Cat');
|
||||
} catch (\Exception $e) {
|
||||
\Illuminate\Support\Facades\Log::warning("Activity Log gagal dicatat: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "Data {$cat->nama_kucing} berhasil diperbarui.");
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(Cat $cat): RedirectResponse
|
||||
{
|
||||
@@ -189,71 +317,224 @@ class CatController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF ID Card / Medical Pass
|
||||
* PENYEMPURNAAN: Perbaikan Barcode (Base64 SVG)
|
||||
*/
|
||||
public function downloadPdf(Cat $cat)
|
||||
//cetak download health ledger pdf
|
||||
|
||||
public function downloadReport(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load([
|
||||
'medicalRecords' => fn($q) => $q->orderBy('date_given', 'desc'),
|
||||
'medicalRecords.performer',
|
||||
'treatments' => fn($q) => $q->orderBy('tanggal', 'desc'), // <--- Pastikan ini ada
|
||||
'sire',
|
||||
'dam',
|
||||
'weightHistories' => fn($q) => $q->orderBy('created_at', 'desc')
|
||||
]);
|
||||
|
||||
$groupedHistory = $cat->medicalRecords->groupBy('type');
|
||||
|
||||
$pdf = Pdf::loadView('pages.reports.medical_history', compact('cat', 'groupedHistory'))
|
||||
->setPaper('a4', 'portrait');
|
||||
|
||||
$filename = "Medical_Report_" . Str::slug($cat->nama_kucing) . "_" . date('Ymd') . ".pdf";
|
||||
|
||||
return $pdf->download($filename);
|
||||
}
|
||||
|
||||
public function bulkWeightPage(): View
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
|
||||
$cat->load(['pic', 'sire', 'dam']);
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// FIX: Konversi SVG ke Base64 agar tampil di domPDF tanpa Imagick
|
||||
$qrCodeRaw = QrCode::format('svg')
|
||||
->size(150)
|
||||
->margin(1)
|
||||
->generate(route('cats.show', $cat->id));
|
||||
|
||||
$qrCode = base64_encode($qrCodeRaw);
|
||||
return view('pages.cats.bulk-weight');
|
||||
}
|
||||
|
||||
$pdf = Pdf::loadView('pages.cats.pdf-card', [
|
||||
'cat' => $cat,
|
||||
'qrCode' => $qrCode, // Dikirim sebagai Base64
|
||||
'printedAt' => Carbon::now()->translatedFormat('d F Y H:i')
|
||||
public function downloadWeightTemplate(): BinaryFileResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Menambahkan Jam & Menit agar nama file lebih spesifik
|
||||
$fileName = "Template_Bulk_BB_" . date('Ymd_Hi') . ".xlsx";
|
||||
|
||||
return Excel::download(new CatWeightTemplateExport, $fileName);
|
||||
}
|
||||
|
||||
public function uploadWeight(Request $request): RedirectResponse
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
// Tambahkan rule 'file' untuk memastikan inputan benar-benar file
|
||||
'file_excel' => 'required|file|mimes:xlsx,xls,csv|max:5120',
|
||||
], [
|
||||
'file_excel.required' => 'File Excel wajib diunggah.',
|
||||
'file_excel.mimes' => 'Format file harus berupa Excel (.xlsx, .xls) atau CSV.',
|
||||
'file_excel.max' => 'Ukuran file maksimal adalah 5MB.',
|
||||
]);
|
||||
|
||||
return $pdf->setPaper([0, 0, 300, 500], 'portrait')
|
||||
->stream('MEDICAL_PASS_' . strtoupper($cat->nama_kucing) . '.pdf');
|
||||
try {
|
||||
// 1. Masukkan class import ke dalam variabel
|
||||
$importHelper = new CatWeightImport();
|
||||
|
||||
// 2. Jalankan proses import menggunakan variabel tersebut
|
||||
Excel::import($importHelper, $request->file('file_excel'));
|
||||
|
||||
// 3. Ambil jumlah data yang berhasil diproses
|
||||
$totalBerhasil = $importHelper->getImportedCount();
|
||||
|
||||
// 4. Sisipkan jumlahnya ke dalam pesan sukses
|
||||
return redirect()->route('cats.index')
|
||||
->with('success', "Data berat badan massal berhasil diproses! Sebanyak {$totalBerhasil} record berhasil disinkronisasi ke dalam database.");
|
||||
|
||||
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
|
||||
return back()->with('error', 'Tipe file tidak dikenali. Pastikan file tidak rusak (corrupt).');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
// MENANGKAP ERROR SISTEM / DATABASE
|
||||
return back()->with('error', 'Gagal memproses file Excel. Pastikan format kolom sesuai template. Error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan/Update Sertifikat Pedigree
|
||||
*/
|
||||
public function uploadPedigree(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$request->validate(['sertifikat_ped_file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240']);
|
||||
|
||||
$registration = $cat->registrations()->where('is_primary', true)->first() ?: $cat->registrations()->first();
|
||||
if (!$registration) {
|
||||
return redirect()->route('cats.pedigree', $cat)->with('error', 'Tambahkan registrasi asosiasi terlebih dahulu sebelum mengunggah sertifikat.');
|
||||
}
|
||||
|
||||
$path = $request->file('sertifikat_ped_file')->store('cat-registrations/' . $cat->id, 'public');
|
||||
$registration->documents()->create([
|
||||
'label' => 'Sertifikat Pedigree',
|
||||
'document_type' => 'pedigree',
|
||||
'file_path' => $path,
|
||||
]);
|
||||
|
||||
return redirect()->route('cats.pedigree', $cat)->with('success', 'Sertifikat pedigree berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* HELPERS (TETAP UTUH) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
private function validateCat(Request $request, $id = null)
|
||||
|
||||
private function validateCat(Request $request, $id = null)
|
||||
{
|
||||
return $request->validate([
|
||||
// Identitas Dasar
|
||||
'nama_kucing' => 'required|string|max:255',
|
||||
'ras' => 'required|string|max:100',
|
||||
'jenis_kelamin' => 'required|in:Jantan,Betina',
|
||||
'tanggal_lahir' => 'nullable|date|before_or_equal:today',
|
||||
|
||||
// Media
|
||||
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
|
||||
|
||||
// Registry & Legalitas (Ditambahkan NON-PED & Unik)
|
||||
'registry' => 'nullable|array',
|
||||
'registry.*' => ['string', \Illuminate\Validation\Rule::in(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'])],
|
||||
'pedigree' => [
|
||||
'nullable', 'string', 'max:255',
|
||||
Rule::unique('cats', 'pedigree')->ignore($id)
|
||||
],
|
||||
'microchip' => [
|
||||
'nullable', 'string', 'max:255',
|
||||
Rule::unique('cats', 'microchip')->ignore($id)
|
||||
],
|
||||
'kode_ems' => 'nullable|string|max:50',
|
||||
|
||||
// Status & Lokasi
|
||||
'status' => 'nullable|string|max:50',
|
||||
'lokasi_kandang'=> 'nullable|string|max:255',
|
||||
'pic_id' => 'nullable|exists:users,id',
|
||||
|
||||
// Silsilah (Validasi Induk harus sesuai Gender)
|
||||
'induk_jantan_id' => [
|
||||
'nullable',
|
||||
Rule::exists('cats', 'id')->where(function ($query) {
|
||||
$query->where('jenis_kelamin', 'Jantan');
|
||||
}),
|
||||
],
|
||||
'induk_betina_id' => [
|
||||
'nullable',
|
||||
Rule::exists('cats', 'id')->where(function ($query) {
|
||||
$query->where('jenis_kelamin', 'Betina');
|
||||
}),
|
||||
],
|
||||
|
||||
// Medis & Monitoring
|
||||
'berat_badan' => 'nullable|numeric|min:0|max:20',
|
||||
'riwayat_sakit_terakhir' => 'nullable|string',
|
||||
'jadwal_vaksin' => 'nullable|date',
|
||||
'jadwal_obat_cacing' => 'nullable|date',
|
||||
'jadwal_obat_kutu' => 'nullable|date',
|
||||
'jadwal_grooming' => 'nullable|date',
|
||||
], [
|
||||
// Custom Message (Opsional agar user paham kenapa gagal)
|
||||
'induk_jantan_id.exists' => 'Induk jantan harus kucing dengan jenis kelamin Jantan.',
|
||||
'induk_betina_id.exists' => 'Induk betina harus kucing dengan jenis kelamin Betina.',
|
||||
'microchip.unique' => 'Nomor Microchip sudah terdaftar di sistem.',
|
||||
'tanggal_lahir.before_or_equal' => 'Tanggal lahir tidak boleh melebihi hari ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function completeTreatment(Request $request, Cat $cat)
|
||||
{
|
||||
return $request->validate([
|
||||
'nama_kucing' => 'required|string|max:255',
|
||||
'ras' => 'required|string',
|
||||
'jenis_kelamin' => 'required|in:Jantan,Betina',
|
||||
'tanggal_lahir' => 'nullable|date',
|
||||
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
|
||||
'registry' => 'nullable|array',
|
||||
'registry.*' => 'string',
|
||||
'pedigree' => 'nullable|string|max:255',
|
||||
'microchip' => 'nullable|string|max:255',
|
||||
'kode_ems' => 'nullable|string|max:255',
|
||||
'status' => 'nullable|string',
|
||||
'induk_jantan_id' => 'nullable|exists:cats,id',
|
||||
'induk_betina_id' => 'nullable|exists:cats,id',
|
||||
'lokasi_kandang' => 'nullable|string',
|
||||
'pic_id' => 'nullable|exists:users,id',
|
||||
'berat_badan' => 'nullable|numeric|min:0',
|
||||
'riwayat_sakit_terakhir' => 'nullable|string',
|
||||
'jadwal_vaksin' => 'nullable|date',
|
||||
'jadwal_obat_cacing' => 'nullable|date',
|
||||
'jadwal_obat_kutu' => 'nullable|date',
|
||||
'jadwal_grooming' => 'nullable|date',
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
'treatment_id' => 'required|exists:treatments,id',
|
||||
'remark_pasca_sakit' => 'required|string|max:500',
|
||||
]);
|
||||
|
||||
// ========================================================
|
||||
// 1. EKSEKUSI PAKSA TABEL TREATMENTS (JALUR KERAS)
|
||||
// DB::table akan menembus langsung ke database MySQL Anda.
|
||||
// ========================================================
|
||||
DB::table('treatments')
|
||||
->where('id', $request->treatment_id)
|
||||
->update([
|
||||
'status_perawatan' => 2,
|
||||
'remark_pasca_sakit' => $request->remark_pasca_sakit,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Ambil nama keluhan untuk dimunculkan di notifikasi
|
||||
$namaKeluhan = DB::table('treatments')->where('id', $request->treatment_id)->value('keluhan');
|
||||
|
||||
// ========================================================
|
||||
// 2. CEK SISA PENYAKIT (Adakah penyakit lain di kucing ini yang masih = 1?)
|
||||
// ========================================================
|
||||
$sisaPenyakitAktif = DB::table('treatments')
|
||||
->where('cat_id', $cat->id)
|
||||
->where('status_perawatan', 1)
|
||||
->exists();
|
||||
|
||||
// ========================================================
|
||||
// 3. EKSEKUSI TABEL CATS BERDASARKAN HASIL CEK
|
||||
// ========================================================
|
||||
if (!$sisaPenyakitAktif) {
|
||||
// JIKA SUDAH TIDAK ADA PENYAKIT LAIN -> Kucing Full Sehat (2)
|
||||
$cat->treatment_status = 2;
|
||||
$cat->save();
|
||||
|
||||
return redirect()->back()->with('success', "Penyakit '{$namaKeluhan}' disembuhkan. Kucing sekarang berstatus SEHAT sepenuhnya!");
|
||||
}
|
||||
|
||||
// JIKA MASIH ADA PENYAKIT LAIN -> Pastikan kucing TETAP berstatus Treatment (1)
|
||||
$cat->treatment_status = 1;
|
||||
$cat->save();
|
||||
|
||||
return redirect()->back()->with('warning', "Penyakit '{$namaKeluhan}' disembuhkan, namun kucing MASIH DALAM PERAWATAN untuk keluhan lainnya.");
|
||||
}
|
||||
|
||||
private function handleImageUpload($file): string
|
||||
public function pedigree(Cat $cat)
|
||||
{
|
||||
$filename = 'cat_' . uniqid() . '.png';
|
||||
$path = 'cats/' . $filename;
|
||||
$img = Image::read($file)->cover(500, 500, 'center')->toPng();
|
||||
Storage::disk('public')->put($path, (string) $img);
|
||||
return $path;
|
||||
return view('pages.cats.pedigree', compact('cat'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cat;
|
||||
use App\Models\CatAssociation;
|
||||
use App\Models\CatRegistration;
|
||||
use App\Models\CatRegistrationDocument;
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CatRegistrationController extends Controller
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
public function index(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'read']);
|
||||
$cat->load(['registrations.association', 'registrations.documents']);
|
||||
$associations = CatAssociation::where('is_active', true)->orderBy('code')->get();
|
||||
return view('pages.cats.pedigree', compact('cat', 'associations'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
$validated = $this->validateRegistration($request, $cat);
|
||||
|
||||
return DB::transaction(function () use ($request, $validated, $cat) {
|
||||
$associationId = $this->resolveAssociation($request);
|
||||
if ($cat->registrations()->where('association_id', $associationId)->exists()) {
|
||||
return back()->withErrors(['association_id' => 'Asosiasi ini sudah terdaftar untuk kucing tersebut.']);
|
||||
}
|
||||
if ($request->boolean('is_primary')) {
|
||||
$cat->registrations()->update(['is_primary' => false]);
|
||||
}
|
||||
$registration = $cat->registrations()->create(array_merge($validated, [
|
||||
'association_id' => $associationId,
|
||||
'is_primary' => $request->boolean('is_primary') || !$cat->registrations()->exists(),
|
||||
]));
|
||||
$this->storeDocumentIfPresent($request, $registration);
|
||||
$this->logAction('CAT_REGISTRATION_CREATED', $cat, 'Menambahkan registrasi pedigree pada ' . $registration->association->code, 'Cat');
|
||||
return back()->with('success', 'Registrasi pedigree berhasil ditambahkan.');
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Request $request, Cat $cat, CatRegistration $registration)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id, 404);
|
||||
$validated = $this->validateRegistration($request, $cat, $registration);
|
||||
if ($request->boolean('is_primary')) {
|
||||
$cat->registrations()->whereKeyNot($registration->id)->update(['is_primary' => false]);
|
||||
}
|
||||
$registration->update(array_merge($validated, ['is_primary' => $request->boolean('is_primary')]));
|
||||
return back()->with('success', 'Registrasi pedigree berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Cat $cat, CatRegistration $registration)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id, 404);
|
||||
$wasPrimary = $registration->is_primary;
|
||||
$registration->delete();
|
||||
if ($wasPrimary && ($next = $cat->registrations()->oldest()->first())) {
|
||||
$next->update(['is_primary' => true]);
|
||||
}
|
||||
return back()->with('success', 'Registrasi pedigree berhasil dihapus.');
|
||||
}
|
||||
|
||||
public function storeDocument(Request $request, Cat $cat, CatRegistration $registration)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id, 404);
|
||||
$request->validate([
|
||||
'document' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'label' => 'nullable|string|max:120',
|
||||
'document_type' => ['nullable', Rule::in(['pedigree','registration','ownership','title','other'])],
|
||||
'issued_at' => 'nullable|date',
|
||||
]);
|
||||
$this->storeDocumentIfPresent($request, $registration, 'document');
|
||||
return back()->with('success', 'Dokumen berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
public function destroyDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
abort_unless($registration->cat_id === $cat->id && $document->cat_registration_id === $registration->id, 404);
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
$document->delete();
|
||||
return back()->with('success', 'Dokumen berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function validateRegistration(Request $request, Cat $cat, ?CatRegistration $registration = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'registration_number' => 'nullable|string|max:100',
|
||||
'pedigree_number' => 'nullable|string|max:100',
|
||||
'certificate_number' => 'nullable|string|max:100',
|
||||
'issue_date' => 'nullable|date',
|
||||
'title' => 'nullable|string|max:120',
|
||||
'ownership_status' => ['nullable', Rule::in(['milik_sendiri','co_own','transfer','lainnya'])],
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_primary' => 'nullable|boolean',
|
||||
'association_id' => $registration ? 'nullable' : 'required',
|
||||
'association_code' => 'nullable|string|max:30',
|
||||
'association_name' => 'nullable|string|max:120',
|
||||
'certificate_file' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveAssociation(Request $request): int
|
||||
{
|
||||
if ($request->association_id !== 'custom') {
|
||||
return (int) CatAssociation::whereKey($request->association_id)->where('is_active', true)->firstOrFail()->id;
|
||||
}
|
||||
$request->validate(['association_code' => 'required|string|max:30', 'association_name' => 'required|string|max:120']);
|
||||
return CatAssociation::firstOrCreate(
|
||||
['code' => strtoupper(trim($request->association_code))],
|
||||
['name' => trim($request->association_name), 'is_active' => true]
|
||||
)->id;
|
||||
}
|
||||
|
||||
private function storeDocumentIfPresent(Request $request, CatRegistration $registration, string $field = 'certificate_file'): void
|
||||
{
|
||||
if (!$request->hasFile($field)) return;
|
||||
$path = $request->file($field)->store('cat-registrations/' . $registration->cat_id, 'public');
|
||||
$registration->documents()->create([
|
||||
'label' => $request->input('label') ?: 'Sertifikat Pedigree',
|
||||
'document_type' => $request->input('document_type') ?: 'pedigree',
|
||||
'file_path' => $path,
|
||||
'issued_at' => $request->input('issued_at') ?: $request->input('issue_date'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
@@ -18,13 +19,41 @@ class CategoryController extends Controller
|
||||
/**
|
||||
* Menampilkan daftar kategori.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
// Mengambil kategori beserta hitungan item di dalamnya (optional untuk insight)
|
||||
$categories = Category::latest()->get();
|
||||
return view('pages.inventory.categories.index', compact('categories'));
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// 1. Proteksi Akses (Sesuai Matriks Role Management)
|
||||
Gate::authorize('check-access', ['categories', 'read']);
|
||||
|
||||
$query = Category::query();
|
||||
|
||||
// 2. SEARCH GLOBAL (Mencari di Nama & Keywords)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('keywords', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 3. LOGIKA SORTING DINAMIS
|
||||
$allowedSorts = ['name', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'name';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// 4. EKSEKUSI DENGAN PAGINASI (Standarisasi 15 item per halaman)
|
||||
$categories = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK RINGKAS
|
||||
$totalCategories = Category::count();
|
||||
$latestCategory = Category::latest()->first()?->name ?? '-';
|
||||
|
||||
return view('pages.inventory.categories.index', compact(
|
||||
'categories', 'totalCategories', 'latestCategory'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Menyimpan kategori baru.
|
||||
*/
|
||||
@@ -46,13 +75,12 @@ class CategoryController extends Controller
|
||||
* Menampilkan halaman edit (Hanya Admin).
|
||||
*/
|
||||
public function edit(Category $category): View
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
{
|
||||
|
||||
Gate::authorize('check-access', ['categories', 'update']);
|
||||
|
||||
return view('pages.inventory.categories.edit', compact('category'));
|
||||
}
|
||||
return view('pages.inventory.categories.edit', compact('category'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Memperbarui data kategori.
|
||||
|
||||
@@ -3,86 +3,453 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment};
|
||||
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment, Kitten};
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
// 1. Statistik Utama
|
||||
$now = Carbon::now();
|
||||
$selectedMonth = $request->get('month', $now->format('m'));
|
||||
$selectedYear = $request->get('year', $now->format('Y'));
|
||||
$selectedGender = $request->get('gender');
|
||||
$selectedStatus = $request->get('status');
|
||||
|
||||
$stats = [
|
||||
'total_cats' => Cat::count(),
|
||||
'total_staff' => \App\Models\User::count(),
|
||||
'sick_cats' => Cat::whereNotNull('riwayat_sakit_terakhir')->count(),
|
||||
'total_kittens' => Kitten::count(),
|
||||
'sick_cats' => Cat::where('treatment_status', 1)->count(),
|
||||
'total_mating' => Matting::where('status', 'proses')->count(),
|
||||
];
|
||||
|
||||
// 2. Birth Watch: Persalinan 7 Hari ke Depan (Rumus: H+63)
|
||||
$upcomingBirths = Matting::with(['dam', 'sire', 'pic'])
|
||||
$upcomingBirths = Matting::with(['dam', 'sire'])
|
||||
->where('status', 'proses')
|
||||
->whereNotNull('tanggal_kawin')
|
||||
->whereBetween('tanggal_kawin', [
|
||||
now()->subDays(70)->toDateString(),
|
||||
now()->subDays(56)->toDateString()
|
||||
])
|
||||
->whereNotNull('tgl_kawin_awal')
|
||||
->get()
|
||||
->map(function ($mating) {
|
||||
$mating->edd = Carbon::parse($mating->tanggal_kawin)->addDays(63);
|
||||
$mating->days_to_go = (int) now()->diffInDays($mating->edd, false);
|
||||
->map(function ($mating) use ($now) {
|
||||
$mating->edd = Carbon::parse($mating->tgl_kawin_awal)->addDays(63);
|
||||
$mating->days_to_go = (int) $now->copy()->startOfDay()->diffInDays($mating->edd->copy()->startOfDay(), false);
|
||||
return $mating;
|
||||
})
|
||||
->filter(fn($m) => $m->days_to_go >= -2 && $m->days_to_go <= 7)
|
||||
->filter(fn ($mating) => $mating->days_to_go >= -2 && $mating->days_to_go <= 7)
|
||||
->sortBy('days_to_go');
|
||||
|
||||
// 3. Medical Alerts
|
||||
$medicalAlerts = [
|
||||
'vaccine' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<=', now()->addDays(3))->count(),
|
||||
'flea' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<=', now()->addDays(3))->count(),
|
||||
'deworming' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<=', now()->addDays(3))->count(),
|
||||
'grooming' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<=', now()->addDays(3))->count(),
|
||||
$overdueAlerts = [
|
||||
'vaccine' => [
|
||||
'count' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'vaccine']),
|
||||
],
|
||||
'flea' => [
|
||||
'count' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'flea']),
|
||||
],
|
||||
'deworming' => [
|
||||
'count' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'deworming']),
|
||||
],
|
||||
'grooming' => [
|
||||
'count' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now)->count(),
|
||||
'url' => route('cats.index', ['filter_overdue' => 'grooming']),
|
||||
],
|
||||
];
|
||||
|
||||
// 4. Inventory Critical (Stok < 5)
|
||||
$lowStock = [
|
||||
'medicines' => Medicine::where('qty', '<', 5)->get(['nama_obat as name', 'qty', 'unit']),
|
||||
'foods' => Food::where('qty', '<', 5)->get(['nama_makanan as name', 'qty', 'unit']),
|
||||
'supplies' => Supply::where('qty', '<', 5)->get(['nama_perlengkapan as name', 'qty', 'unit']),
|
||||
];
|
||||
$totalLowStock = collect($lowStock)->flatten(1)->count();
|
||||
|
||||
// 5. Chart Data: Sebaran Ras (Persian, British Shorthair, dll)
|
||||
$chartData = Cat::select('ras', DB::raw('count(*) as total'))->groupBy('ras')->get();
|
||||
|
||||
// 6. Weight Trends: Rata-rata Berat (6 Bulan Terakhir)
|
||||
$weightTrends = DB::table('weight_histories')
|
||||
/*
|
||||
* Berat terakhir untuk daftar/ranking pada bulan yang dipilih.
|
||||
* Kode ini dipertahankan agar fungsi dashboard lama tetap tersedia.
|
||||
*/
|
||||
$weightQuery = DB::table('weight_histories')
|
||||
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
|
||||
->select(
|
||||
DB::raw('AVG(berat) as avg_weight'),
|
||||
DB::raw("DATE_FORMAT(tanggal_timbang, '%M') as month"),
|
||||
DB::raw('MONTH(tanggal_timbang) as month_num')
|
||||
'cats.id as cat_id',
|
||||
'cats.nama_kucing',
|
||||
'cats.jenis_kelamin',
|
||||
'cats.status',
|
||||
'cats.tanggal_lahir',
|
||||
'weight_histories.berat',
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->where('tanggal_timbang', '>=', now()->subMonths(6))
|
||||
->groupBy('month', 'month_num')
|
||||
->orderBy('month_num')
|
||||
->get();
|
||||
->whereIn('weight_histories.id', function ($query) use ($selectedMonth, $selectedYear) {
|
||||
$query->select(DB::raw('MAX(id)'))
|
||||
->from('weight_histories')
|
||||
->whereMonth('tanggal_timbang', $selectedMonth)
|
||||
->whereYear('tanggal_timbang', $selectedYear)
|
||||
->groupBy('cat_id');
|
||||
});
|
||||
|
||||
if ($weightTrends->isEmpty()) {
|
||||
$weightTrends = collect([(object)['month' => now()->format('F'), 'avg_weight' => 0]]);
|
||||
if ($selectedGender) {
|
||||
$weightQuery->where('cats.jenis_kelamin', $selectedGender);
|
||||
}
|
||||
|
||||
// 7. Recent Activity
|
||||
$recentTreatments = Treatment::with(['cat', 'pic'])->latest()->take(5)->get();
|
||||
if ($selectedStatus) {
|
||||
if (in_array(strtolower($selectedStatus), ['adult', 'dewasa'])) {
|
||||
$weightQuery->whereIn('cats.status', ['Adult', 'Dewasa']);
|
||||
} else {
|
||||
$weightQuery->where('cats.status', $selectedStatus);
|
||||
}
|
||||
}
|
||||
|
||||
$individualWeightData = $weightQuery
|
||||
->orderBy('weight_histories.berat', 'desc')
|
||||
->get()
|
||||
->map(function ($cat) use ($now) {
|
||||
$cat->usia = $this->formatAge($cat->tanggal_lahir, $now);
|
||||
$cat->kategori_usia = $this->ageCategory($cat->tanggal_lahir, $cat->status, $now);
|
||||
return $cat;
|
||||
});
|
||||
|
||||
$weightSummary = [
|
||||
'overall' => $individualWeightData->avg('berat') ?? 0,
|
||||
'male' => $individualWeightData->where('jenis_kelamin', 'Jantan')->avg('berat') ?? 0,
|
||||
'female' => $individualWeightData->where('jenis_kelamin', 'Betina')->avg('berat') ?? 0,
|
||||
];
|
||||
|
||||
/*
|
||||
* Pemantauan berat badan.
|
||||
* Kategori umur dihitung dari tanggal lahir agar konsisten:
|
||||
* Kitten < 7 bulan, Junior 7-11 bulan, Dewasa >= 12 bulan.
|
||||
*/
|
||||
$allowedTrendMonths = [3, 6, 12, 24];
|
||||
$weightTrendMonths = (int) $request->get('weight_months', 6);
|
||||
if (!in_array($weightTrendMonths, $allowedTrendMonths, true)) {
|
||||
$weightTrendMonths = 6;
|
||||
}
|
||||
|
||||
// Filter tabel dapat menampilkan semua kategori, sedangkan grafik individual
|
||||
// selalu dibatasi pada satu kategori agar mudah dibaca.
|
||||
$allowedTableCategories = ['Semua', 'Kitten', 'Junior', 'Dewasa'];
|
||||
$weightTableCategory = $request->get('weight_category', 'Semua');
|
||||
if (!in_array($weightTableCategory, $allowedTableCategories, true)) {
|
||||
$weightTableCategory = 'Semua';
|
||||
}
|
||||
|
||||
$allowedTrendCategories = ['Kitten', 'Junior', 'Dewasa'];
|
||||
$defaultTrendCategory = $weightTableCategory !== 'Semua' ? $weightTableCategory : 'Kitten';
|
||||
$weightTrendCategory = $request->get('trend_category', $defaultTrendCategory);
|
||||
if (!in_array($weightTrendCategory, $allowedTrendCategories, true)) {
|
||||
$weightTrendCategory = 'Kitten';
|
||||
}
|
||||
|
||||
$trendStart = $now->copy()->subMonths($weightTrendMonths - 1)->startOfMonth();
|
||||
|
||||
$weightTrendRows = DB::table('weight_histories')
|
||||
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
|
||||
->select(
|
||||
'cats.id as cat_id',
|
||||
'cats.nama_kucing',
|
||||
'cats.jenis_kelamin',
|
||||
'cats.status',
|
||||
'cats.tanggal_lahir',
|
||||
'cats.foto',
|
||||
'weight_histories.berat',
|
||||
'weight_histories.tanggal_timbang'
|
||||
)
|
||||
->whereNull('cats.deleted_at')
|
||||
->whereDate('weight_histories.tanggal_timbang', '>=', $trendStart->toDateString())
|
||||
->orderBy('weight_histories.tanggal_timbang')
|
||||
->orderBy('cats.nama_kucing')
|
||||
->get()
|
||||
->map(function ($row) use ($now) {
|
||||
$row->kategori_usia = $this->ageCategory($row->tanggal_lahir, $row->status, $now);
|
||||
$date = Carbon::parse($row->tanggal_timbang);
|
||||
$row->tanggal_iso = $date->format('Y-m-d');
|
||||
$row->bulan_iso = $date->format('Y-m');
|
||||
return $row;
|
||||
});
|
||||
|
||||
// Sumbu grafik dibuat per bulan, bukan per tanggal timbang, supaya garis tidak terlalu padat.
|
||||
$categoryMonthKeys = collect();
|
||||
for ($cursor = $trendStart->copy(); $cursor->lte($now); $cursor->addMonth()) {
|
||||
$categoryMonthKeys->push($cursor->format('Y-m'));
|
||||
}
|
||||
|
||||
$categoryAverageTrend = [
|
||||
'labels' => $categoryMonthKeys,
|
||||
'series' => collect(['Kitten', 'Junior', 'Dewasa'])->map(function ($category) use ($categoryMonthKeys, $weightTrendRows) {
|
||||
$rows = $weightTrendRows->where('kategori_usia', $category);
|
||||
|
||||
return [
|
||||
'name' => $category,
|
||||
'data' => $categoryMonthKeys->map(function ($month) use ($rows) {
|
||||
// Ambil berat terakhir setiap kucing pada bulan tersebut, lalu hitung rata-ratanya.
|
||||
$latestPerCat = $rows
|
||||
->where('bulan_iso', $month)
|
||||
->groupBy('cat_id')
|
||||
->map(fn ($catRows) => $catRows->last());
|
||||
|
||||
$value = $latestPerCat->avg('berat');
|
||||
return $value === null ? null : round((float) $value, 2);
|
||||
})->values(),
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
|
||||
// Ringkasan berdasarkan berat terakhir setiap kucing pada rentang periode.
|
||||
$latestPerCatInTrend = $weightTrendRows
|
||||
->groupBy('cat_id')
|
||||
->map(fn ($rows) => $rows->last());
|
||||
|
||||
$weightCategorySummary = collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($latestPerCatInTrend) {
|
||||
$rows = $latestPerCatInTrend->where('kategori_usia', $category);
|
||||
return [$category => [
|
||||
'cats' => $rows->count(),
|
||||
'average' => round((float) ($rows->avg('berat') ?? 0), 2),
|
||||
'min' => $rows->isEmpty() ? 0 : round((float) $rows->min('berat'), 2),
|
||||
'max' => $rows->isEmpty() ? 0 : round((float) $rows->max('berat'), 2),
|
||||
]];
|
||||
});
|
||||
|
||||
// KPI bulan berjalan dibanding bulan sebelumnya.
|
||||
$currentMonthKey = $now->format('Y-m');
|
||||
$previousMonthKey = $now->copy()->subMonth()->format('Y-m');
|
||||
|
||||
$latestByCatMonth = $weightTrendRows
|
||||
->groupBy(fn ($row) => $row->cat_id . '|' . $row->bulan_iso)
|
||||
->map(fn ($rows) => $rows->last());
|
||||
|
||||
$currentMonthRows = $latestByCatMonth->where('bulan_iso', $currentMonthKey)->values();
|
||||
$previousMonthRows = $latestByCatMonth->where('bulan_iso', $previousMonthKey)->values();
|
||||
|
||||
$percentChange = static function (float $current, float $previous): float {
|
||||
if ($previous <= 0) {
|
||||
return $current > 0 ? 100.0 : 0.0;
|
||||
}
|
||||
return round((($current - $previous) / $previous) * 100, 1);
|
||||
};
|
||||
|
||||
$weightKpis = [
|
||||
'weighed' => [
|
||||
'value' => $currentMonthRows->pluck('cat_id')->unique()->count(),
|
||||
'change' => $percentChange(
|
||||
(float) $currentMonthRows->pluck('cat_id')->unique()->count(),
|
||||
(float) $previousMonthRows->pluck('cat_id')->unique()->count()
|
||||
),
|
||||
],
|
||||
'categories' => collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($currentMonthRows, $previousMonthRows, $latestPerCatInTrend, $percentChange) {
|
||||
$currentRows = $currentMonthRows->where('kategori_usia', $category);
|
||||
$previousRows = $previousMonthRows->where('kategori_usia', $category);
|
||||
|
||||
// Jika bulan berjalan belum memiliki data, tetap tampilkan posisi berat terakhir
|
||||
// tanpa mengarang persentase perubahan.
|
||||
$currentAverage = $currentRows->isNotEmpty()
|
||||
? (float) $currentRows->avg('berat')
|
||||
: (float) ($latestPerCatInTrend->where('kategori_usia', $category)->avg('berat') ?? 0);
|
||||
$previousAverage = (float) ($previousRows->avg('berat') ?? 0);
|
||||
|
||||
return [$category => [
|
||||
'average' => round($currentAverage, 2),
|
||||
'change' => $currentRows->isNotEmpty() && $previousRows->isNotEmpty()
|
||||
? $percentChange($currentAverage, $previousAverage)
|
||||
: 0.0,
|
||||
'has_comparison' => $currentRows->isNotEmpty() && $previousRows->isNotEmpty(),
|
||||
]];
|
||||
}),
|
||||
];
|
||||
|
||||
// Grafik tren kategori terpilih: maksimal 6 kucing dengan data terlengkap.
|
||||
$selectedCategoryGroups = $weightTrendRows
|
||||
->where('kategori_usia', $weightTrendCategory)
|
||||
->groupBy('cat_id')
|
||||
->sort(function ($a, $b) {
|
||||
$countComparison = $b->count() <=> $a->count();
|
||||
if ($countComparison !== 0) {
|
||||
return $countComparison;
|
||||
}
|
||||
|
||||
return strcmp((string) $b->last()->tanggal_iso, (string) $a->last()->tanggal_iso);
|
||||
})
|
||||
->take(6);
|
||||
|
||||
$weightTrendLabels = $categoryMonthKeys->values();
|
||||
$weightTrendSeries = $selectedCategoryGroups
|
||||
->map(function ($rows) use ($categoryMonthKeys) {
|
||||
$first = $rows->first();
|
||||
$monthlyValues = $rows->groupBy('bulan_iso')->map(fn ($monthRows) => round((float) $monthRows->last()->berat, 2));
|
||||
|
||||
return [
|
||||
'cat_id' => (int) $first->cat_id,
|
||||
'name' => $first->nama_kucing,
|
||||
'category' => $first->kategori_usia,
|
||||
'data' => $categoryMonthKeys->map(fn ($month) => $monthlyValues->get($month))->values(),
|
||||
'latest' => round((float) $rows->last()->berat, 2),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
|
||||
// Tabel berat terbaru. Perubahan membandingkan dua catatan terakhir pada periode terpilih.
|
||||
$weightLatestTable = $weightTrendRows
|
||||
->groupBy('cat_id')
|
||||
->map(function ($rows) {
|
||||
$rows = $rows->sortBy('tanggal_timbang')->values();
|
||||
$latest = $rows->last();
|
||||
$previous = $rows->count() > 1 ? $rows->get($rows->count() - 2) : null;
|
||||
|
||||
return [
|
||||
'cat_id' => (int) $latest->cat_id,
|
||||
'name' => $latest->nama_kucing,
|
||||
'gender' => $latest->jenis_kelamin,
|
||||
'category' => $latest->kategori_usia,
|
||||
'photo' => $latest->foto,
|
||||
'latest' => round((float) $latest->berat, 2),
|
||||
'previous' => $previous ? round((float) $previous->berat, 2) : null,
|
||||
'change' => $previous ? round((float) $latest->berat - (float) $previous->berat, 2) : null,
|
||||
'date' => $latest->tanggal_iso,
|
||||
];
|
||||
})
|
||||
->when($weightTableCategory !== 'Semua', fn ($rows) => $rows->where('category', $weightTableCategory))
|
||||
->sortByDesc('date')
|
||||
->values();
|
||||
|
||||
// Inventory
|
||||
$allExpiringItems = collect();
|
||||
$lowStock = collect();
|
||||
|
||||
$inventoryTypes = [
|
||||
'medicines' => ['model' => Medicine::class, 'name' => 'nama_obat'],
|
||||
'foods' => ['model' => Food::class, 'name' => 'nama_makanan'],
|
||||
'supplies' => ['model' => Supply::class, 'name' => 'nama_perlengkapan'],
|
||||
];
|
||||
|
||||
foreach ($inventoryTypes as $type => $config) {
|
||||
$modelClass = $config['model'];
|
||||
$nameColumn = $config['name'];
|
||||
|
||||
$modelClass::where('qty', '<', 5)
|
||||
->select("$nameColumn as name", 'qty', 'unit')
|
||||
->get()
|
||||
->each(function ($item) use ($lowStock, $type) {
|
||||
$item->url = route("inventory.$type.index", ['search' => $item->name]);
|
||||
$lowStock->push($item);
|
||||
});
|
||||
|
||||
if (Schema::hasColumn((new $modelClass)->getTable(), 'expired_at')) {
|
||||
$modelClass::whereNotNull('expired_at')
|
||||
->where('expired_at', '<=', $now->copy()->addDays(30))
|
||||
->get()
|
||||
->each(function ($item) use ($allExpiringItems, $now, $type, $nameColumn) {
|
||||
$expiredDate = Carbon::parse($item->expired_at);
|
||||
$allExpiringItems->push((object) [
|
||||
'name' => $item->$nameColumn,
|
||||
'qty' => $item->qty,
|
||||
'unit' => $item->unit,
|
||||
'expired_at' => $item->expired_at,
|
||||
'url' => route("inventory.$type.index", ['search' => $item->$nameColumn]),
|
||||
'formatted_date' => $expiredDate->format('d M Y'),
|
||||
'days_left' => (int) $now->copy()->startOfDay()->diffInDays($expiredDate->copy()->startOfDay(), false),
|
||||
'is_expired' => $expiredDate->isPast(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$totalLowStock = $lowStock->count();
|
||||
$sortedItems = $allExpiringItems->sortBy('days_left');
|
||||
$perPage = 5;
|
||||
$currentPage = $request->input('expiry_page', 1);
|
||||
$offset = ($currentPage * $perPage) - $perPage;
|
||||
|
||||
$expiringItems = new LengthAwarePaginator(
|
||||
$sortedItems->slice($offset, $perPage)->values(),
|
||||
$sortedItems->count(),
|
||||
$perPage,
|
||||
$currentPage,
|
||||
[
|
||||
'path' => $request->url(),
|
||||
'query' => $request->query(),
|
||||
'pageName' => 'expiry_page',
|
||||
]
|
||||
);
|
||||
|
||||
$topDiseasesWeek = Treatment::select('diagnosa', DB::raw('count(*) as total'))
|
||||
->where('tanggal', '>=', $now->copy()->subWeek())
|
||||
->groupBy('diagnosa')
|
||||
->orderBy('total', 'desc')
|
||||
->get();
|
||||
|
||||
$topDiseasesMonth = Treatment::select('diagnosa', DB::raw('count(*) as total'))
|
||||
->where('tanggal', '>=', $now->copy()->subMonth())
|
||||
->groupBy('diagnosa')
|
||||
->orderBy('total', 'desc')
|
||||
->get();
|
||||
|
||||
$recentTreatments = Treatment::with(['cat'])->latest()->take(5)->get();
|
||||
|
||||
return view('pages.dashboard.index', compact(
|
||||
'stats',
|
||||
'upcomingBirths',
|
||||
'medicalAlerts',
|
||||
'lowStock',
|
||||
'totalLowStock',
|
||||
'chartData',
|
||||
'weightTrends',
|
||||
'recentTreatments'
|
||||
));
|
||||
'stats',
|
||||
'upcomingBirths',
|
||||
'overdueAlerts',
|
||||
'individualWeightData',
|
||||
'weightSummary',
|
||||
'weightTrendLabels',
|
||||
'weightTrendSeries',
|
||||
'categoryAverageTrend',
|
||||
'weightCategorySummary',
|
||||
'weightKpis',
|
||||
'weightLatestTable',
|
||||
'weightTableCategory',
|
||||
'weightTrendCategory',
|
||||
'weightTrendMonths',
|
||||
'lowStock',
|
||||
'totalLowStock',
|
||||
'expiringItems',
|
||||
'selectedMonth',
|
||||
'selectedYear',
|
||||
'selectedGender',
|
||||
'selectedStatus',
|
||||
'topDiseasesWeek',
|
||||
'topDiseasesMonth',
|
||||
'recentTreatments'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function ageCategory($birthDate, ?string $storedStatus, Carbon $referenceDate): string
|
||||
{
|
||||
if ($birthDate) {
|
||||
$birth = Carbon::parse($birthDate);
|
||||
$months = $birth->diffInMonths($referenceDate);
|
||||
|
||||
if ($months < 7) {
|
||||
return 'Kitten';
|
||||
}
|
||||
|
||||
if ($months < 12) {
|
||||
return 'Junior';
|
||||
}
|
||||
|
||||
return 'Dewasa';
|
||||
}
|
||||
|
||||
$status = strtolower(trim((string) $storedStatus));
|
||||
return match ($status) {
|
||||
'kitten' => 'Kitten',
|
||||
'junior' => 'Junior',
|
||||
'adult', 'dewasa' => 'Dewasa',
|
||||
default => 'Dewasa',
|
||||
};
|
||||
}
|
||||
|
||||
private function formatAge($birthDate, Carbon $referenceDate): string
|
||||
{
|
||||
if (!$birthDate) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$diff = Carbon::parse($birthDate)->diff($referenceDate);
|
||||
$parts = [];
|
||||
|
||||
if ($diff->y > 0) {
|
||||
$parts[] = $diff->y . ' th';
|
||||
}
|
||||
if ($diff->m > 0) {
|
||||
$parts[] = $diff->m . ' bln';
|
||||
}
|
||||
if ($diff->y === 0 && $diff->d > 0) {
|
||||
$parts[] = $diff->d . ' hr';
|
||||
}
|
||||
|
||||
return $parts ? implode(' ', $parts) : '0 hr';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class FoodController extends Controller
|
||||
{
|
||||
@@ -18,17 +19,68 @@ class FoodController extends Controller
|
||||
* Tampilkan daftar stok makanan.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Food::with('category');
|
||||
{
|
||||
// Proteksi Akses
|
||||
Gate::authorize('check-access', ['foods', 'read']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_makanan', 'like', '%' . $request->search . '%');
|
||||
$now = now();
|
||||
$query = Food::with('category');
|
||||
|
||||
// 1. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan makanan yang sudah kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
|
||||
$foods = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.foods.index', compact('foods'));
|
||||
}
|
||||
|
||||
// 2. SEARCH GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_makanan', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 3. SORTING DINAMIS (Menambahkan kembali expired_at)
|
||||
$allowedSorts = ['nama_makanan', 'qty', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_makanan';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$foods = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 4. STATISTIK (Komprehensif)
|
||||
$totalStock = Food::sum('qty');
|
||||
$lowStockCount = Food::where('qty', '<=', 5)->count();
|
||||
|
||||
// Hitung item yang expired (Wajib ada kolom expired_at di database)
|
||||
$expiredCount = Food::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$totalItems = Food::count();
|
||||
|
||||
return view('pages.inventory.foods.index', compact(
|
||||
'foods',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount',
|
||||
'totalItems'
|
||||
));
|
||||
}
|
||||
/**
|
||||
* Tampilkan form tambah.
|
||||
*/
|
||||
|
||||
@@ -3,15 +3,12 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class MedicalDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
// 1. Tren Penyakit Bulanan (Data 6 Bulan Terakhir)
|
||||
$monthlyTrends = Treatment::select(
|
||||
DB::raw('MONTHNAME(tanggal) as month'),
|
||||
DB::raw('COUNT(*) as total')
|
||||
@@ -21,20 +18,28 @@ class MedicalDashboardController extends Controller
|
||||
->orderBy(DB::raw('MONTH(tanggal)'))
|
||||
->get();
|
||||
|
||||
// 2. Daftar Obat Paling Sering Digunakan (Top 5)
|
||||
$topMedicines = Treatment::select('nama_obat', DB::raw('SUM(qty_used) as total_used'))
|
||||
->groupBy('nama_obat')
|
||||
// treatments memakai medicine_id. Nama obat diambil dari tabel medicines.
|
||||
$topObats = Treatment::query()
|
||||
->join('medicines', 'treatments.medicine_id', '=', 'medicines.id')
|
||||
->select(
|
||||
'medicines.id',
|
||||
'medicines.nama_obat',
|
||||
DB::raw('SUM(treatments.qty_used) as total_used')
|
||||
)
|
||||
->whereNotNull('treatments.medicine_id')
|
||||
->groupBy('medicines.id', 'medicines.nama_obat')
|
||||
->orderByDesc('total_used')
|
||||
->take(5)
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
// 3. Keluhan/Penyakit yang Paling Sering Muncul
|
||||
$topComplaints = Treatment::select('keluhan', DB::raw('COUNT(*) as total'))
|
||||
->whereNotNull('keluhan')
|
||||
->where('keluhan', '!=', '')
|
||||
->groupBy('keluhan')
|
||||
->orderByDesc('total')
|
||||
->take(5)
|
||||
->limit(5)
|
||||
->get();
|
||||
|
||||
return view('pages.dashboard.medical', compact('monthlyTrends', 'topMedicines', 'topComplaints'));
|
||||
return view('pages.dashboard.medical', compact('monthlyTrends', 'topObats', 'topComplaints'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Cat;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\MedicalRecord;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Exception;
|
||||
|
||||
class MedicalRecordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Simpan rekam medis individu (dari halaman show cat)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Tambahan Validasi
|
||||
$request->validate([
|
||||
'cat_id' => 'required|exists:cats,id',
|
||||
'type' => 'required|string',
|
||||
'date_given' => 'required|date',
|
||||
'next_schedule' => 'nullable|date|after_or_equal:date_given',
|
||||
'medicines' => 'nullable|array',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request) {
|
||||
$type = $request->type;
|
||||
$medicines = $request->input('medicines', []);
|
||||
|
||||
if ($type === 'Grooming') {
|
||||
$this->createRecord($request->cat_id, $type, 'Routine Grooming', null, 'SESSION', $request);
|
||||
} else {
|
||||
foreach ($medicines as $med) {
|
||||
if (empty($med['name'])) continue;
|
||||
|
||||
$doseValue = $this->parseFraction($med['dosage']);
|
||||
|
||||
// Pengecekan Stok Sebelum Potong
|
||||
$item = Medicine::where('nama_obat', $med['name'])->first();
|
||||
if ($item) {
|
||||
if ($item->qty < $doseValue) {
|
||||
throw new Exception("Stok obat '{$med['name']}' tidak mencukupi. Sisa: {$item->qty}");
|
||||
}
|
||||
$item->decrement('qty', $doseValue);
|
||||
}
|
||||
|
||||
$this->createRecord($request->cat_id, $type, $med['name'], $med['dosage'], $med['unit'], $request);
|
||||
}
|
||||
}
|
||||
|
||||
// Update pengingat di tabel cats
|
||||
$this->updateCatReminder($request->cat_id, $type, $request->next_schedule);
|
||||
|
||||
return back()->with('success', 'Medical Ledger berhasil dicatat dan jadwal otomatis diperbarui.');
|
||||
});
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal mencatat rekam medis: ' . $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form Bulk Treatment
|
||||
*/
|
||||
/**
|
||||
* Tampilkan form Bulk Treatment
|
||||
*/
|
||||
public function bulkCreate()
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
// Mengambil kucing yang Sehat (2) maupun yang sedang Treatment (1)
|
||||
$cats = Cat::whereIn('treatment_status', [1, 2])
|
||||
->orderBy('nama_kucing', 'asc')
|
||||
->get();
|
||||
|
||||
// Mengambil obat dengan stok yang valid (qty > 0)
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
|
||||
|
||||
return view('pages.cats.medical-bulk', compact('cats', 'availableMedicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan Bulk Treatment (Massal)
|
||||
*/
|
||||
public function bulkStore(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$request->validate([
|
||||
'cat_ids' => 'required|array|min:1',
|
||||
'type' => 'required',
|
||||
'date_given' => 'required|date',
|
||||
'next_schedule' => 'nullable|date|after_or_equal:date_given',
|
||||
]);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($request) {
|
||||
$catIds = $request->cat_ids;
|
||||
$medicines = $request->input('medicines', []);
|
||||
$totalCats = count($catIds);
|
||||
|
||||
foreach ($catIds as $catId) {
|
||||
if ($request->type === 'Grooming') {
|
||||
$this->createRecord($catId, $request->type, 'Routine Grooming', null, 'SESSION', $request);
|
||||
} else {
|
||||
foreach ($medicines as $med) {
|
||||
if (empty($med['name'])) continue;
|
||||
|
||||
$doseValue = $this->parseFraction($med['dosage']);
|
||||
|
||||
// Validasi stok untuk bulk (kebutuhan = dosis * jumlah kucing)
|
||||
$item = Medicine::where('nama_obat', $med['name'])->first();
|
||||
if ($item) {
|
||||
if ($item->qty < $doseValue) {
|
||||
throw new Exception("Stok '{$med['name']}' tidak cukup untuk pemrosesan massal.");
|
||||
}
|
||||
$item->decrement('qty', $doseValue);
|
||||
}
|
||||
|
||||
$this->createRecord($catId, $request->type, $med['name'], $med['dosage'], $med['unit'], $request);
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateCatReminder($catId, $request->type, $request->next_schedule);
|
||||
}
|
||||
|
||||
return redirect()->route('cats.index')->with('success', "Bulk Treatment berhasil diterapkan pada $totalCats kucing.");
|
||||
});
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal memproses bulk treatment: ' . $e->getMessage())->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Simpan data ke tabel medical_records
|
||||
*/
|
||||
/**
|
||||
* Helper: Simpan data ke tabel medical_records
|
||||
*/
|
||||
private function createRecord($catId, $type, $name, $dosage, $unit, $request)
|
||||
{
|
||||
return MedicalRecord::create([
|
||||
'cat_id' => $catId,
|
||||
'user_id' => auth()->id(), // Ini adalah kolom yang benar sesuai migration kita
|
||||
'type' => $type,
|
||||
'treatment_name' => $name,
|
||||
'dosage' => $this->parseFraction($dosage),
|
||||
'unit' => $unit,
|
||||
'date_given' => $request->date_given,
|
||||
'next_schedule' => $request->next_schedule,
|
||||
'notes' => $request->notes
|
||||
// HAPUS baris 'performed_by' di sini
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Update kolom jadwal di tabel cats
|
||||
*/
|
||||
private function updateCatReminder($catId, $type, $nextDate)
|
||||
{
|
||||
if (!$nextDate) return;
|
||||
|
||||
$mapping = [
|
||||
'Vaccine' => 'jadwal_vaksin',
|
||||
'Deworming' => 'jadwal_obat_cacing',
|
||||
'Flea Treatment' => 'jadwal_obat_kutu',
|
||||
'Grooming' => 'jadwal_grooming',
|
||||
];
|
||||
|
||||
if (isset($mapping[$type])) {
|
||||
Cat::where('id', $catId)->update([$mapping[$type] => $nextDate]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Konversi pecahan (1/2) ke desimal (0.5)
|
||||
*/
|
||||
private function parseFraction($fraction)
|
||||
{
|
||||
if (is_null($fraction)) return 0;
|
||||
|
||||
$fraction = str_replace(' ', '', $fraction); // Hapus spasi jika ada
|
||||
|
||||
if (str_contains($fraction, '/')) {
|
||||
list($numerator, $denominator) = explode('/', $fraction);
|
||||
return $denominator > 0 ? (float)$numerator / (float)$denominator : 0;
|
||||
}
|
||||
return (float)$fraction;
|
||||
}
|
||||
|
||||
public function create(Cat $cat)
|
||||
{
|
||||
Gate::authorize('check-access', ['cats', 'update']);
|
||||
|
||||
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
|
||||
|
||||
|
||||
return view('pages.cats.medical-ledger-create', compact('cat', 'availableMedicines'));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use App\Imports\MedicineImport;
|
||||
use App\Exports\MedicineTemplateExport;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class MedicineController extends Controller
|
||||
{
|
||||
@@ -20,18 +21,74 @@ class MedicineController extends Controller
|
||||
* Tampilkan daftar obat-obatan (Index).
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Medicine::query();
|
||||
{
|
||||
// Proteksi Akses
|
||||
Gate::authorize('check-access', ['medicines', 'read']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_obat', 'like', '%' . $request->search . '%');
|
||||
$now = now();
|
||||
$query = Medicine::query();
|
||||
|
||||
// 1. FILTER KHUSUS (DARI DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan yang sudah lewat tanggal kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang akan kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis (<= 5)
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
|
||||
$medicines = $query->latest()->paginate(10);
|
||||
|
||||
return view('pages.inventory.medicines.index', compact('medicines'));
|
||||
}
|
||||
|
||||
// 2. SEARCH GLOBAL
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_obat', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 3. FILTER KATEGORI (Opsional jika ada dropdown di UI)
|
||||
if ($request->filled('category')) {
|
||||
$query->where('kategori', $request->category);
|
||||
}
|
||||
|
||||
// 4. SORTING DINAMIS
|
||||
// Gunakan 'expired_at' sebagai default sorting jika dari Dashboard 'expired'
|
||||
$allowedSorts = ['nama_obat', 'kategori', 'qty', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_obat';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$medicines = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK BOX (Selalu hitung total keseluruhan, bukan hasil filter)
|
||||
$totalStock = Medicine::sum('qty');
|
||||
$lowStockCount = Medicine::where('qty', '<=', 5)->count();
|
||||
|
||||
// Menghitung barang yang sudah expired (merah)
|
||||
$expiredCount = Medicine::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$categories = Medicine::distinct()->whereNotNull('kategori')->pluck('kategori');
|
||||
|
||||
return view('pages.inventory.medicines.index', compact(
|
||||
'medicines',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount', // Tambahan statistik baru
|
||||
'categories'
|
||||
));
|
||||
}
|
||||
/**
|
||||
* Tampilkan form tambah obat.
|
||||
*/
|
||||
|
||||
@@ -74,17 +74,16 @@ class ReportController extends Controller
|
||||
|
||||
protected function applyModuleFilters($query, $request, $module)
|
||||
{
|
||||
$status = $request->status;
|
||||
$status = $request->status;
|
||||
|
||||
// TAMBAHAN: Logika Filter untuk Modul Cats (Sehat/Sakit)
|
||||
// Ini yang membuat filter status di populasi kucing bekerja
|
||||
// PERBAIKAN 1: Gunakan $request->filled('status') sesuai dengan <select name="status"> di Blade
|
||||
if ($module === 'cats' && $request->filled('status')) {
|
||||
match($status) {
|
||||
'sakit' => $query->whereNotNull('riwayat_sakit_terakhir')->where('riwayat_sakit_terakhir', '!=', ''),
|
||||
'sehat' => $query->where(function($q) {
|
||||
$q->whereNull('riwayat_sakit_terakhir')
|
||||
->orWhere('riwayat_sakit_terakhir', '');
|
||||
}),
|
||||
|
||||
// PERBAIKAN 2: Tangkap semua kemungkinan value (Bahasa Indonesia, Inggris, maupun angka)
|
||||
match(strtolower($status)) {
|
||||
'sakit', 'treatment', '1' => $query->where('treatment_status', 1),
|
||||
'sehat', 'healthy', '2' => $query->where('treatment_status', 2),
|
||||
default => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Role;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
@@ -13,6 +14,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'read']);
|
||||
$roles = Role::all();
|
||||
// Menambahkan prefix 'pages.' sesuai struktur folder Anda
|
||||
return view('pages.admin.roles.index', compact('roles'));
|
||||
@@ -20,12 +22,14 @@ class RoleController extends Controller
|
||||
|
||||
public function create()
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'create']);
|
||||
// Mengarahkan ke folder pages sesuai standarisasi Anda
|
||||
return view('pages.admin.roles.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'create']);
|
||||
$request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'slug' => 'required|string|max:255|unique:roles,slug',
|
||||
@@ -53,6 +57,7 @@ class RoleController extends Controller
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'update']);
|
||||
$role = Role::with('permissions')->findOrFail($id);
|
||||
|
||||
/**
|
||||
@@ -94,6 +99,7 @@ class RoleController extends Controller
|
||||
*/
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
Gate::authorize('check-access', ['roles', 'update']);
|
||||
$role = Role::findOrFail($id);
|
||||
|
||||
DB::transaction(function () use ($request, $role) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Intervention\Image\Laravel\Facades\Image;
|
||||
|
||||
class SettingController extends Controller
|
||||
@@ -28,6 +29,7 @@ class SettingController extends Controller
|
||||
|
||||
public function index()
|
||||
{
|
||||
Gate::authorize('check-access', ['settings', 'read']);
|
||||
$settings = Setting::pluck('value', 'key')->all();
|
||||
|
||||
// Tambahkan default jika version belum ada
|
||||
@@ -49,6 +51,7 @@ class SettingController extends Controller
|
||||
|
||||
public function update(Request $request)
|
||||
{
|
||||
Gate::authorize('check-access', ['settings', 'update']);
|
||||
$request->validate([
|
||||
'app_logo' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
|
||||
'app_favicon' => 'nullable|image|mimes:png,jpg,ico|max:512',
|
||||
|
||||
@@ -9,6 +9,8 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SupplyController extends Controller
|
||||
{
|
||||
@@ -17,18 +19,74 @@ class SupplyController extends Controller
|
||||
/**
|
||||
* Tampilkan daftar perlengkapan.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$query = Supply::with('category');
|
||||
public function index(Request $request): View
|
||||
{
|
||||
// 1. Proteksi Akses (Sesuai Matriks Role Management)
|
||||
Gate::authorize('check-access', ['supplies', 'read']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$query->where('nama_perlengkapan', 'like', '%' . $request->search . '%');
|
||||
$now = now();
|
||||
$query = Supply::with('category');
|
||||
|
||||
// 2. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
|
||||
if ($request->filled('filter')) {
|
||||
$filter = $request->get('filter');
|
||||
|
||||
if ($filter === 'expired') {
|
||||
// Menampilkan perlengkapan yang sudah kadaluwarsa
|
||||
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
|
||||
} elseif ($filter === 'expiring_soon') {
|
||||
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
|
||||
$query->whereNotNull('expired_at')
|
||||
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
|
||||
} elseif ($filter === 'low_stock') {
|
||||
// Menampilkan stok kritis
|
||||
$query->where('qty', '<=', 5);
|
||||
}
|
||||
|
||||
$supplies = $query->latest()->paginate(10);
|
||||
return view('pages.inventory.supplies.index', compact('supplies'));
|
||||
}
|
||||
|
||||
// 3. SEARCH GLOBAL (Mencari di Nama, Kategori, dan Unit)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function($q) use ($search) {
|
||||
$q->where('nama_perlengkapan', 'like', "%{$search}%")
|
||||
->orWhere('kategori', 'like', "%{$search}%")
|
||||
->orWhere('unit', 'like', "%{$search}%")
|
||||
->orWhereHas('category', function($sub) use ($search) {
|
||||
$sub->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. LOGIKA SORTING DINAMIS (Menambahkan expired_at dalam daftar sort)
|
||||
$allowedSorts = ['nama_perlengkapan', 'qty', 'kategori', 'expired_at', 'created_at'];
|
||||
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_perlengkapan';
|
||||
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
// Eksekusi Query dengan Pagination
|
||||
$supplies = $query->orderBy($sort, $direction)
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
// 5. STATISTIK RINGKAS (Komprehensif)
|
||||
$totalStock = Supply::sum('qty');
|
||||
$lowStockCount = Supply::where('qty', '<=', 5)->count();
|
||||
|
||||
// Hitung item yang expired (Wajib ada kolom expired_at di database)
|
||||
$expiredCount = Supply::whereNotNull('expired_at')
|
||||
->where('expired_at', '<', $now)
|
||||
->count();
|
||||
|
||||
$totalItems = Supply::count();
|
||||
|
||||
return view('pages.inventory.supplies.index', compact(
|
||||
'supplies',
|
||||
'totalStock',
|
||||
'lowStockCount',
|
||||
'expiredCount', // Variabel statistik baru
|
||||
'totalItems'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tampilkan form tambah.
|
||||
*/
|
||||
@@ -103,24 +161,41 @@ class SupplyController extends Controller
|
||||
/**
|
||||
* Hapus data perlengkapan.
|
||||
*/
|
||||
public function destroy(Supply $supply): RedirectResponse
|
||||
{
|
||||
if (auth()->user()->role !== 'admin') {
|
||||
abort(403, 'Akses Ditolak.');
|
||||
}
|
||||
public function destroy(Supply $supply): RedirectResponse
|
||||
{
|
||||
// 1. OTORISASI DINAMIS (Inilah perbaikan untuk akses Supervisor)
|
||||
// Menggunakan Gate 'check-access' agar sinkron dengan Role Management Anda
|
||||
Gate::authorize('check-access', ['supplies', 'delete']);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$supplyName = $supply->nama_perlengkapan;
|
||||
|
||||
if ($supply->foto_produk) {
|
||||
// 2. PEMBERSIHAN FILE (STORAGE)
|
||||
if ($supply->foto_produk && Storage::disk('public')->exists($supply->foto_produk)) {
|
||||
Storage::disk('public')->delete($supply->foto_produk);
|
||||
}
|
||||
|
||||
// PENTING: Log dilakukan SEBELUM delete agar ID tidak null
|
||||
$this->logAction('DELETED_SUPPLY', $supply, "Menghapus perlengkapan: {$supplyName}");
|
||||
// 3. AUDIT TRAIL
|
||||
// Dicatat sebelum delete agar konteks data $supply masih tersedia secara lengkap
|
||||
if (method_exists($this, 'logAction')) {
|
||||
$this->logAction('DELETE', 'Inventory', "Menghapus perlengkapan: {$supplyName}");
|
||||
}
|
||||
|
||||
// 4. EKSEKUSI HAPUS
|
||||
$supply->delete();
|
||||
|
||||
// FIX: Menggunakan .index
|
||||
return redirect()->route('inventory.supplies.index')->with('success', 'Data perlengkapan berhasil dihapus.');
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('inventory.supplies.index')
|
||||
->with('success', "Perlengkapan '{$supplyName}' berhasil dihapus dari sistem.");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
|
||||
// Reporting error jika terjadi kegagalan sistem
|
||||
return back()->with('error', 'Gagal menghapus data. Silakan coba lagi atau hubungi IT.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Cat;
|
||||
use App\Models\Treatment;
|
||||
use App\Models\Medicine;
|
||||
use App\Models\TreatmentDetail; // Menambahkan model Detail
|
||||
use App\Traits\LogsActivity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -18,167 +19,314 @@ class TreatmentController extends Controller
|
||||
use LogsActivity;
|
||||
|
||||
/**
|
||||
* Menampilkan daftar rekam medis dengan optimasi Eager Loading.
|
||||
* Menampilkan daftar rekam medis dengan optimasi Eager Loading & Filtering.
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$treatments = Treatment::with([
|
||||
'cat:id,nama_kucing,foto',
|
||||
'medicine:id,nama_obat,unit',
|
||||
'user:id,name'
|
||||
])
|
||||
->latest('tanggal')
|
||||
->paginate(15);
|
||||
public function index(Request $request): View|\Symfony\Component\HttpFoundation\BinaryFileResponse
|
||||
{
|
||||
// 1. QUERY DASAR DENGAN OPTIMASI EAGER LOADING
|
||||
$query = Treatment::with([
|
||||
'cat:id,nama_kucing,foto,treatment_status',
|
||||
'details.medicine:id,nama_obat,unit',
|
||||
'user:id,name'
|
||||
])
|
||||
->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', 1);
|
||||
});
|
||||
|
||||
return view('pages.treatments.index', compact('treatments'));
|
||||
// 2. LOGIKA FILTERING DINAMIS
|
||||
if ($request->filled('search')) {
|
||||
$query->whereHas('cat', function($q) use ($request) {
|
||||
$q->where('nama_kucing', 'like', '%' . $request->search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('pic_id')) {
|
||||
$query->where('user_id', $request->pic_id);
|
||||
}
|
||||
|
||||
if ($request->filled('start_date') && $request->filled('end_date')) {
|
||||
$query->whereBetween('tanggal', [$request->start_date, $request->end_date]);
|
||||
}
|
||||
|
||||
// 3. LOGIKA EXPORT (Pastikan blok IF ditutup dengan benar)
|
||||
if ($request->filled('export')) {
|
||||
$exportData = new \App\Exports\TreatmentExport($request);
|
||||
$fileName = 'Report_Active_Treatment_' . now()->format('Ymd_His');
|
||||
|
||||
if ($request->export === 'excel') {
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.xlsx");
|
||||
}
|
||||
|
||||
if ($request->export === 'pdf') {
|
||||
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.pdf", \Maatwebsite\Excel\Excel::DOMPDF);
|
||||
}
|
||||
} // <--- Pastikan kurung ini menutup blok export, bukan menutup fungsi index
|
||||
|
||||
// 4. EKSEKUSI DATA UNTUK VIEW (Line 67)
|
||||
$treatments = $query->latest('tanggal')->paginate(20)->withQueryString();
|
||||
|
||||
$pics = \App\Models\User::select('id', 'name')->orderBy('name')->get();
|
||||
|
||||
return view('pages.treatments.index', compact('treatments', 'pics'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Form tambah rekam medis.
|
||||
*/
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$selectedCat = $request->filled('cat_id') ? Cat::findOrFail($request->cat_id) : null;
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::where('qty', '>', 0)->get();
|
||||
$selectedCat = null;
|
||||
if ($request->filled('cat_id')) {
|
||||
$selectedCat = Cat::select('id', 'nama_kucing', 'ras')->findOrFail($request->cat_id);
|
||||
$cats = collect([$selectedCat]);
|
||||
} else {
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
}
|
||||
|
||||
// Menampilkan semua obat (termasuk stok 0 untuk kebutuhan edit/view,
|
||||
// namun di filter di frontend untuk input baru)
|
||||
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
|
||||
|
||||
return view('pages.treatments.create', compact('selectedCat', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Simpan rekam medis baru & potong stok (mendukung desimal).
|
||||
* Simpan rekam medis baru & potong stok (Mendukung Multi-Obat).
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||
{
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
$fotoPath = null;
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, &$validated) {
|
||||
$medicine = Medicine::findOrFail($request->medicine_id);
|
||||
|
||||
// Validasi kecukupan stok desimal
|
||||
if ($medicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty} {$medicine->unit}).");
|
||||
}
|
||||
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// Otomatisasi data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $medicine->nama_obat;
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($request, $fotoPath) {
|
||||
|
||||
$treatment = Treatment::create($validated);
|
||||
$userId = auth()->id() ?? 1;
|
||||
$firstTreatment = null;
|
||||
|
||||
// Potong stok (decrement mendukung nilai float/decimal)
|
||||
$medicine->decrement('qty', $request->qty_used);
|
||||
if ($request->filled('items')) {
|
||||
foreach ($request->items as $item) {
|
||||
$medicine = \App\Models\Medicine::findOrFail($item['medicine_id']);
|
||||
|
||||
$this->logAction('TREATED', $treatment, "Mencatat pengobatan oleh " . auth()->user()->name);
|
||||
if ($medicine->qty < $item['qty_used']) {
|
||||
throw new \Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty}).");
|
||||
}
|
||||
|
||||
$treatment = \App\Models\Treatment::create([
|
||||
'cat_id' => $request->cat_id,
|
||||
'user_id' => $userId,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
|
||||
// Langsung masukkan diagnosa tanpa embel-embel
|
||||
'diagnosa' => $request->diagnosa,
|
||||
|
||||
'foto_bukti' => $fotoPath,
|
||||
'status_perawatan' => 1,
|
||||
'medicine_id' => $item['medicine_id'],
|
||||
'qty_used' => $item['qty_used'],
|
||||
]);
|
||||
|
||||
if (!$firstTreatment) {
|
||||
$firstTreatment = $treatment;
|
||||
}
|
||||
|
||||
$medicine->decrement('qty', $item['qty_used']);
|
||||
}
|
||||
}
|
||||
|
||||
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
|
||||
|
||||
if ($firstTreatment) {
|
||||
$this->logAction('TREATED', $firstTreatment, "Mencatat pengobatan klinis untuk " . $firstTreatment->cat->nama_kucing);
|
||||
}
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis klinis berhasil dicatat dan stok dipotong otomatis.');
|
||||
});
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis berhasil dicatat dengan presisi.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
if (isset($validated['foto_bukti'])) {
|
||||
Storage::disk('public')->delete($validated['foto_bukti']);
|
||||
} catch (\Exception $e) {
|
||||
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
|
||||
}
|
||||
return back()->withInput()->with('error', 'Gagal menyimpan: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Menampilkan detail spesifik satu rekam medis.
|
||||
*/
|
||||
public function show(Treatment $treatment): View
|
||||
{
|
||||
// Load details untuk menampilkan list obat
|
||||
$treatment->load(['cat', 'medicine', 'user', 'details.medicine']);
|
||||
return view('pages.treatments.show', compact('treatment'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Form edit rekam medis.
|
||||
*/
|
||||
public function edit(Treatment $treatment): View
|
||||
{
|
||||
$treatment->load('details');
|
||||
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
|
||||
$medicines = Medicine::all();
|
||||
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
|
||||
|
||||
return view('pages.treatments.edit', compact('treatment', 'cats', 'medicines'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update rekam medis & sinkronisasi ulang stok desimal.
|
||||
* Update rekam medis & sinkronisasi ulang stok desimal (Multi-Obat).
|
||||
*/
|
||||
public function update(Request $request, Treatment $treatment): RedirectResponse
|
||||
public function update(Request $request, Treatment $treatment)
|
||||
{
|
||||
// 1. Validasi Input
|
||||
$validated = $this->validateTreatment($request);
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($request, $treatment, &$validated) {
|
||||
// 1. Kembalikan stok lama (Desimal Revert)
|
||||
$oldMedicine = Medicine::find($treatment->medicine_id);
|
||||
return DB::transaction(function () use ($request, $treatment) {
|
||||
|
||||
// 2. Kelola Foto Bukti (Hapus yang lama jika ada foto baru)
|
||||
$fotoPath = $treatment->foto_bukti;
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
|
||||
}
|
||||
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
}
|
||||
|
||||
// 3. KEMBALIKAN STOK OBAT LAMA (Penting agar stok tidak bocor)
|
||||
$oldMedicine = \App\Models\Medicine::find($treatment->medicine_id);
|
||||
if ($oldMedicine) {
|
||||
$oldMedicine->increment('qty', $treatment->qty_used);
|
||||
}
|
||||
|
||||
// 2. Potong stok baru
|
||||
$newMedicine = Medicine::findOrFail($request->medicine_id);
|
||||
if ($newMedicine->qty < $request->qty_used) {
|
||||
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi untuk perubahan ini.");
|
||||
}
|
||||
$newMedicine->decrement('qty', $request->qty_used);
|
||||
// 4. Ambil data obat PERTAMA dari form untuk memperbarui data saat ini (ID 9)
|
||||
$firstItem = $request->items[0];
|
||||
$newMedicine = \App\Models\Medicine::findOrFail($firstItem['medicine_id']);
|
||||
|
||||
// 3. Kelola Foto
|
||||
if ($request->hasFile('foto_bukti')) {
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
|
||||
if ($newMedicine->qty < $firstItem['qty_used']) {
|
||||
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi (Sisa: {$newMedicine->qty}).");
|
||||
}
|
||||
|
||||
// 4. Update data sistem
|
||||
$validated['user_id'] = auth()->id();
|
||||
$validated['nama_obat'] = $newMedicine->nama_obat;
|
||||
$treatment->update($validated);
|
||||
// Format Diagnosa (Gabungan Diagnosa Utama + Dosis/Ket)
|
||||
$catatanDosis = isset($firstItem['dosis']) ? " | Dosis: " . $firstItem['dosis'] : "";
|
||||
$catatanKet = isset($firstItem['keterangan']) && $firstItem['keterangan'] != '' ? " (" . $firstItem['keterangan'] . ")" : "";
|
||||
$diagnosaFinal = $request->diagnosa . $catatanDosis . $catatanKet;
|
||||
|
||||
$this->logAction('UPDATED_TREATMENT', $treatment, "Diperbarui oleh " . auth()->user()->name);
|
||||
// EKSEKUSI UPDATE REKAM MEDIS (Tanpa kolom nama_obat!)
|
||||
$treatment->update([
|
||||
'cat_id' => $request->cat_id,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
'diagnosa' => $diagnosaFinal,
|
||||
'foto_bukti' => $fotoPath,
|
||||
'medicine_id' => $firstItem['medicine_id'],
|
||||
'qty_used' => $firstItem['qty_used'],
|
||||
]);
|
||||
|
||||
// Potong stok obat pertama
|
||||
$newMedicine->decrement('qty', $firstItem['qty_used']);
|
||||
|
||||
// 5. JIKA STAF MENAMBAHKAN OBAT EKSTRA DI HALAMAN EDIT
|
||||
// Buat sebagai record baru agar data tidak hilang
|
||||
if (count($request->items) > 1) {
|
||||
for ($i = 1; $i < count($request->items); $i++) {
|
||||
$extraItem = $request->items[$i];
|
||||
$extraMed = \App\Models\Medicine::findOrFail($extraItem['medicine_id']);
|
||||
|
||||
if ($extraMed->qty < $extraItem['qty_used']) {
|
||||
throw new Exception("Stok {$extraMed->nama_obat} tidak mencukupi.");
|
||||
}
|
||||
|
||||
$catatanDosisEx = isset($extraItem['dosis']) ? " | Dosis: " . $extraItem['dosis'] : "";
|
||||
$catatanKetEx = isset($extraItem['keterangan']) && $extraItem['keterangan'] != '' ? " (" . $extraItem['keterangan'] . ")" : "";
|
||||
$diagnosaFinalEx = $request->diagnosa . $catatanDosisEx . $catatanKetEx;
|
||||
|
||||
\App\Models\Treatment::create([
|
||||
'cat_id' => $request->cat_id,
|
||||
'user_id' => auth()->id() ?? 1,
|
||||
'tanggal' => $request->tanggal,
|
||||
'keluhan' => $request->keluhan,
|
||||
'diagnosa' => $diagnosaFinalEx,
|
||||
'foto_bukti' => $fotoPath,
|
||||
'status_perawatan' => $treatment->status_perawatan, // Ikuti status (Misal: 1 / Aktif)
|
||||
'medicine_id' => $extraItem['medicine_id'],
|
||||
'qty_used' => $extraItem['qty_used'],
|
||||
]);
|
||||
|
||||
$extraMed->decrement('qty', $extraItem['qty_used']);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Sinkronisasi Kucing & Log Activity
|
||||
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
|
||||
$this->logAction('UPDATED', $treatment, "Memperbarui rekam medis klinis untuk " . $treatment->cat->nama_kucing);
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)
|
||||
->with('success', 'Rekam medis berhasil diperbarui dan stok obat disesuaikan ulang secara otomatis!');
|
||||
});
|
||||
|
||||
return redirect()->route('cats.show', $request->cat_id)->with('success', 'Rekam medis & inventori berhasil di-sinkronisasi.');
|
||||
|
||||
} catch (Exception $e) {
|
||||
return back()->withInput()->with('error', 'Gagal memperbarui: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hapus rekam medis & kembalikan stok.
|
||||
* Hapus rekam medis & kembalikan semua stok obat terkait.
|
||||
*/
|
||||
public function destroy(Treatment $treatment): RedirectResponse
|
||||
{
|
||||
try {
|
||||
DB::transaction(function () use ($treatment) {
|
||||
if ($treatment->medicine_id) {
|
||||
Medicine::where('id', $treatment->medicine_id)->increment('qty', $treatment->qty_used);
|
||||
// Kembalikan semua stok dari tabel detail
|
||||
foreach ($treatment->details as $detail) {
|
||||
Medicine::where('id', $detail->medicine_id)->increment('qty', $detail->qty_used);
|
||||
}
|
||||
|
||||
if ($treatment->foto_bukti) {
|
||||
Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
}
|
||||
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
|
||||
|
||||
$this->logAction('DELETED_TREATMENT', $treatment, "Menghapus catatan medis kucing: {$treatment->cat->nama_kucing}");
|
||||
|
||||
// Delete otomatis menghapus details karena cascade di migration
|
||||
$treatment->delete();
|
||||
});
|
||||
|
||||
return back()->with('success', 'Data rekam medis dihapus dan stok dikembalikan.');
|
||||
return back()->with('success', 'Data rekam medis dihapus dan semua stok obat dikembalikan.');
|
||||
} catch (Exception $e) {
|
||||
return back()->with('error', 'Gagal menghapus data.');
|
||||
return back()->with('error', 'Gagal menghapus data: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper Validasi: Terpusat untuk Clean Code & Mendukung Desimal.
|
||||
*/
|
||||
private function validateTreatment(Request $request)
|
||||
private function validateTreatment(Request $request)
|
||||
{
|
||||
return $request->validate([
|
||||
'cat_id' => 'required|exists:cats,id',
|
||||
'medicine_id' => 'required|exists:medicines,id',
|
||||
// 'numeric' mengizinkan input desimal (koma)
|
||||
'qty_used' => 'required|numeric|min:0.01',
|
||||
'tanggal' => 'required|date',
|
||||
'keluhan' => 'required|string|max:255',
|
||||
'diagnosa' => 'required|string',
|
||||
'dosis' => 'required|string',
|
||||
'keterangan' => 'nullable|string',
|
||||
'foto_bukti' => 'nullable|image|max:2048',
|
||||
|
||||
// Validasi untuk Single Medicine
|
||||
'medicine_id' => 'required_without:items|nullable|exists:medicines,id',
|
||||
'qty_used' => 'required_without:items|nullable|numeric|min:0',
|
||||
|
||||
// Validasi untuk Multi Medicine
|
||||
// FIX: Wajibkan array items jika mode single (medicine_id) kosong
|
||||
'items' => 'required_without:medicine_id|nullable|array',
|
||||
'items.*.medicine_id' => 'required_with:items|exists:medicines,id',
|
||||
'items.*.qty_used' => 'required_with:items|numeric|min:0.01',
|
||||
|
||||
], [
|
||||
// Pesan Error Custom (Opsional, agar lebih mudah di-debug)
|
||||
'items.required_without' => 'Anda harus memasukkan minimal 1 obat.',
|
||||
'items.*.medicine_id.required_with' => 'Ada pilihan obat yang masih kosong di daftar.',
|
||||
'items.*.qty_used.required_with' => 'Jumlah (Qty) obat harus diisi.',
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,12 @@ class UserController extends Controller
|
||||
Gate::authorize('check-access', ['users', 'read']);
|
||||
|
||||
$users = User::query()
|
||||
->with('role')
|
||||
->with('roleModel')
|
||||
->when($request->search, function ($query, $search) {
|
||||
$query->where('name', 'like', "%{$search}%")
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
->paginate(10);
|
||||
@@ -194,7 +196,6 @@ class UserController extends Controller
|
||||
public function clearLogs()
|
||||
{
|
||||
Gate::authorize('check-access', ['logs', 'delete']);
|
||||
ActivityLog::truncate();
|
||||
return back()->with('success', 'Seluruh riwayat aktivitas telah dibersihkan.');
|
||||
return back()->with('error', 'Penghapusan massal audit trail dinonaktifkan untuk menjaga jejak perubahan data. Gunakan kebijakan retensi/arsip bila diperlukan.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnLoginController
|
||||
{
|
||||
/**
|
||||
* Returns the challenge to assertion.
|
||||
*/
|
||||
public function options(AssertionRequest $request): Responsable
|
||||
{
|
||||
return $request->toVerify($request->validate(['email' => 'sometimes|email|string']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user in.
|
||||
*/
|
||||
public function login(AssertedRequest $request): Response
|
||||
{
|
||||
return response()->noContent($request->login() ? 204 : 422);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\WebAuthn;
|
||||
|
||||
use Illuminate\Contracts\Support\Responsable;
|
||||
use Illuminate\Http\Response;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
|
||||
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
|
||||
|
||||
use function response;
|
||||
|
||||
class WebAuthnRegisterController
|
||||
{
|
||||
/**
|
||||
* Returns a challenge to be verified by the user device.
|
||||
*/
|
||||
public function options(AttestationRequest $request): Responsable
|
||||
{
|
||||
return $request
|
||||
->fastRegistration()
|
||||
// ->userless()
|
||||
// ->allowDuplicates()
|
||||
->toCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a device for further WebAuthn authentication.
|
||||
*/
|
||||
public function register(AttestedRequest $request): Response
|
||||
{
|
||||
$request->save();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class AdminMiddleware
|
||||
|
||||
// 2. Ambil slug role (Mendukung sistem baru yang kita buat)
|
||||
// Kita gunakan optional() agar jika user tidak punya role, aplikasi tidak crash.
|
||||
$roleSlug = optional(auth()->user()->role)->slug ?? auth()->user()->role;
|
||||
$roleSlug = auth()->user()->roleModel?->slug ?? auth()->user()->getRawOriginal('role');
|
||||
|
||||
// 3. Logika Existing Anda (Tetap Dipertahankan)
|
||||
if ($roleSlug !== 'admin') {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiToken;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AuthenticateApiToken
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$plain = $request->bearerToken();
|
||||
if (!$plain) {
|
||||
return response()->json(['message' => 'Token akses tidak ditemukan.'], 401);
|
||||
}
|
||||
|
||||
$token = ApiToken::with('user')->where('token_hash', hash('sha256', $plain))->first();
|
||||
if (!$token || !$token->user || ($token->expires_at && $token->expires_at->isPast())) {
|
||||
return response()->json(['message' => 'Token akses tidak valid atau sudah kedaluwarsa.'], 401);
|
||||
}
|
||||
|
||||
$token->forceFill(['last_used_at' => now()])->saveQuietly();
|
||||
$request->setUserResolver(fn () => $token->user);
|
||||
$request->attributes->set('api_token', $token);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Imports;
|
||||
|
||||
use App\Models\WeightHistory;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\ToCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithStartRow;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
|
||||
class CatWeightImport implements ToCollection, WithStartRow
|
||||
{
|
||||
// Tambahkan variabel untuk menghitung data yang sukses
|
||||
private int $importedCount = 0;
|
||||
|
||||
public function startRow(): int
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
public function collection(Collection $rows)
|
||||
{
|
||||
foreach ($rows as $row) {
|
||||
$catId = $row[0] ?? null;
|
||||
$berat = $row[2] ?? null;
|
||||
$tanggalExcel = $row[3] ?? null;
|
||||
|
||||
if (empty($catId) || empty($berat) || empty($tanggalExcel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedDate = null;
|
||||
try {
|
||||
if (is_numeric($tanggalExcel)) {
|
||||
$parsedDate = Date::excelToDateTimeObject($tanggalExcel);
|
||||
} else {
|
||||
$tanggalExcel = str_ireplace(
|
||||
['januari', 'februari', 'maret', 'april', 'mei', 'juni', 'juli', 'agustus', 'september', 'oktober', 'november', 'desember'],
|
||||
['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december'],
|
||||
$tanggalExcel
|
||||
);
|
||||
$parsedDate = Carbon::parse($tanggalExcel);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$carbonDate = Carbon::instance($parsedDate);
|
||||
$dateOnly = $carbonDate->format('Y-m-d');
|
||||
$timeOnly = $carbonDate->format('H:i:s');
|
||||
|
||||
$existingRecord = WeightHistory::where('cat_id', $catId)
|
||||
->whereDate('tanggal_timbang', $dateOnly)
|
||||
->first();
|
||||
|
||||
if ($existingRecord) {
|
||||
$finalTime = ($timeOnly === '00:00:00') ? Carbon::parse($existingRecord->tanggal_timbang)->format('H:i:s') : $timeOnly;
|
||||
|
||||
$existingRecord->update([
|
||||
'berat' => $berat,
|
||||
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
|
||||
]);
|
||||
|
||||
// Tambah hitungan jika berhasil update
|
||||
$this->importedCount++;
|
||||
} else {
|
||||
$finalTime = ($timeOnly === '00:00:00') ? now()->format('H:i:s') : $timeOnly;
|
||||
|
||||
WeightHistory::create([
|
||||
'cat_id' => $catId,
|
||||
'berat' => $berat,
|
||||
'tanggal_timbang' => $dateOnly . ' ' . $finalTime,
|
||||
]);
|
||||
|
||||
// Tambah hitungan jika berhasil insert baru
|
||||
$this->importedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fungsi untuk melempar total hitungan ke Controller
|
||||
public function getImportedCount(): int
|
||||
{
|
||||
return $this->importedCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ApiToken extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'name', 'token_hash', 'last_used_at', 'expires_at'];
|
||||
protected function casts(): array { return ['last_used_at' => 'datetime', 'expires_at' => 'datetime']; }
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
+106
-44
@@ -16,7 +16,6 @@ class Cat extends Model
|
||||
|
||||
/**
|
||||
* SINKRONISASI TOTAL: 22 Kolom Database
|
||||
* Memasukkan berat_terakhir, lokasi, dan kolom identitas lainnya.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_kucing', 'ras', 'registry', 'jenis_kelamin', 'tanggal_lahir',
|
||||
@@ -24,9 +23,38 @@ class Cat extends Model
|
||||
'jadwal_vaksin', 'jadwal_obat_cacing', 'jadwal_obat_kutu',
|
||||
'jadwal_grooming', 'induk_jantan_id', 'induk_betina_id',
|
||||
'lokasi_kandang', 'pic_id', 'berat_badan',
|
||||
'pedigree', 'microchip', 'status', 'kode_ems' // Kolom tambahan dari UI Anda
|
||||
'pedigree','sertifikat_ped_file', 'microchip', 'status', 'kode_ems','treatment_status'
|
||||
];
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi (Booted)
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
// A. Sebelum simpan: Sinkronisasi berat_terakhir
|
||||
static::saving(function ($cat) {
|
||||
if ($cat->isDirty('berat_badan')) {
|
||||
$cat->berat_terakhir = $cat->berat_badan;
|
||||
}
|
||||
});
|
||||
|
||||
// B. Setelah update berhasil: Catat ke tabel history
|
||||
static::updated(function ($cat) {
|
||||
if ($cat->wasChanged('berat_badan')) {
|
||||
// Kita gunakan try-catch agar jika tabel history bermasalah, update kucing tidak ikut mati
|
||||
try {
|
||||
$cat->weightHistories()->create([
|
||||
'berat' => $cat->berat_badan,
|
||||
'tanggal_timbang' => now(), // Pastikan kolom ini ada di migrasi Anda
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
// Log error diam-diam agar user tidak melihat Error 500
|
||||
\Illuminate\Support\Facades\Log::error("Gagal mencatat history berat: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Casting Laravel 11/12 Style
|
||||
*/
|
||||
@@ -38,7 +66,7 @@ class Cat extends Model
|
||||
'jadwal_obat_cacing' => 'date',
|
||||
'jadwal_obat_kutu' => 'date',
|
||||
'jadwal_grooming' => 'date',
|
||||
'registry' => 'array',
|
||||
'registry' => 'array', // Otomatis mengurus JSON di DB
|
||||
'berat_badan' => 'decimal:2',
|
||||
'berat_terakhir' => 'decimal:2',
|
||||
];
|
||||
@@ -48,10 +76,10 @@ class Cat extends Model
|
||||
/* RELATIONS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
public function treatments(): HasMany
|
||||
{
|
||||
return $this->hasMany(Treatment::class);
|
||||
}
|
||||
public function treatments()
|
||||
{
|
||||
return $this->hasMany(Treatment::class, 'cat_id');
|
||||
}
|
||||
|
||||
public function weightHistories(): HasMany
|
||||
{
|
||||
@@ -89,26 +117,38 @@ class Cat extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
public function registrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(CatRegistration::class)->orderByDesc('is_primary')->orderBy('id');
|
||||
}
|
||||
|
||||
public function primaryRegistration()
|
||||
{
|
||||
return $this->hasOne(CatRegistration::class)->where('is_primary', true);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS */
|
||||
/* ACCESSORS (Penyempurnaan Null-Safety & Logic) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
protected function genderIcon(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
// Database menggunakan enum 'Jantan'/'Betina' (Case Sensitive)
|
||||
get: fn () => strtolower($this->jenis_kelamin) === 'jantan'
|
||||
? '<i class="fas fa-mars text-indigo-500"></i>'
|
||||
: '<i class="fas fa-venus text-rose-500"></i>'
|
||||
get: fn () => strtolower($this->jenis_kelamin ?? '') === 'jantan'
|
||||
? '<i class="fas fa-mars text-indigo-500" title="Jantan"></i>'
|
||||
: '<i class="fas fa-venus text-rose-500" title="Betina"></i>'
|
||||
);
|
||||
}
|
||||
|
||||
protected function fotoUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->foto
|
||||
? Storage::url($this->foto)
|
||||
: asset('images/default-cat.png')
|
||||
get: function () {
|
||||
if ($this->foto && Storage::disk('public')->exists($this->foto)) {
|
||||
return Storage::url($this->foto);
|
||||
}
|
||||
return asset('images/default-cat.png');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,57 +166,79 @@ class Cat extends Model
|
||||
);
|
||||
}
|
||||
|
||||
// Usia presisi untuk laporan medis (Tahun, Bulan, Hari)
|
||||
protected function usiaDetail(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (!$this->tanggal_lahir) return 'Data N/A';
|
||||
$diff = $this->tanggal_lahir->diff(now());
|
||||
return "{$diff->y} Thn, {$diff->m} Bln, {$diff->d} Hari";
|
||||
return "{$diff->y}th {$diff->m}bln {$diff->d}hr";
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
protected function registryLabel(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->registry ?? 'NON-PED'
|
||||
);
|
||||
}
|
||||
|
||||
// Badges untuk registry
|
||||
/**
|
||||
* Badge Registry yang disempurnakan (lebih bersih)
|
||||
*/
|
||||
protected function registryBadges(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
// Jika tidak ada registry
|
||||
if (empty($this->registry)) {
|
||||
return '<span class="text-[9px] text-slate-400 font-bold italic tracking-widest">NON-PED</span>';
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$registries = $this->registry;
|
||||
|
||||
// Jika kosong atau eksplisit memilih NON-PED
|
||||
if (empty($registries) || (is_array($registries) && in_array('NON-PED', $registries))) {
|
||||
return '<span class="px-2 py-0.5 bg-slate-100 text-slate-400 border border-slate-200 rounded-lg text-[8px] font-black uppercase tracking-tighter">NON-PED</span>';
|
||||
}
|
||||
|
||||
// Mapping warna untuk setiap federasi
|
||||
$config = [
|
||||
'ICA' => ['bg' => 'bg-blue-50', 'text' => 'text-blue-700', 'border' => 'border-blue-100'],
|
||||
'CFA' => ['bg' => 'bg-rose-50', 'text' => 'text-rose-700', 'border' => 'border-rose-100'],
|
||||
'WCF' => ['bg' => 'bg-emerald-50', 'text' => 'text-emerald-700', 'border' => 'border-emerald-100'],
|
||||
'FFF' => ['bg' => 'bg-amber-50', 'text' => 'text-amber-700', 'border' => 'border-amber-100'],
|
||||
'ICA' => 'bg-blue-50 text-blue-700 border-blue-100',
|
||||
'CFA' => 'bg-rose-50 text-rose-700 border-rose-100',
|
||||
'WCF' => 'bg-emerald-50 text-emerald-700 border-emerald-100',
|
||||
'FFF' => 'bg-amber-50 text-amber-700 border-amber-100',
|
||||
];
|
||||
|
||||
$html = '<div class="flex flex-wrap gap-1">';
|
||||
$registries = is_array($this->registry) ? $this->registry : [$this->registry];
|
||||
$list = is_array($registries) ? $registries : [$registries];
|
||||
|
||||
foreach ($registries as $reg) {
|
||||
$style = $config[$reg] ?? ['bg' => 'bg-slate-50', 'text' => 'text-slate-600', 'border' => 'border-slate-200'];
|
||||
|
||||
$html .= "<span class='px-2 py-0.5 {$style['bg']} {$style['text']} {$style['border']} border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm'>";
|
||||
$html .= $reg;
|
||||
$html .= "</span>";
|
||||
foreach ($list as $reg) {
|
||||
if($reg === 'NON-PED') continue; // Skip jika tercampur (opsional)
|
||||
$uReg = strtoupper($reg);
|
||||
$style = $config[$uReg] ?? 'bg-slate-50 text-slate-600 border-slate-200';
|
||||
$html .= "<span class='px-2 py-0.5 border rounded-lg text-[8px] font-black uppercase tracking-tighter shadow-sm {$style}'>{$uReg}</span>";
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public function medicalRecords()
|
||||
{
|
||||
// Mengambil riwayat, terbaru diletakkan paling atas
|
||||
return $this->hasMany(MedicalRecord::class)->orderBy('date_given', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sinkronkan kolom status perawatan dari treatment yang masih aktif.
|
||||
* Nilai 1 = dalam perawatan, 2 = sehat.
|
||||
*/
|
||||
public function updateTreatmentStatus(): void
|
||||
{
|
||||
$status = $this->treatments()->where('status_perawatan', 1)->exists() ? 1 : 2;
|
||||
$this->forceFill(['treatment_status' => $status])->saveQuietly();
|
||||
}
|
||||
/**
|
||||
* Scope untuk filter kucing yang sedang sakit (Under Treatment)
|
||||
*/
|
||||
public function scopeUnderTreatment($query)
|
||||
{
|
||||
// Hapus logika whereHas lama, ganti dengan ini:
|
||||
return $query->where('treatment_status', 1);
|
||||
}
|
||||
|
||||
public function scopeHealthy($query)
|
||||
{
|
||||
return $query->where('treatment_status', 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CatAssociation extends Model
|
||||
{
|
||||
protected $fillable = ['code', 'name', 'country', 'website', 'is_active'];
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function registrations()
|
||||
{
|
||||
return $this->hasMany(CatRegistration::class, 'association_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CatRegistration extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'cat_id', 'association_id', 'registration_number', 'pedigree_number',
|
||||
'certificate_number', 'issue_date', 'title', 'ownership_status',
|
||||
'is_primary', 'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['issue_date' => 'date', 'is_primary' => 'boolean'];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (CatRegistration $registration) {
|
||||
$registration->loadMissing('documents');
|
||||
foreach ($registration->documents as $document) {
|
||||
if ($document->file_path) {
|
||||
Storage::disk('public')->delete($document->file_path);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function cat() { return $this->belongsTo(Cat::class); }
|
||||
public function association() { return $this->belongsTo(CatAssociation::class, 'association_id'); }
|
||||
public function documents() { return $this->hasMany(CatRegistrationDocument::class)->latest(); }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CatRegistrationDocument extends Model
|
||||
{
|
||||
protected $fillable = ['cat_registration_id', 'label', 'document_type', 'file_path', 'issued_at'];
|
||||
protected function casts(): array { return ['issued_at' => 'date']; }
|
||||
public function registration() { return $this->belongsTo(CatRegistration::class, 'cat_registration_id'); }
|
||||
public function getUrlAttribute(): string { return Storage::disk('public')->url($this->file_path); }
|
||||
}
|
||||
+47
-14
@@ -3,19 +3,41 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Food extends Model
|
||||
{
|
||||
protected $table = 'foods';
|
||||
|
||||
// Gunakan guarded atau fillable, pastikan 'slug' dan 'category_id' diizinkan
|
||||
protected $guarded = ['id'];
|
||||
// Menggunakan fillable lebih aman untuk tracking kolom apa saja yang masuk ke DB
|
||||
protected $fillable = [
|
||||
'nama_makanan',
|
||||
'foto_produk',
|
||||
'slug',
|
||||
'qty',
|
||||
'unit',
|
||||
'category_id',
|
||||
'kategori',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting tipe data agar terbaca sebagai objek oleh Carbon
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke model Category
|
||||
*/
|
||||
public function category()
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
@@ -27,14 +49,13 @@ class Food extends Model
|
||||
{
|
||||
static::saving(function ($food) {
|
||||
// 1. OTOMATIS GENERATE SLUG
|
||||
// Hanya generate jika nama berubah atau slug masih kosong
|
||||
if ($food->isDirty('nama_makanan') || empty($food->slug)) {
|
||||
$food->slug = Str::slug($food->nama_makanan) . '-' . Str::random(5);
|
||||
$food->slug = Str::slug($food->nama_makanan) . '-' . Str::lower(Str::random(5));
|
||||
}
|
||||
|
||||
// 2. AUTO-CATEGORIZATION (SMART LOGIC)
|
||||
// Hanya jalankan jika category_id belum diisi manual
|
||||
if (empty($food->category_id)) {
|
||||
// Jalankan hanya jika category_id belum diisi manual atau nama makanan berubah
|
||||
if (empty($food->category_id) || $food->isDirty('nama_makanan')) {
|
||||
$productName = strtolower($food->nama_makanan);
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
$found = false;
|
||||
@@ -49,27 +70,39 @@ class Food extends Model
|
||||
$food->category_id = $cat->id;
|
||||
$food->kategori = $cat->name;
|
||||
$found = true;
|
||||
break; // Keluar dari loop keyword
|
||||
break 2; // Keluar dari kedua loop sekaligus
|
||||
}
|
||||
}
|
||||
if ($found) break; // Keluar dari loop kategori
|
||||
}
|
||||
|
||||
// 3. FALLBACK LOGIC
|
||||
// Jika tidak ada keyword yang cocok, cari kategori default
|
||||
if (!$found) {
|
||||
if (!$found && empty($food->category_id)) {
|
||||
$default = Category::where('name', 'Dry Food')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$food->category_id = $default->id;
|
||||
$food->kategori = $default->name;
|
||||
} else {
|
||||
$food->kategori = 'Umum';
|
||||
$food->kategori = $food->kategori ?: 'Umum';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SINKRONISASI TEKS KATEGORI
|
||||
// Jika category_id diisi manual di form, pastikan kolom 'kategori' (string) juga terupdate
|
||||
if ($food->isDirty('category_id') && !empty($food->category_id)) {
|
||||
$food->kategori = $food->category?->name ?? $food->kategori;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Cek Status Expired
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,9 @@ class Kitten extends Model
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if ($this->is_pedigree) {
|
||||
return '<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase border border-indigo-100 italic">Certificate OK</span>';
|
||||
return '<span class="px-2 py-0.5 bg-indigo-50 text-indigo-600 rounded text-[9px] font-black uppercase border border-indigo-100 italic">Sertifikat tersedia</span>';
|
||||
}
|
||||
return '<span class="px-2 py-0.5 bg-slate-50 text-slate-400 rounded text-[9px] font-black uppercase border border-slate-100">Non-Ped</span>';
|
||||
return '<span class="px-2 py-0.5 bg-slate-50 text-slate-400 rounded text-[9px] font-black uppercase border border-slate-100">Non-pedigree</span>';
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class Matting extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* SINKRONISASI OTOMATIS (The Engine)
|
||||
* SINKRONISASI OTOMATIS (Mesin sinkronisasi)
|
||||
* Saat data Matting disimpan, otomatis update data di tabel Cats.
|
||||
*/
|
||||
protected static function booted()
|
||||
@@ -34,8 +34,7 @@ class Matting extends Model
|
||||
// 1. Update data Sire (Bapak)
|
||||
if ($matting->sire_id && $matting->sire) {
|
||||
$matting->sire->update([
|
||||
'pedigree' => $matting->no_pedigree_bapak,
|
||||
'microchip' => $now_microchip_bapak ?? $matting->no_microchip_bapak,
|
||||
'microchip' => $matting->no_microchip_bapak,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_sire,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_bapak
|
||||
'kode_ems' => $matting->ems_bapak,
|
||||
@@ -45,7 +44,6 @@ class Matting extends Model
|
||||
// 2. Update data Dam (Ibu)
|
||||
if ($matting->dam_id && $matting->dam) {
|
||||
$matting->dam->update([
|
||||
'pedigree' => $matting->no_pedigree_ibu,
|
||||
'microchip' => $matting->no_microchip_ibu,
|
||||
'tanggal_lahir' => $matting->tgl_lahir_dam,
|
||||
// Pastikan kolom di tabel 'mattings' adalah ems_ibu
|
||||
@@ -74,7 +72,7 @@ class Matting extends Model
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS (UI & Breeding Intelligence) */
|
||||
/* ACCESSORS (UI & BREEDING) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -114,7 +112,7 @@ class Matting extends Model
|
||||
get: function () {
|
||||
$status = strtolower($this->status);
|
||||
return match($status) {
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">Monitoring</span>',
|
||||
'proses' => '<span class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-[10px] font-black uppercase border border-blue-100">Dipantau</span>',
|
||||
'berhasil' => '<span class="px-3 py-1 bg-emerald-50 text-emerald-600 rounded-full text-[10px] font-black uppercase border border-emerald-100">Lahir</span>',
|
||||
'gagal' => '<span class="px-3 py-1 bg-rose-50 text-rose-600 rounded-full text-[10px] font-black uppercase border border-rose-100">Gagal</span>',
|
||||
default => '<span class="px-3 py-1 bg-slate-50 text-slate-600 rounded-full text-[10px] font-black uppercase">'.$status.'</span>',
|
||||
@@ -131,7 +129,7 @@ class Matting extends Model
|
||||
// Pastikan kolom di tabel 'mattings' adalah 'user_id'
|
||||
// Jika kolomnya bernama 'pic_id', ganti 'user_id' menjadi 'pic_id'
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
'name' => 'Sistem / Admin'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo; // 1. TAMBAHKAN BARIS INI
|
||||
|
||||
class MedicalRecord extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'cat_id',
|
||||
'user_id',
|
||||
'type',
|
||||
'treatment_name',
|
||||
'dosage',
|
||||
'unit',
|
||||
'date_given',
|
||||
'next_schedule',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date_given' => 'date',
|
||||
'next_schedule' => 'date',
|
||||
];
|
||||
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class);
|
||||
}
|
||||
|
||||
// 2. PASTIKAN RELASI INI BENAR
|
||||
public function performer(): BelongsTo
|
||||
{
|
||||
// Karena kolomnya sekarang user_id (bukan performed_by),
|
||||
// kita beri tahu Laravel secara eksplisit:
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
}
|
||||
+73
-30
@@ -4,58 +4,77 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class Medicine extends Model
|
||||
{
|
||||
/**
|
||||
* Mass assignment untuk kolom-kolom inventori.
|
||||
* Kolom 'kategori' tetap ada di fillable agar bisa diisi otomatis oleh sistem.
|
||||
* Mass assignment.
|
||||
* Menambahkan expired_at dan category_id agar sinkron dengan update database terbaru.
|
||||
*/
|
||||
protected $fillable = [
|
||||
'nama_obat',
|
||||
'foto_produk',
|
||||
'kategori',
|
||||
'category_id',
|
||||
'qty',
|
||||
'unit',
|
||||
'expired_date'
|
||||
'expired_date',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Casting (Laravel 11/12 style)
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_date' => 'date',
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float', // Mendukung dosis desimal (misal 2.5 ml)
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi & Integritas Data
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
static::saving(function ($medicine) {
|
||||
$name = strtolower($medicine->nama_obat);
|
||||
|
||||
// Ambil semua kategori dari database
|
||||
$categories = \App\Models\Category::all();
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
// Pecah kata kunci berdasarkan koma
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
{
|
||||
static::saving(function ($medicine) {
|
||||
$name = strtolower($medicine->nama_obat);
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($name, trim($keyword))) {
|
||||
$medicine->category_id = $cat->id;
|
||||
$medicine->kategori = $cat->name; // Sinkronisasi teks kategori
|
||||
return;
|
||||
// 1. OTOMATISASI KATEGORI BERDASARKAN KEYWORDS
|
||||
$categories = \App\Models\Category::all();
|
||||
$matched = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
foreach ($keywords as $keyword) {
|
||||
if (str_contains($name, trim($keyword))) {
|
||||
$medicine->category_id = $cat->id;
|
||||
$medicine->kategori = $cat->name;
|
||||
$matched = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$medicine->kategori = 'Umum';
|
||||
});
|
||||
}
|
||||
if (!$matched) {
|
||||
$medicine->kategori = $medicine->kategori ?: 'Umum';
|
||||
}
|
||||
|
||||
/**
|
||||
* Casting tipe data untuk presisi medis.
|
||||
*/
|
||||
protected $casts = [
|
||||
'expired_date' => 'date', // Memperbaiki error isPast() di Blade
|
||||
'qty' => 'float', // Mendukung dosis desimal seperti 2.5 ml
|
||||
];
|
||||
// 2. SINKRONISASI TANGGAL EXPIRED (Double Guard)
|
||||
// Jika expired_at kosong tapi expired_date ada, samakan. Begitu juga sebaliknya.
|
||||
if ($medicine->expired_date && !$medicine->expired_at) {
|
||||
$medicine->expired_at = $medicine->expired_date;
|
||||
} elseif ($medicine->expired_at && !$medicine->expired_date) {
|
||||
$medicine->expired_date = $medicine->expired_at;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Mempermudah pemanggilan foto produk dengan fallback image.
|
||||
@@ -66,10 +85,26 @@ class Medicine extends Model
|
||||
return asset('storage/' . $this->foto_produk);
|
||||
}
|
||||
|
||||
// Menggunakan UI Avatars sebagai placeholder jika foto tidak ada
|
||||
return 'https://ui-avatars.com/api/?name=' . urlencode($this->nama_obat) . '&background=f1f5f9&color=64748b';
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Cek Status Expired secara instan.
|
||||
* Bisa digunakan di Blade: @if($medicine->is_expired) ... @endif
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Kategori Obat.
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke riwayat pengobatan kucing.
|
||||
*/
|
||||
@@ -77,4 +112,12 @@ class Medicine extends Model
|
||||
{
|
||||
return $this->hasMany(Treatment::class, 'medicine_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Melihat riwayat penggunaan obat ini di berbagai rekam medis
|
||||
*/
|
||||
public function treatmentDetails()
|
||||
{
|
||||
return $this->hasMany(TreatmentDetail::class);
|
||||
}
|
||||
}
|
||||
+70
-27
@@ -9,59 +9,102 @@ use Illuminate\Support\Str;
|
||||
class Supply extends Model
|
||||
{
|
||||
protected $table = 'supplies';
|
||||
protected $guarded = ['id'];
|
||||
|
||||
// Menggunakan fillable untuk keamanan mass assignment
|
||||
protected $fillable = [
|
||||
'nama_perlengkapan',
|
||||
'foto_produk',
|
||||
'slug',
|
||||
'qty',
|
||||
'unit',
|
||||
'category_id',
|
||||
'kategori',
|
||||
'expired_at'
|
||||
];
|
||||
|
||||
/**
|
||||
* Modern Casting (Laravel 11/12)
|
||||
* Memastikan expired_at menjadi objek Carbon untuk mencegah error isPast()
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expired_at' => 'date',
|
||||
'qty' => 'float',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke model Category
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'category_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic Otomatisasi & Integritas Data
|
||||
*/
|
||||
protected static function booted()
|
||||
{
|
||||
parent::booted();
|
||||
|
||||
static::saving(function ($supply) {
|
||||
// 1. Sinkronisasi Slug (Mencegah error SQL sebelumnya)
|
||||
// 1. OTOMATIS GENERATE SLUG
|
||||
if ($supply->isDirty('nama_perlengkapan') || empty($supply->slug)) {
|
||||
$supply->slug = Str::slug($supply->nama_perlengkapan) . '-' . Str::random(5);
|
||||
$supply->slug = Str::slug($supply->nama_perlengkapan) . '-' . Str::lower(Str::random(5));
|
||||
}
|
||||
|
||||
// 2. Auto-Categorization
|
||||
if (empty($supply->category_id)) {
|
||||
// 2. AUTO-CATEGORIZATION (SMART LOGIC)
|
||||
// Jalankan jika category_id kosong atau nama perlengkapan berubah
|
||||
if (empty($supply->category_id) || $supply->isDirty('nama_perlengkapan')) {
|
||||
$name = strtolower($supply->nama_perlengkapan);
|
||||
|
||||
// Ambil kategori yang punya keywords
|
||||
$categories = Category::whereNotNull('keywords')->get();
|
||||
$found = false;
|
||||
|
||||
foreach ($categories as $cat) {
|
||||
$keywords = explode(',', strtolower($cat->keywords));
|
||||
$keywordsArray = explode(',', strtolower($cat->keywords));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
$trimmed = trim($keyword);
|
||||
foreach ($keywordsArray as $keyword) {
|
||||
$cleanKeyword = trim($keyword);
|
||||
|
||||
// Cek apakah "pasir" atau keyword lain ada di nama produk
|
||||
if (!empty($trimmed) && str_contains($name, $trimmed)) {
|
||||
if (!empty($cleanKeyword) && str_contains($name, $cleanKeyword)) {
|
||||
$supply->category_id = $cat->id;
|
||||
// PENTING: Update kolom teks 'kategori' agar tampilan tidak 'Umum'
|
||||
$supply->kategori = $cat->name;
|
||||
return;
|
||||
$found = true;
|
||||
break 2; // Berhenti mencari jika sudah ketemu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback jika tidak ada yang cocok
|
||||
// Cari kategori yang mengandung kata 'Pasir' atau 'Umum'
|
||||
$default = Category::where('name', 'LIKE', '%Pasir%')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$supply->category_id = $default->id;
|
||||
$supply->kategori = $default->name;
|
||||
} else {
|
||||
$supply->kategori = 'Umum';
|
||||
// 3. FALLBACK LOGIC
|
||||
if (!$found && empty($supply->category_id)) {
|
||||
$default = Category::where('name', 'LIKE', '%Pasir%')
|
||||
->orWhere('name', 'Umum')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
$supply->category_id = $default->id;
|
||||
$supply->kategori = $default->name;
|
||||
} else {
|
||||
$supply->kategori = $supply->kategori ?: 'Umum';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. SINKRONISASI TEKS KATEGORI
|
||||
// Jika ID kategori diubah manual di form, update teks kolom 'kategori'
|
||||
if ($supply->isDirty('category_id') && !empty($supply->category_id)) {
|
||||
$supply->kategori = $supply->category?->name ?? $supply->kategori;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessor: Membantu pengecekan expired di Blade secara rapi
|
||||
* Contoh: @if($supply->is_expired) ... @endif
|
||||
*/
|
||||
public function getIsExpiredAttribute(): bool
|
||||
{
|
||||
return $this->expired_at ? $this->expired_at->isPast() : false;
|
||||
}
|
||||
}
|
||||
+62
-19
@@ -4,8 +4,11 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Treatment extends Model
|
||||
{
|
||||
@@ -24,10 +27,12 @@ class Treatment extends Model
|
||||
'foto_bukti',
|
||||
'medicine_id',
|
||||
'qty_used',
|
||||
'status_perawatan', // Tambahan Baru
|
||||
'remark_pasca_sakit'
|
||||
];
|
||||
|
||||
/**
|
||||
* Casting data agar formatnya sesuai (Tanggal jadi object Carbon).
|
||||
* Casting data agar formatnya sesuai.
|
||||
*/
|
||||
protected $casts = [
|
||||
'tanggal' => 'datetime',
|
||||
@@ -35,7 +40,7 @@ class Treatment extends Model
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* RELASI (MENGATASI ERROR RELATION NOT FOUND) */
|
||||
/* RELASI */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -43,25 +48,19 @@ class Treatment extends Model
|
||||
*/
|
||||
public function cat(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Cat::class, 'cat_id')->withDefault([
|
||||
'nama_kucing' => 'Kucing Terhapus'
|
||||
]);
|
||||
return $this->belongsTo(Cat::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi utama ke User (PIC).
|
||||
* Nama fungsi 'user' wajib ada karena dipanggil di TreatmentController baris 31.
|
||||
* Relasi utama ke User (PIC).
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id')->withDefault([
|
||||
'name' => 'System / Admin'
|
||||
]);
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias untuk relasi user.
|
||||
* Nama fungsi 'pic' wajib ada karena dipanggil di DashboardController.
|
||||
* Alias untuk relasi user (Digunakan di Dashboard).
|
||||
*/
|
||||
public function pic(): BelongsTo
|
||||
{
|
||||
@@ -69,22 +68,38 @@ class Treatment extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Inventori Obat.
|
||||
* PENTING: Relasi ke Medicine tunggal (Legacy Support).
|
||||
* Ini yang memperbaiki error 'RelationNotFoundException' pada data lama.
|
||||
*/
|
||||
public function medicine(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Medicine::class, 'medicine_id')->withDefault([
|
||||
'nama_obat' => $this->nama_obat ?? 'Obat Manual'
|
||||
]);
|
||||
return $this->belongsTo(Medicine::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke Detail Obat (Sistem Baru Multi-Medicine).
|
||||
*/
|
||||
public function details(): HasMany
|
||||
{
|
||||
return $this->hasMany(TreatmentDetail::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi langsung ke banyak Medicine (Many-to-Many via pivot details).
|
||||
*/
|
||||
public function medicines(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Medicine::class, 'treatment_details')
|
||||
->withPivot('dosis', 'keterangan_pakai')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ACCESSORS */
|
||||
/* ACCESSORS */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Mengambil URL foto bukti.
|
||||
* Cara akses di Blade: {{ $treatment->foto_url }}
|
||||
*/
|
||||
public function getFotoUrlAttribute(): string
|
||||
{
|
||||
@@ -95,8 +110,26 @@ class Treatment extends Model
|
||||
return Storage::url($this->foto_bukti);
|
||||
}
|
||||
|
||||
/**
|
||||
* Menghitung Progres Pengobatan secara otomatis.
|
||||
* Cara pakai di Blade: {{ $treatment->status_progress }}
|
||||
*/
|
||||
public function getStatusProgressAttribute(): string
|
||||
{
|
||||
$text = strtolower($this->diagnosa . ' ' . $this->keterangan);
|
||||
|
||||
// Keyword indikator membaik
|
||||
$improvingKeywords = ['baik', 'makan', 'aktif', 'sembuh', 'lincah', 'normal', 'turun panas'];
|
||||
|
||||
if (Str::contains($text, $improvingKeywords)) {
|
||||
return 'MEMBAIK';
|
||||
}
|
||||
|
||||
return 'STUCK';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SCOPES */
|
||||
/* SCOPES */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -106,4 +139,14 @@ class Treatment extends Model
|
||||
{
|
||||
$query->orderBy('tanggal', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope khusus untuk mengambil rekam medis yang sedang aktif (Under Treatment).
|
||||
*/
|
||||
public function scopeActiveTreatment(Builder $query): Builder
|
||||
{
|
||||
return $query->whereHas('cat', function($q) {
|
||||
$q->where('treatment_status', 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TreatmentDetail extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
// Menentukan kolom yang boleh diisi secara massal
|
||||
protected $fillable = [
|
||||
'treatment_id',
|
||||
'medicine_id',
|
||||
'dosis',
|
||||
'keterangan_pakai',
|
||||
];
|
||||
|
||||
/**
|
||||
* Relasi balik ke Header Treatment (Rekam Medis Utama)
|
||||
*/
|
||||
public function treatment()
|
||||
{
|
||||
return $this->belongsTo(Treatment::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Relasi ke data Obat (Inventory)
|
||||
*/
|
||||
public function medicine()
|
||||
{
|
||||
return $this->belongsTo(Medicine::class);
|
||||
}
|
||||
}
|
||||
+20
-14
@@ -8,12 +8,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo; // Tambahan: Import untuk relasi
|
||||
use Laragear\WebAuthn\Contracts\WebAuthnAuthenticatable; // Tambahkan ini
|
||||
use Laragear\WebAuthn\WebAuthnAuthentication;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable, HasPermissions; // Tambahan: Gunakan HasPermissions
|
||||
|
||||
use WebAuthnAuthentication;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
@@ -55,23 +59,25 @@ class User extends Authenticatable
|
||||
* Tambahan: Relasi ke tabel Roles
|
||||
* Setiap user memiliki satu Role.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
/**
|
||||
* Relasi role memakai nama roleModel agar tidak bertabrakan dengan kolom legacy `role`.
|
||||
*/
|
||||
public function roleModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
return $this->belongsTo(Role::class, 'role_id');
|
||||
}
|
||||
|
||||
public function hasAccess($module, $permission)
|
||||
{
|
||||
// Jika admin, berikan akses penuh secara otomatis
|
||||
if ($this->role && $this->role->slug === 'admin') {
|
||||
return true;
|
||||
}
|
||||
public function hasAccess($module, $permission): bool
|
||||
{
|
||||
$role = $this->roleModel;
|
||||
if ($role?->slug === 'admin' || $this->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cek di tabel role_permissions melalui relasi role
|
||||
return $this->role->permissions()
|
||||
->where('module_slug', $module)
|
||||
->where($permission, 1) // $permission berisi 'can_read', 'can_update', dll
|
||||
->exists();
|
||||
}
|
||||
return $role?->permissions()
|
||||
->where('module_slug', $module)
|
||||
->where($permission, 1)
|
||||
->exists() ?? false;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -6,5 +6,27 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WeightHistory extends Model
|
||||
{
|
||||
//
|
||||
}
|
||||
// Pastikan fillable sesuai dengan kolom di database
|
||||
protected $fillable = [
|
||||
'cat_id',
|
||||
'berat',
|
||||
'tanggal_timbang', // Pastikan baris ini ada
|
||||
'created_at',
|
||||
'updated_at'
|
||||
];
|
||||
|
||||
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tanggal_timbang' => 'date',
|
||||
'berat' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function cat()
|
||||
{
|
||||
return $this->belongsTo(Cat::class);
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,18 @@ use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Gate; // Import Gate untuk sistem permission
|
||||
use Illuminate\Support\Facades\URL;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void { /* ... */ }
|
||||
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
|
||||
if (config('app.env') === 'production') {
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
// 1. DAFTARKAN OBSERVERS (Tetap Utuh & Aman)
|
||||
if (Schema::hasTable('medicines')) {
|
||||
Medicine::observe(MedicineObserver::class);
|
||||
@@ -51,29 +56,33 @@ class AppServiceProvider extends ServiceProvider
|
||||
$view->with($stockData);
|
||||
});
|
||||
|
||||
// 4. SISTEM OTORISASI GLOBAL (Gate Permission)
|
||||
// 4. SISTEM OTORISASI GLOBAL (Gate Permission)
|
||||
Gate::define('check-access', function ($user, $module, $permission) {
|
||||
// Hindari pengecekan jika user tidak memiliki role_id atau data role
|
||||
if (!$user->role_id && !$user->role) return false;
|
||||
|
||||
// Deteksi Role: Apakah string (lama) atau object (baru)
|
||||
// Jika $user->role adalah string, ambil langsung. Jika object, ambil slug-nya.
|
||||
$roleIdentifier = is_object($user->role) ? $user->role->slug : $user->role;
|
||||
|
||||
// Admin Bypass: Role admin memiliki akses ke seluruh fitur
|
||||
if ($roleIdentifier === 'admin') {
|
||||
// Dukungan akun legacy yang masih menyimpan role sebagai string.
|
||||
if ($user->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!$user->role_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->roleModel;
|
||||
if ($role?->slug === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Menyelaraskan nama kolom (can_read, can_create, can_update, can_delete)
|
||||
$column = str_starts_with($permission, 'can_') ? $permission : 'can_' . $permission;
|
||||
|
||||
// Cek matriks di tabel role_permissions berdasarkan role_id
|
||||
return \App\Models\RolePermission::where('role_id', $user->role_id)
|
||||
->where('module_slug', $module)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
try {
|
||||
return \App\Models\RolePermission::where('role_id', $user->role_id)
|
||||
->where('module_slug', $module)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
<?php
|
||||
namespace App\Traits;
|
||||
|
||||
trait HasPermissions {
|
||||
public function hasPermission($moduleSlug, $action) {
|
||||
// Jika user tidak punya role, akses ditolak
|
||||
if (!$this->role) return false;
|
||||
|
||||
// Cek ke tabel permission_role
|
||||
return $this->role->permissions()
|
||||
trait HasPermissions
|
||||
{
|
||||
public function hasPermission($moduleSlug, $action): bool
|
||||
{
|
||||
if ($this->getRawOriginal('role') === 'admin') {
|
||||
return true;
|
||||
}
|
||||
$role = $this->roleModel;
|
||||
if (!$role) return false;
|
||||
$column = str_starts_with($action, 'can_') ? $action : 'can_' . $action;
|
||||
return $role->permissions()
|
||||
->where('module_slug', $moduleSlug)
|
||||
->where($action, 1)
|
||||
->where($column, 1)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user