initial commit

This commit is contained in:
2026-09-23 12:45:02 +07:00
parent d16f454083
commit 507fc6ccdf
235 changed files with 19587 additions and 2835 deletions
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ApiToken;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class AuthController extends Controller
{
public function login(Request $request)
{
$data = $request->validate(['email' => 'required|email', 'password' => 'required|string', 'device_name' => 'nullable|string|max:100']);
$user = User::where('email', strtolower($data['email']))->first();
if (!$user || !Hash::check($data['password'], $user->password)) {
return response()->json(['message' => 'Email atau kata sandi tidak sesuai.'], 422);
}
$plain = Str::random(80);
ApiToken::create([
'user_id' => $user->id,
'name' => $data['device_name'] ?? 'Android',
'token_hash' => hash('sha256', $plain),
'expires_at' => now()->addDays(90),
]);
return response()->json(['token' => $plain, 'token_type' => 'Bearer', 'user' => $this->userPayload($user)]);
}
public function me(Request $request) { return response()->json(['user' => $this->userPayload($request->user())]); }
public function logout(Request $request) { $request->attributes->get('api_token')?->delete(); return response()->json(['message' => 'Sesi berhasil diakhiri.']); }
private function userPayload(User $user): array { return ['id'=>$user->id,'name'=>$user->name,'email'=>$user->email,'role'=>$user->roleModel?->name ?? $user->getRawOriginal('role')]; }
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Matting;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class BreedingController extends Controller
{
public function index(Request $request)
{
Gate::authorize('check-access', ['matting', 'read']);
$items=Matting::with(['sire:id,nama_kucing,foto','dam:id,nama_kucing,foto','kittens.cat:id,nama_kucing,foto'])
->when($request->status,fn($q,$v)=>$q->where('status',$v))->latest('tgl_kawin_awal')->paginate(15);
$items->getCollection()->transform(fn($m)=>$this->payload($m));
return response()->json($items);
}
public function show(Matting $matting) { Gate::authorize('check-access', ['matting', 'read']); $matting->load(['sire','dam','kittens.cat.registrations.association']); return response()->json(['data'=>$this->payload($matting,true)]); }
private function payload(Matting $m,bool $detail=false): array
{
$edd=$m->tgl_kawin_awal ? Carbon::parse($m->tgl_kawin_awal)->addDays(63) : null;
return ['id'=>$m->id,'sire'=>['id'=>$m->sire_id,'nama'=>$m->sire?->nama_kucing],'dam'=>['id'=>$m->dam_id,'nama'=>$m->dam?->nama_kucing],'tanggal_kawin_awal'=>$m->tgl_kawin_awal?->format('Y-m-d'),'tanggal_kawin_akhir'=>$m->tgl_kawin_akhir?->format('Y-m-d'),'perkiraan_lahir'=>$edd?->format('Y-m-d'),'status'=>$m->status,'jumlah_anak_hidup'=>$m->jumlah_anak_hidup,'anakan'=>$m->kittens->map(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'foto'=>$k->cat?->foto_url])->values()];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Cat;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class CatController extends Controller
{
public function index(Request $request)
{
Gate::authorize('check-access', ['cats', 'read']);
$cats = Cat::with(['registrations.association'])
->when($request->search, fn($q,$s)=>$q->where(fn($x)=>$x->where('nama_kucing','like',"%$s%")->orWhere('microchip','like',"%$s%")->orWhere('kode_ems','like',"%$s%")))
->when($request->gender, fn($q,$v)=>$q->where('jenis_kelamin',$v))
->orderBy('nama_kucing')->paginate(min((int)$request->get('per_page',20),50));
$cats->getCollection()->transform(fn($c)=>$this->payload($c));
return response()->json($cats);
}
public function show(Cat $cat)
{
Gate::authorize('check-access', ['cats', 'read']);
$cat->load(['sire:id,nama_kucing','dam:id,nama_kucing','registrations.association','registrations.documents','weightHistories'=>fn($q)=>$q->latest('tanggal_timbang')->take(30)]);
return response()->json(['data'=>$this->payload($cat,true)]);
}
private function payload(Cat $c, bool $detail=false): array
{
$base=['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'ras'=>$c->ras,'jenis_kelamin'=>$c->jenis_kelamin,'tanggal_lahir'=>$c->tanggal_lahir?->format('Y-m-d'),'usia'=>$c->age,'kode_ems'=>$c->kode_ems,'microchip'=>$c->microchip,'status'=>$c->status,'berat_terakhir'=>(float)($c->berat_badan ?? $c->berat_terakhir ?? 0),'lokasi'=>$c->lokasi_kandang,'registrasi'=>$c->registrations->map(fn($r)=>['id'=>$r->id,'asosiasi'=>$r->association?->code,'nama_asosiasi'=>$r->association?->name,'nomor_registrasi'=>$r->registration_number,'nomor_pedigree'=>$r->pedigree_number,'nomor_sertifikat'=>$r->certificate_number,'title'=>$r->title,'utama'=>$r->is_primary])->values()];
if($detail){$base['induk_jantan']=$c->sire?->nama_kucing;$base['induk_betina']=$c->dam?->nama_kucing;$base['riwayat_berat']=$c->weightHistories->map(fn($w)=>['id'=>$w->id,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang])->values();}
return $base;
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Cat;
use App\Models\Kitten;
use App\Models\Matting;
use App\Models\WeightHistory;
use Carbon\Carbon;
use Illuminate\Support\Facades\Gate;
class DashboardController extends Controller
{
public function index()
{
Gate::authorize('check-access', ['cats', 'read']);
$now = now();
$upcoming = Matting::with(['sire:id,nama_kucing','dam:id,nama_kucing'])
->where('status', 'proses')->whereNotNull('tgl_kawin_awal')->latest('tgl_kawin_awal')->take(20)->get()
->map(function ($m) use ($now) {
$edd = Carbon::parse($m->tgl_kawin_awal)->addDays(63);
return ['id'=>$m->id,'sire'=>$m->sire?->nama_kucing,'dam'=>$m->dam?->nama_kucing,'tanggal_kawin'=>$m->tgl_kawin_awal?->format('Y-m-d'),'perkiraan_lahir'=>$edd->format('Y-m-d'),'sisa_hari'=>$now->startOfDay()->diffInDays($edd->copy()->startOfDay(), false)];
})->filter(fn($v) => $v['sisa_hari'] >= -3 && $v['sisa_hari'] <= 30)->values();
return response()->json([
'ringkasan' => [
'total_kucing' => Cat::count(),
'jantan' => Cat::where('jenis_kelamin','Jantan')->count(),
'betina' => Cat::where('jenis_kelamin','Betina')->count(),
'breeding_aktif' => Matting::where('status','proses')->count(),
'total_anakan' => Kitten::count(),
],
'perkiraan_kelahiran' => $upcoming,
'berat_terbaru' => WeightHistory::with('cat:id,nama_kucing,foto')->latest('tanggal_timbang')->take(8)->get()->map(fn($w)=>['cat_id'=>$w->cat_id,'nama'=>$w->cat?->nama_kucing,'foto'=>$w->cat?->foto_url,'berat'=>(float)$w->berat,'tanggal'=>$w->tanggal_timbang?->format('Y-m-d') ?? (string)$w->tanggal_timbang]),
'kucing_terbaru' => Cat::latest()->take(6)->get(['id','nama_kucing','foto','jenis_kelamin','kode_ems','status'])->map(fn($c)=>['id'=>$c->id,'nama'=>$c->nama_kucing,'foto'=>$c->foto_url,'jenis_kelamin'=>$c->jenis_kelamin,'kode_ems'=>$c->kode_ems,'status'=>$c->status]),
]);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Kitten;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class KittenController extends Controller
{
public function index(Request $request)
{
Gate::authorize('check-access', ['matting', 'read']);
$items=Kitten::with(['cat:id,nama_kucing,foto,induk_jantan_id,induk_betina_id','matting.sire:id,nama_kucing','matting.dam:id,nama_kucing'])
->when($request->search,fn($q,$s)=>$q->where('nama','like',"%$s%"))->latest('tgl_lahir')->paginate(20);
$items->getCollection()->transform(fn($k)=>['id'=>$k->id,'cat_id'=>$k->cat_id,'nama'=>$k->nama,'foto'=>$k->cat?->foto_url,'jenis_kelamin'=>$k->jenis_kelamin,'tanggal_lahir'=>$k->tgl_lahir?->format('Y-m-d'),'kode_ems'=>$k->kode_ems,'sire'=>$k->matting?->sire?->nama_kucing,'dam'=>$k->matting?->dam?->nama_kucing]);
return response()->json($items);
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Cat;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class WeightController extends Controller
{
public function index(Cat $cat) { Gate::authorize('check-access', ['cats', 'read']); return response()->json(['data'=>$cat->weightHistories()->orderByDesc('tanggal_timbang')->paginate(30)]); }
public function store(Request $request, Cat $cat)
{
Gate::authorize('check-access', ['cats', 'update']);
$data=$request->validate(['berat'=>'required|numeric|min:0.05|max:30','tanggal'=>'nullable|date|before_or_equal:today']);
$history=$cat->weightHistories()->create(['berat'=>$data['berat'],'tanggal_timbang'=>$data['tanggal'] ?? today()]);
DB::table('cats')->where('id',$cat->id)->update(['berat_badan'=>$data['berat'],'berat_terakhir'=>$data['berat'],'updated_at'=>now()]);
return response()->json(['message'=>'Berat badan berhasil disimpan.','data'=>$history],201);
}
}
@@ -6,7 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\{Matting, Cat, Kitten};
use App\Traits\LogsActivity;
use Illuminate\Http\{Request, RedirectResponse, JsonResponse};
use Illuminate\Support\Facades\{DB, Storage};
use Illuminate\Support\Facades\{DB, Storage, Gate};
use Illuminate\View\View;
class MattingController extends Controller
@@ -15,6 +15,7 @@ class MattingController extends Controller
public function index(): View
{
Gate::authorize('check-access', ['matting', 'read']);
$mattings = Matting::with(['sire', 'dam'])
->withCount('kittens')
->latest()
@@ -25,6 +26,7 @@ class MattingController extends Controller
public function create(): View
{
Gate::authorize('check-access', ['matting', 'create']);
$sires = Cat::where('jenis_kelamin', 'Jantan')->get(['id', 'nama_kucing', 'ras']);
$dams = Cat::where('jenis_kelamin', 'Betina')->get(['id', 'nama_kucing', 'ras']);
return view('pages.matting.create', compact('sires', 'dams'));
@@ -32,6 +34,7 @@ class MattingController extends Controller
public function getCatData($id): JsonResponse
{
Gate::authorize('check-access', ['matting', 'read']);
$cat = Cat::find($id);
if (!$cat) return response()->json(['error' => 'Kucing tidak ditemukan'], 404);
@@ -48,6 +51,7 @@ class MattingController extends Controller
*/
public function store(Request $request): RedirectResponse
{
Gate::authorize('check-access', ['matting', 'create']);
$this->validateMatting($request);
return DB::transaction(function () use ($request) {
@@ -79,6 +83,7 @@ class MattingController extends Controller
*/
public function edit(Matting $matting): View
{
Gate::authorize('check-access', ['matting', 'update']);
// Load relasi agar data anak muncul di form edit
$matting->load('kittens.cat');
@@ -93,6 +98,7 @@ class MattingController extends Controller
*/
public function update(Request $request, Matting $matting): RedirectResponse
{
Gate::authorize('check-access', ['matting', 'update']);
$this->validateMatting($request);
return DB::transaction(function () use ($request, $matting) {
@@ -157,14 +163,12 @@ class MattingController extends Controller
private function syncParentData(Request $request)
{
Cat::where('id', $request->sire_id)->update([
'pedigree' => $request->no_pedigree_bapak,
'microchip' => $request->no_microchip_bapak,
'tanggal_lahir' => $request->tgl_lahir_sire,
'kode_ems' => $request->ems_bapak // Sinkron EMS Bapak
]);
Cat::where('id', $request->dam_id)->update([
'pedigree' => $request->no_pedigree_ibu,
'microchip' => $request->no_microchip_ibu,
'tanggal_lahir' => $request->tgl_lahir_dam,
'kode_ems' => $request->ems_ibu // Sinkron EMS Ibu
@@ -218,6 +222,7 @@ class MattingController extends Controller
public function destroy(Matting $matting): RedirectResponse
{
Gate::authorize('check-access', ['matting', 'delete']);
return DB::transaction(function () use ($matting) {
try {
$pairName = ($matting->sire->nama_kucing ?? 'Sire') . ' x ' . ($matting->dam->nama_kucing ?? 'Dam');
+419 -138
View File
@@ -2,17 +2,24 @@
namespace App\Http\Controllers;
use App\Models\{Cat, User, Matting};
use App\Models\{Cat, User, Matting,Medicine};
use App\Traits\LogsActivity;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
use Illuminate\Support\Facades\{Storage, DB, Gate}; // Tambahkan Gate
use Illuminate\Support\Facades\{Storage, DB, Gate,Cache}; // Tambahkan Gate
use Illuminate\Support\Carbon;
use Barryvdh\DomPDF\Facade\Pdf;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Intervention\Image\Laravel\Facades\Image;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;
use App\Imports\CatWeightImport;
use Maatwebsite\Excel\Facades\Excel;
use App\Exports\CatWeightTemplateExport;
use Maatwebsite\Excel\Validators\ValidationException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class CatController extends Controller
{
@@ -21,47 +28,106 @@ class CatController extends Controller
/**
* Menampilkan daftar kucing dengan filter cerdas.
*/
public function index(Request $request): View
{
// Proteksi Akses: Read
Gate::authorize('check-access', ['cats', 'read']);
public function index(Request $request): View
{
// Proteksi Akses: Read
Gate::authorize('check-access', ['cats', 'read']);
$query = Cat::query()->with(['pic', 'sire', 'dam']);
$now = now();
$query = Cat::query()->with(['pic:id,name', 'sire', 'dam', 'registrations.association']);
// 1. Pencarian Global
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_kucing', 'like', "%{$search}%")
->orWhere('ras', 'like', "%{$search}%")
->orWhere('microchip', 'like', "%{$search}%")
->orWhere('pedigree', 'like', "%{$search}%")
->orWhere('kode_ems', 'like', "%{$search}%");
});
}
// 2. Filter Spesifik
if ($request->filled('status')) $query->where('status', $request->status);
if ($request->filled('ras')) $query->where('ras', $request->ras);
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
$cats = $query->latest()->paginate(15)->withQueryString();
// 3. Statistik Dashboard (Tetap Utuh)
$totalCats = Cat::count();
$safeWords = ['sehat', 'normal', 'aktif', 'sembuh', '-', 'tidak ada', 'clean'];
$sickCats = Cat::whereNotNull('riwayat_sakit_terakhir')
->where(function($q) use ($safeWords) {
foreach ($safeWords as $word) {
$q->where('riwayat_sakit_terakhir', 'NOT LIKE', "%{$word}%");
}
})->count();
$breeds = Cat::distinct()->pluck('ras');
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
// 1. PENCARIAN GLOBAL
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_kucing', 'like', "%{$search}%")
->orWhere('ras', 'like', "%{$search}%")
->orWhere('microchip', 'like', "%{$search}%")
->orWhere('pedigree', 'like', "%{$search}%")
->orWhere('kode_ems', 'like', "%{$search}%")
->orWhere('lokasi_kandang', 'like', "%{$search}%")
->orWhereHas('pic', function($sub) use ($search) {
$sub->where('name', 'like', "%{$search}%");
});
});
}
// 2. FILTER OVERDUE (Link dari Dashboard)
if ($request->filled('filter_overdue')) {
$filter = $request->get('filter_overdue');
$query->where(function($q) use ($filter, $now) {
if ($filter === 'vaccine') {
$q->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now);
} elseif ($filter === 'flea' || $filter === 'obat_kutu') {
$q->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now);
} elseif ($filter === 'deworming' || $filter === 'obat_cacing') {
$q->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now);
} elseif ($filter === 'grooming') {
$q->whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now);
}
});
}
// --- TAMBAHAN: 2.1 FILTER KESEHATAN (Link dari Dashboard Under Treatment) ---
if ($request->get('filter_health') === 'treatment') {
// Menggunakan kolom treatment_status: 1 (Treatment), 2 (Healthy)
$query->where('treatment_status', 1);
}
// 3. FILTER STATUS & LOGIKA USIA DINAMIS
if ($request->filled('status')) {
$status = $request->status;
if (in_array(strtolower($status), ['adult', 'dewasa'])) {
// Dewasa: > 12 bulan
$query->where('tanggal_lahir', '<=', $now->copy()->subMonths(12));
} elseif ($status === 'Junior') {
// Junior: 7 bulan - 12 bulan
$query->whereBetween('tanggal_lahir', [
$now->copy()->subMonths(12)->addDay(),
$now->copy()->subMonths(7)
]);
} elseif ($status === 'Kitten') {
// Kitten: < 7 bulan
$query->where('tanggal_lahir', '>', $now->copy()->subMonths(7));
} else {
$query->where('status', $status);
}
}
// 4. FILTER STANDAR (Ras & Gender)
if ($request->filled('ras')) $query->where('ras', $request->ras);
if ($request->filled('gender')) $query->where('jenis_kelamin', $request->gender);
// 5. VALIDASI SORTING
$allowedSorts = ['nama_kucing', 'lokasi_kandang', 'berat_badan', 'created_at', 'pic_name'];
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'created_at';
$direction = strtolower($request->get('direction')) === 'asc' ? 'asc' : 'desc';
if ($sort === 'pic_name') {
$query->leftJoin('users', 'cats.pic_id', '=', 'users.id')
->select('cats.*')
->orderBy('users.name', $direction);
} else {
$query->orderBy("cats.$sort", $direction);
}
// 6. EKSEKUSI DENGAN PAGINASI
$cats = $query->paginate(15)->withQueryString();
// 7. STATISTIK (DIPERBAIKI: Menggunakan treatment_status)
$totalCats = Cat::count();
// Perbaikan: Tidak lagi menggunakan string search yang tidak akurat (15 vs 4)
// Sekarang langsung merujuk pada kolom treatment_status bernilai 1
$sickCats = Cat::where('treatment_status', 1)->count();
$breeds = Cat::distinct()->whereNotNull('ras')->pluck('ras');
return view('pages.cats.index', compact('cats', 'totalCats', 'sickCats', 'breeds'));
}
public function create(): View
{
// Proteksi Akses: Create
@@ -75,47 +141,93 @@ class CatController extends Controller
}
public function store(Request $request): RedirectResponse
{
Gate::authorize('check-access', ['cats', 'create']);
{
Gate::authorize('check-access', ['cats', 'create']);
$validated = $this->validateCat($request);
// 1. Validasi
$validated = $this->validateCat($request);
return DB::transaction(function () use ($request, $validated) {
if ($request->hasFile('foto')) {
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
}
return DB::transaction(function () use ($request, $validated) {
// 2. Handling Upload Foto (Fix: Encoding Intervention v3)
if ($request->hasFile('foto')) {
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
}
if (isset($validated['berat_badan'])) {
$validated['berat_terakhir'] = $validated['berat_badan'];
}
// 3. Handling Berat Badan (Sederhanakan)
// Cukup isi berat_badan, Model Cat akan otomatis mengisi berat_terakhir (via static::saving)
if ($request->filled('berat_badan')) {
$validated['berat_badan'] = $request->berat_badan;
}
if (empty($validated['status'])) {
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
}
// 4. Handling Status Otomatis
if (empty($validated['status'])) {
$validated['status'] = ($request->induk_jantan_id || $request->induk_betina_id) ? 'Kitten' : 'Active';
}
// Memastikan registry disimpan sebagai JSON jika model tidak melakukan casting
if (isset($validated['registry'])) {
$validated['registry'] = $validated['registry'];
}
// 5. Simpan Data
// Pastikan kolom-kolom induk juga masuk ke validated jika ada di request
$validated['induk_jantan_id'] = $request->induk_jantan_id;
$validated['induk_betina_id'] = $request->induk_betina_id;
$validated['pic_id'] = $request->pic_id ?? auth()->id();
$cat = Cat::create($validated);
$this->logAction('CREATED', $cat, "Mendaftarkan pasien baru: {$cat->nama_kucing}", 'Cat');
$cat = Cat::create($validated);
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
});
}
// 6. Logging (Pastikan Trait LogsActivity & Tabel Log sudah punya kolom 'model')
try {
$this->logAction('CREATED', $cat, "Mendaftarkan kucing baru: {$cat->nama_kucing}", 'Cat');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning("Log gagal dicatat: " . $e->getMessage());
}
return redirect()->route('cats.index')->with('success', "{$cat->nama_kucing} berhasil didaftarkan.");
});
}
/**
* Fix: Perbaikan cara simpan gambar pada Intervention Image v3
*/
private function handleImageUpload($file): string
{
$filename = 'cat_' . uniqid() . '.png';
$path = 'cats/' . $filename;
// Intervention v3: Gunakan encode() atau toPng() lalu simpan sebagai string
$img = Image::read($file)->cover(500, 500, 'center');
// Simpan ke disk public
Storage::disk('public')->put($path, $img->toPng()->toString());
return $path;
}
public function show(Cat $cat): View
{
Gate::authorize('check-access', ['cats', 'read']);
{
// 1. Otorisasi (TETAP ADA)
Gate::authorize('check-access', ['cats', 'read']);
$cat->load([
'treatments' => fn($q) => $q->latest(),
'sire', 'dam', 'pic',
'weightHistories' => fn($q) => $q->latest()->limit(12)
]);
return view('pages.cats.show', compact('cat'));
}
// 2. Eager Loading (DITAMBAHKAN medicalRecords & performer)
// Kita memuat medicalRecords agar tidak terjadi query berulang di dalam Loop Blade (N+1 Problem)
$cat->load([
'treatments' => fn($q) => $q->latest(),
'medicalRecords' => fn($q) => $q->latest(), // Baru: Riwayat Vaksin/Obat Cacing
'medicalRecords.performer', // Baru: Siapa yang melakukan tindakan
'sire',
'dam',
'pic',
'weightHistories' => fn($q) => $q->latest()->limit(12),
'registrations.association',
'registrations.documents'
]);
// 3. Ambil Data Inventori (Baru: Untuk Datalist di Modal Input)
// Menampilkan semua obat yang stoknya masih tersedia
$availableMedicines = Medicine::where('qty', '>', 0)
->orderBy('nama_obat', 'asc')
->get(['nama_obat', 'qty', 'unit']);
// 4. Return View dengan Compact (DITAMBAHKAN availableMedicines)
return view('pages.cats.show', compact('cat', 'availableMedicines'));
}
public function edit(Cat $cat): View
{
@@ -130,30 +242,46 @@ class CatController extends Controller
}
public function update(Request $request, Cat $cat): RedirectResponse
{
Gate::authorize('check-access', ['cats', 'update']);
{
// 1. Otorisasi Akses
Gate::authorize('check-access', ['cats', 'update']);
$validated = $this->validateCat($request, $cat->id);
// 2. Validasi (Mengirimkan $cat->id agar Rule:unique mengabaikan record ini sendiri)
$validated = $this->validateCat($request, $cat->id);
return DB::transaction(function () use ($request, $validated, $cat) {
if ($request->hasFile('foto')) {
if ($cat->foto) Storage::disk('public')->delete($cat->foto);
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
return DB::transaction(function () use ($request, $validated, $cat) {
// 3. Handling Foto
if ($request->hasFile('foto')) {
// Hapus foto lama jika ada
if ($cat->foto && Storage::disk('public')->exists($cat->foto)) {
Storage::disk('public')->delete($cat->foto);
}
// Upload foto baru menggunakan helper yang sudah diperbaiki
$validated['foto'] = $this->handleImageUpload($request->file('foto'));
}
if ($request->filled('berat_badan')) {
$validated['berat_terakhir'] = $request->berat_badan;
if ($cat->berat_badan != $request->berat_badan) {
$cat->weightHistories()->create(['berat' => $request->berat_badan]);
}
}
/**
* 4. PENYEMPURNAAN REGISTRY (FIX UPDATE)
* Jika di form semua checkbox tidak dicentang, $request->registry akan kosong.
* Kita paksa nilainya menjadi ['NON-PED'] agar data lama di DB terhapus/terganti.
*/
$validated['registry'] = $request->input('registry', ['NON-PED']);
$cat->update($validated);
$this->logAction('UPDATED', $cat, "Update data medis/profil: {$cat->nama_kucing}", 'Cat');
// 5. Update Data Utama
// Logika berat_badan sudah otomatis dihandle oleh Model (static::saving/updated)
$cat->update($validated);
return redirect()->route('cats.index')->with('success', 'Data berhasil diperbarui.');
});
}
// 6. Activity Log (Dibalut Try-Catch agar proses update tidak gagal hanya karena log)
try {
$this->logAction('UPDATED', $cat, "Memperbarui profil medis & identitas: {$cat->nama_kucing}", 'Cat');
} catch (\Exception $e) {
\Illuminate\Support\Facades\Log::warning("Activity Log gagal dicatat: " . $e->getMessage());
}
return redirect()->route('cats.index')->with('success', "Data {$cat->nama_kucing} berhasil diperbarui.");
});
}
public function destroy(Cat $cat): RedirectResponse
{
@@ -189,71 +317,224 @@ class CatController extends Controller
]);
}
/**
* PDF ID Card / Medical Pass
* PENYEMPURNAAN: Perbaikan Barcode (Base64 SVG)
*/
public function downloadPdf(Cat $cat)
//cetak download health ledger pdf
public function downloadReport(Cat $cat)
{
Gate::authorize('check-access', ['cats', 'read']);
$cat->load([
'medicalRecords' => fn($q) => $q->orderBy('date_given', 'desc'),
'medicalRecords.performer',
'treatments' => fn($q) => $q->orderBy('tanggal', 'desc'), // <--- Pastikan ini ada
'sire',
'dam',
'weightHistories' => fn($q) => $q->orderBy('created_at', 'desc')
]);
$groupedHistory = $cat->medicalRecords->groupBy('type');
$pdf = Pdf::loadView('pages.reports.medical_history', compact('cat', 'groupedHistory'))
->setPaper('a4', 'portrait');
$filename = "Medical_Report_" . Str::slug($cat->nama_kucing) . "_" . date('Ymd') . ".pdf";
return $pdf->download($filename);
}
public function bulkWeightPage(): View
{
Gate::authorize('check-access', ['cats', 'read']);
$cat->load(['pic', 'sire', 'dam']);
Gate::authorize('check-access', ['cats', 'update']);
// FIX: Konversi SVG ke Base64 agar tampil di domPDF tanpa Imagick
$qrCodeRaw = QrCode::format('svg')
->size(150)
->margin(1)
->generate(route('cats.show', $cat->id));
$qrCode = base64_encode($qrCodeRaw);
return view('pages.cats.bulk-weight');
}
$pdf = Pdf::loadView('pages.cats.pdf-card', [
'cat' => $cat,
'qrCode' => $qrCode, // Dikirim sebagai Base64
'printedAt' => Carbon::now()->translatedFormat('d F Y H:i')
public function downloadWeightTemplate(): BinaryFileResponse
{
Gate::authorize('check-access', ['cats', 'update']);
// Menambahkan Jam & Menit agar nama file lebih spesifik
$fileName = "Template_Bulk_BB_" . date('Ymd_Hi') . ".xlsx";
return Excel::download(new CatWeightTemplateExport, $fileName);
}
public function uploadWeight(Request $request): RedirectResponse
{
Gate::authorize('check-access', ['cats', 'update']);
$request->validate([
// Tambahkan rule 'file' untuk memastikan inputan benar-benar file
'file_excel' => 'required|file|mimes:xlsx,xls,csv|max:5120',
], [
'file_excel.required' => 'File Excel wajib diunggah.',
'file_excel.mimes' => 'Format file harus berupa Excel (.xlsx, .xls) atau CSV.',
'file_excel.max' => 'Ukuran file maksimal adalah 5MB.',
]);
return $pdf->setPaper([0, 0, 300, 500], 'portrait')
->stream('MEDICAL_PASS_' . strtoupper($cat->nama_kucing) . '.pdf');
try {
// 1. Masukkan class import ke dalam variabel
$importHelper = new CatWeightImport();
// 2. Jalankan proses import menggunakan variabel tersebut
Excel::import($importHelper, $request->file('file_excel'));
// 3. Ambil jumlah data yang berhasil diproses
$totalBerhasil = $importHelper->getImportedCount();
// 4. Sisipkan jumlahnya ke dalam pesan sukses
return redirect()->route('cats.index')
->with('success', "Data berat badan massal berhasil diproses! Sebanyak {$totalBerhasil} record berhasil disinkronisasi ke dalam database.");
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
return back()->with('error', 'Tipe file tidak dikenali. Pastikan file tidak rusak (corrupt).');
} catch (\Exception $e) {
// MENANGKAP ERROR SISTEM / DATABASE
return back()->with('error', 'Gagal memproses file Excel. Pastikan format kolom sesuai template. Error: ' . $e->getMessage());
}
}
/**
* Simpan/Update Sertifikat Pedigree
*/
public function uploadPedigree(Request $request, Cat $cat)
{
Gate::authorize('check-access', ['cats', 'update']);
$request->validate(['sertifikat_ped_file' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240']);
$registration = $cat->registrations()->where('is_primary', true)->first() ?: $cat->registrations()->first();
if (!$registration) {
return redirect()->route('cats.pedigree', $cat)->with('error', 'Tambahkan registrasi asosiasi terlebih dahulu sebelum mengunggah sertifikat.');
}
$path = $request->file('sertifikat_ped_file')->store('cat-registrations/' . $cat->id, 'public');
$registration->documents()->create([
'label' => 'Sertifikat Pedigree',
'document_type' => 'pedigree',
'file_path' => $path,
]);
return redirect()->route('cats.pedigree', $cat)->with('success', 'Sertifikat pedigree berhasil ditambahkan.');
}
/* -------------------------------------------------------------------------- */
/* HELPERS (TETAP UTUH) */
/* -------------------------------------------------------------------------- */
private function validateCat(Request $request, $id = null)
private function validateCat(Request $request, $id = null)
{
return $request->validate([
// Identitas Dasar
'nama_kucing' => 'required|string|max:255',
'ras' => 'required|string|max:100',
'jenis_kelamin' => 'required|in:Jantan,Betina',
'tanggal_lahir' => 'nullable|date|before_or_equal:today',
// Media
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
// Registry & Legalitas (Ditambahkan NON-PED & Unik)
'registry' => 'nullable|array',
'registry.*' => ['string', \Illuminate\Validation\Rule::in(['ICA', 'CFA', 'WCF', 'FFF', 'NON-PED'])],
'pedigree' => [
'nullable', 'string', 'max:255',
Rule::unique('cats', 'pedigree')->ignore($id)
],
'microchip' => [
'nullable', 'string', 'max:255',
Rule::unique('cats', 'microchip')->ignore($id)
],
'kode_ems' => 'nullable|string|max:50',
// Status & Lokasi
'status' => 'nullable|string|max:50',
'lokasi_kandang'=> 'nullable|string|max:255',
'pic_id' => 'nullable|exists:users,id',
// Silsilah (Validasi Induk harus sesuai Gender)
'induk_jantan_id' => [
'nullable',
Rule::exists('cats', 'id')->where(function ($query) {
$query->where('jenis_kelamin', 'Jantan');
}),
],
'induk_betina_id' => [
'nullable',
Rule::exists('cats', 'id')->where(function ($query) {
$query->where('jenis_kelamin', 'Betina');
}),
],
// Medis & Monitoring
'berat_badan' => 'nullable|numeric|min:0|max:20',
'riwayat_sakit_terakhir' => 'nullable|string',
'jadwal_vaksin' => 'nullable|date',
'jadwal_obat_cacing' => 'nullable|date',
'jadwal_obat_kutu' => 'nullable|date',
'jadwal_grooming' => 'nullable|date',
], [
// Custom Message (Opsional agar user paham kenapa gagal)
'induk_jantan_id.exists' => 'Induk jantan harus kucing dengan jenis kelamin Jantan.',
'induk_betina_id.exists' => 'Induk betina harus kucing dengan jenis kelamin Betina.',
'microchip.unique' => 'Nomor Microchip sudah terdaftar di sistem.',
'tanggal_lahir.before_or_equal' => 'Tanggal lahir tidak boleh melebihi hari ini.',
]);
}
public function completeTreatment(Request $request, Cat $cat)
{
return $request->validate([
'nama_kucing' => 'required|string|max:255',
'ras' => 'required|string',
'jenis_kelamin' => 'required|in:Jantan,Betina',
'tanggal_lahir' => 'nullable|date',
'foto' => 'nullable|image|mimes:jpg,jpeg,png|max:3072',
'registry' => 'nullable|array',
'registry.*' => 'string',
'pedigree' => 'nullable|string|max:255',
'microchip' => 'nullable|string|max:255',
'kode_ems' => 'nullable|string|max:255',
'status' => 'nullable|string',
'induk_jantan_id' => 'nullable|exists:cats,id',
'induk_betina_id' => 'nullable|exists:cats,id',
'lokasi_kandang' => 'nullable|string',
'pic_id' => 'nullable|exists:users,id',
'berat_badan' => 'nullable|numeric|min:0',
'riwayat_sakit_terakhir' => 'nullable|string',
'jadwal_vaksin' => 'nullable|date',
'jadwal_obat_cacing' => 'nullable|date',
'jadwal_obat_kutu' => 'nullable|date',
'jadwal_grooming' => 'nullable|date',
Gate::authorize('check-access', ['cats', 'update']);
$request->validate([
'treatment_id' => 'required|exists:treatments,id',
'remark_pasca_sakit' => 'required|string|max:500',
]);
// ========================================================
// 1. EKSEKUSI PAKSA TABEL TREATMENTS (JALUR KERAS)
// DB::table akan menembus langsung ke database MySQL Anda.
// ========================================================
DB::table('treatments')
->where('id', $request->treatment_id)
->update([
'status_perawatan' => 2,
'remark_pasca_sakit' => $request->remark_pasca_sakit,
'updated_at' => now(),
]);
// Ambil nama keluhan untuk dimunculkan di notifikasi
$namaKeluhan = DB::table('treatments')->where('id', $request->treatment_id)->value('keluhan');
// ========================================================
// 2. CEK SISA PENYAKIT (Adakah penyakit lain di kucing ini yang masih = 1?)
// ========================================================
$sisaPenyakitAktif = DB::table('treatments')
->where('cat_id', $cat->id)
->where('status_perawatan', 1)
->exists();
// ========================================================
// 3. EKSEKUSI TABEL CATS BERDASARKAN HASIL CEK
// ========================================================
if (!$sisaPenyakitAktif) {
// JIKA SUDAH TIDAK ADA PENYAKIT LAIN -> Kucing Full Sehat (2)
$cat->treatment_status = 2;
$cat->save();
return redirect()->back()->with('success', "Penyakit '{$namaKeluhan}' disembuhkan. Kucing sekarang berstatus SEHAT sepenuhnya!");
}
// JIKA MASIH ADA PENYAKIT LAIN -> Pastikan kucing TETAP berstatus Treatment (1)
$cat->treatment_status = 1;
$cat->save();
return redirect()->back()->with('warning', "Penyakit '{$namaKeluhan}' disembuhkan, namun kucing MASIH DALAM PERAWATAN untuk keluhan lainnya.");
}
private function handleImageUpload($file): string
public function pedigree(Cat $cat)
{
$filename = 'cat_' . uniqid() . '.png';
$path = 'cats/' . $filename;
$img = Image::read($file)->cover(500, 500, 'center')->toPng();
Storage::disk('public')->put($path, (string) $img);
return $path;
return view('pages.cats.pedigree', compact('cat'));
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers;
use App\Models\Cat;
use App\Models\CatAssociation;
use App\Models\CatRegistration;
use App\Models\CatRegistrationDocument;
use App\Traits\LogsActivity;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
class CatRegistrationController extends Controller
{
use LogsActivity;
public function index(Cat $cat)
{
Gate::authorize('check-access', ['cats', 'read']);
$cat->load(['registrations.association', 'registrations.documents']);
$associations = CatAssociation::where('is_active', true)->orderBy('code')->get();
return view('pages.cats.pedigree', compact('cat', 'associations'));
}
public function store(Request $request, Cat $cat)
{
Gate::authorize('check-access', ['cats', 'update']);
$validated = $this->validateRegistration($request, $cat);
return DB::transaction(function () use ($request, $validated, $cat) {
$associationId = $this->resolveAssociation($request);
if ($cat->registrations()->where('association_id', $associationId)->exists()) {
return back()->withErrors(['association_id' => 'Asosiasi ini sudah terdaftar untuk kucing tersebut.']);
}
if ($request->boolean('is_primary')) {
$cat->registrations()->update(['is_primary' => false]);
}
$registration = $cat->registrations()->create(array_merge($validated, [
'association_id' => $associationId,
'is_primary' => $request->boolean('is_primary') || !$cat->registrations()->exists(),
]));
$this->storeDocumentIfPresent($request, $registration);
$this->logAction('CAT_REGISTRATION_CREATED', $cat, 'Menambahkan registrasi pedigree pada ' . $registration->association->code, 'Cat');
return back()->with('success', 'Registrasi pedigree berhasil ditambahkan.');
});
}
public function update(Request $request, Cat $cat, CatRegistration $registration)
{
Gate::authorize('check-access', ['cats', 'update']);
abort_unless($registration->cat_id === $cat->id, 404);
$validated = $this->validateRegistration($request, $cat, $registration);
if ($request->boolean('is_primary')) {
$cat->registrations()->whereKeyNot($registration->id)->update(['is_primary' => false]);
}
$registration->update(array_merge($validated, ['is_primary' => $request->boolean('is_primary')]));
return back()->with('success', 'Registrasi pedigree berhasil diperbarui.');
}
public function destroy(Cat $cat, CatRegistration $registration)
{
Gate::authorize('check-access', ['cats', 'update']);
abort_unless($registration->cat_id === $cat->id, 404);
$wasPrimary = $registration->is_primary;
$registration->delete();
if ($wasPrimary && ($next = $cat->registrations()->oldest()->first())) {
$next->update(['is_primary' => true]);
}
return back()->with('success', 'Registrasi pedigree berhasil dihapus.');
}
public function storeDocument(Request $request, Cat $cat, CatRegistration $registration)
{
Gate::authorize('check-access', ['cats', 'update']);
abort_unless($registration->cat_id === $cat->id, 404);
$request->validate([
'document' => 'required|file|mimes:pdf,jpg,jpeg,png|max:10240',
'label' => 'nullable|string|max:120',
'document_type' => ['nullable', Rule::in(['pedigree','registration','ownership','title','other'])],
'issued_at' => 'nullable|date',
]);
$this->storeDocumentIfPresent($request, $registration, 'document');
return back()->with('success', 'Dokumen berhasil ditambahkan.');
}
public function destroyDocument(Cat $cat, CatRegistration $registration, CatRegistrationDocument $document)
{
Gate::authorize('check-access', ['cats', 'update']);
abort_unless($registration->cat_id === $cat->id && $document->cat_registration_id === $registration->id, 404);
Storage::disk('public')->delete($document->file_path);
$document->delete();
return back()->with('success', 'Dokumen berhasil dihapus.');
}
private function validateRegistration(Request $request, Cat $cat, ?CatRegistration $registration = null): array
{
return $request->validate([
'registration_number' => 'nullable|string|max:100',
'pedigree_number' => 'nullable|string|max:100',
'certificate_number' => 'nullable|string|max:100',
'issue_date' => 'nullable|date',
'title' => 'nullable|string|max:120',
'ownership_status' => ['nullable', Rule::in(['milik_sendiri','co_own','transfer','lainnya'])],
'notes' => 'nullable|string|max:1000',
'is_primary' => 'nullable|boolean',
'association_id' => $registration ? 'nullable' : 'required',
'association_code' => 'nullable|string|max:30',
'association_name' => 'nullable|string|max:120',
'certificate_file' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
]);
}
private function resolveAssociation(Request $request): int
{
if ($request->association_id !== 'custom') {
return (int) CatAssociation::whereKey($request->association_id)->where('is_active', true)->firstOrFail()->id;
}
$request->validate(['association_code' => 'required|string|max:30', 'association_name' => 'required|string|max:120']);
return CatAssociation::firstOrCreate(
['code' => strtoupper(trim($request->association_code))],
['name' => trim($request->association_name), 'is_active' => true]
)->id;
}
private function storeDocumentIfPresent(Request $request, CatRegistration $registration, string $field = 'certificate_file'): void
{
if (!$request->hasFile($field)) return;
$path = $request->file($field)->store('cat-registrations/' . $registration->cat_id, 'public');
$registration->documents()->create([
'label' => $request->input('label') ?: 'Sertifikat Pedigree',
'document_type' => $request->input('document_type') ?: 'pedigree',
'file_path' => $path,
'issued_at' => $request->input('issued_at') ?: $request->input('issue_date'),
]);
}
}
+39 -11
View File
@@ -10,6 +10,7 @@ use App\Traits\LogsActivity;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Gate;
class CategoryController extends Controller
{
@@ -18,13 +19,41 @@ class CategoryController extends Controller
/**
* Menampilkan daftar kategori.
*/
public function index(): View
{
// Mengambil kategori beserta hitungan item di dalamnya (optional untuk insight)
$categories = Category::latest()->get();
return view('pages.inventory.categories.index', compact('categories'));
public function index(Request $request): View
{
// 1. Proteksi Akses (Sesuai Matriks Role Management)
Gate::authorize('check-access', ['categories', 'read']);
$query = Category::query();
// 2. SEARCH GLOBAL (Mencari di Nama & Keywords)
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('keywords', 'like', "%{$search}%");
});
}
// 3. LOGIKA SORTING DINAMIS
$allowedSorts = ['name', 'created_at'];
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'name';
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
// 4. EKSEKUSI DENGAN PAGINASI (Standarisasi 15 item per halaman)
$categories = $query->orderBy($sort, $direction)
->paginate(15)
->withQueryString();
// 5. STATISTIK RINGKAS
$totalCategories = Category::count();
$latestCategory = Category::latest()->first()?->name ?? '-';
return view('pages.inventory.categories.index', compact(
'categories', 'totalCategories', 'latestCategory'
));
}
/**
* Menyimpan kategori baru.
*/
@@ -46,13 +75,12 @@ class CategoryController extends Controller
* Menampilkan halaman edit (Hanya Admin).
*/
public function edit(Category $category): View
{
if (auth()->user()->role !== 'admin') {
abort(403, 'Akses Ditolak.');
}
{
Gate::authorize('check-access', ['categories', 'update']);
return view('pages.inventory.categories.edit', compact('category'));
}
return view('pages.inventory.categories.edit', compact('category'));
}
/**
* Memperbarui data kategori.
+423 -56
View File
@@ -3,86 +3,453 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment};
use App\Models\{Cat, Medicine, Food, Supply, Matting, Treatment, Kitten};
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Pagination\LengthAwarePaginator;
class DashboardController extends Controller
{
public function index()
public function index(Request $request)
{
// 1. Statistik Utama
$now = Carbon::now();
$selectedMonth = $request->get('month', $now->format('m'));
$selectedYear = $request->get('year', $now->format('Y'));
$selectedGender = $request->get('gender');
$selectedStatus = $request->get('status');
$stats = [
'total_cats' => Cat::count(),
'total_staff' => \App\Models\User::count(),
'sick_cats' => Cat::whereNotNull('riwayat_sakit_terakhir')->count(),
'total_kittens' => Kitten::count(),
'sick_cats' => Cat::where('treatment_status', 1)->count(),
'total_mating' => Matting::where('status', 'proses')->count(),
];
// 2. Birth Watch: Persalinan 7 Hari ke Depan (Rumus: H+63)
$upcomingBirths = Matting::with(['dam', 'sire', 'pic'])
$upcomingBirths = Matting::with(['dam', 'sire'])
->where('status', 'proses')
->whereNotNull('tanggal_kawin')
->whereBetween('tanggal_kawin', [
now()->subDays(70)->toDateString(),
now()->subDays(56)->toDateString()
])
->whereNotNull('tgl_kawin_awal')
->get()
->map(function ($mating) {
$mating->edd = Carbon::parse($mating->tanggal_kawin)->addDays(63);
$mating->days_to_go = (int) now()->diffInDays($mating->edd, false);
->map(function ($mating) use ($now) {
$mating->edd = Carbon::parse($mating->tgl_kawin_awal)->addDays(63);
$mating->days_to_go = (int) $now->copy()->startOfDay()->diffInDays($mating->edd->copy()->startOfDay(), false);
return $mating;
})
->filter(fn($m) => $m->days_to_go >= -2 && $m->days_to_go <= 7)
->filter(fn ($mating) => $mating->days_to_go >= -2 && $mating->days_to_go <= 7)
->sortBy('days_to_go');
// 3. Medical Alerts
$medicalAlerts = [
'vaccine' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<=', now()->addDays(3))->count(),
'flea' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<=', now()->addDays(3))->count(),
'deworming' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<=', now()->addDays(3))->count(),
'grooming' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<=', now()->addDays(3))->count(),
$overdueAlerts = [
'vaccine' => [
'count' => Cat::whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'vaccine']),
],
'flea' => [
'count' => Cat::whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'flea']),
],
'deworming' => [
'count' => Cat::whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'deworming']),
],
'grooming' => [
'count' => Cat::whereNotNull('jadwal_grooming')->where('jadwal_grooming', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'grooming']),
],
];
// 4. Inventory Critical (Stok < 5)
$lowStock = [
'medicines' => Medicine::where('qty', '<', 5)->get(['nama_obat as name', 'qty', 'unit']),
'foods' => Food::where('qty', '<', 5)->get(['nama_makanan as name', 'qty', 'unit']),
'supplies' => Supply::where('qty', '<', 5)->get(['nama_perlengkapan as name', 'qty', 'unit']),
];
$totalLowStock = collect($lowStock)->flatten(1)->count();
// 5. Chart Data: Sebaran Ras (Persian, British Shorthair, dll)
$chartData = Cat::select('ras', DB::raw('count(*) as total'))->groupBy('ras')->get();
// 6. Weight Trends: Rata-rata Berat (6 Bulan Terakhir)
$weightTrends = DB::table('weight_histories')
/*
* Berat terakhir untuk daftar/ranking pada bulan yang dipilih.
* Kode ini dipertahankan agar fungsi dashboard lama tetap tersedia.
*/
$weightQuery = DB::table('weight_histories')
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
->select(
DB::raw('AVG(berat) as avg_weight'),
DB::raw("DATE_FORMAT(tanggal_timbang, '%M') as month"),
DB::raw('MONTH(tanggal_timbang) as month_num')
'cats.id as cat_id',
'cats.nama_kucing',
'cats.jenis_kelamin',
'cats.status',
'cats.tanggal_lahir',
'weight_histories.berat',
'weight_histories.tanggal_timbang'
)
->where('tanggal_timbang', '>=', now()->subMonths(6))
->groupBy('month', 'month_num')
->orderBy('month_num')
->get();
->whereIn('weight_histories.id', function ($query) use ($selectedMonth, $selectedYear) {
$query->select(DB::raw('MAX(id)'))
->from('weight_histories')
->whereMonth('tanggal_timbang', $selectedMonth)
->whereYear('tanggal_timbang', $selectedYear)
->groupBy('cat_id');
});
if ($weightTrends->isEmpty()) {
$weightTrends = collect([(object)['month' => now()->format('F'), 'avg_weight' => 0]]);
if ($selectedGender) {
$weightQuery->where('cats.jenis_kelamin', $selectedGender);
}
// 7. Recent Activity
$recentTreatments = Treatment::with(['cat', 'pic'])->latest()->take(5)->get();
if ($selectedStatus) {
if (in_array(strtolower($selectedStatus), ['adult', 'dewasa'])) {
$weightQuery->whereIn('cats.status', ['Adult', 'Dewasa']);
} else {
$weightQuery->where('cats.status', $selectedStatus);
}
}
$individualWeightData = $weightQuery
->orderBy('weight_histories.berat', 'desc')
->get()
->map(function ($cat) use ($now) {
$cat->usia = $this->formatAge($cat->tanggal_lahir, $now);
$cat->kategori_usia = $this->ageCategory($cat->tanggal_lahir, $cat->status, $now);
return $cat;
});
$weightSummary = [
'overall' => $individualWeightData->avg('berat') ?? 0,
'male' => $individualWeightData->where('jenis_kelamin', 'Jantan')->avg('berat') ?? 0,
'female' => $individualWeightData->where('jenis_kelamin', 'Betina')->avg('berat') ?? 0,
];
/*
* Pemantauan berat badan.
* Kategori umur dihitung dari tanggal lahir agar konsisten:
* Kitten < 7 bulan, Junior 7-11 bulan, Dewasa >= 12 bulan.
*/
$allowedTrendMonths = [3, 6, 12, 24];
$weightTrendMonths = (int) $request->get('weight_months', 6);
if (!in_array($weightTrendMonths, $allowedTrendMonths, true)) {
$weightTrendMonths = 6;
}
// Filter tabel dapat menampilkan semua kategori, sedangkan grafik individual
// selalu dibatasi pada satu kategori agar mudah dibaca.
$allowedTableCategories = ['Semua', 'Kitten', 'Junior', 'Dewasa'];
$weightTableCategory = $request->get('weight_category', 'Semua');
if (!in_array($weightTableCategory, $allowedTableCategories, true)) {
$weightTableCategory = 'Semua';
}
$allowedTrendCategories = ['Kitten', 'Junior', 'Dewasa'];
$defaultTrendCategory = $weightTableCategory !== 'Semua' ? $weightTableCategory : 'Kitten';
$weightTrendCategory = $request->get('trend_category', $defaultTrendCategory);
if (!in_array($weightTrendCategory, $allowedTrendCategories, true)) {
$weightTrendCategory = 'Kitten';
}
$trendStart = $now->copy()->subMonths($weightTrendMonths - 1)->startOfMonth();
$weightTrendRows = DB::table('weight_histories')
->join('cats', 'weight_histories.cat_id', '=', 'cats.id')
->select(
'cats.id as cat_id',
'cats.nama_kucing',
'cats.jenis_kelamin',
'cats.status',
'cats.tanggal_lahir',
'cats.foto',
'weight_histories.berat',
'weight_histories.tanggal_timbang'
)
->whereNull('cats.deleted_at')
->whereDate('weight_histories.tanggal_timbang', '>=', $trendStart->toDateString())
->orderBy('weight_histories.tanggal_timbang')
->orderBy('cats.nama_kucing')
->get()
->map(function ($row) use ($now) {
$row->kategori_usia = $this->ageCategory($row->tanggal_lahir, $row->status, $now);
$date = Carbon::parse($row->tanggal_timbang);
$row->tanggal_iso = $date->format('Y-m-d');
$row->bulan_iso = $date->format('Y-m');
return $row;
});
// Sumbu grafik dibuat per bulan, bukan per tanggal timbang, supaya garis tidak terlalu padat.
$categoryMonthKeys = collect();
for ($cursor = $trendStart->copy(); $cursor->lte($now); $cursor->addMonth()) {
$categoryMonthKeys->push($cursor->format('Y-m'));
}
$categoryAverageTrend = [
'labels' => $categoryMonthKeys,
'series' => collect(['Kitten', 'Junior', 'Dewasa'])->map(function ($category) use ($categoryMonthKeys, $weightTrendRows) {
$rows = $weightTrendRows->where('kategori_usia', $category);
return [
'name' => $category,
'data' => $categoryMonthKeys->map(function ($month) use ($rows) {
// Ambil berat terakhir setiap kucing pada bulan tersebut, lalu hitung rata-ratanya.
$latestPerCat = $rows
->where('bulan_iso', $month)
->groupBy('cat_id')
->map(fn ($catRows) => $catRows->last());
$value = $latestPerCat->avg('berat');
return $value === null ? null : round((float) $value, 2);
})->values(),
];
})->values(),
];
// Ringkasan berdasarkan berat terakhir setiap kucing pada rentang periode.
$latestPerCatInTrend = $weightTrendRows
->groupBy('cat_id')
->map(fn ($rows) => $rows->last());
$weightCategorySummary = collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($latestPerCatInTrend) {
$rows = $latestPerCatInTrend->where('kategori_usia', $category);
return [$category => [
'cats' => $rows->count(),
'average' => round((float) ($rows->avg('berat') ?? 0), 2),
'min' => $rows->isEmpty() ? 0 : round((float) $rows->min('berat'), 2),
'max' => $rows->isEmpty() ? 0 : round((float) $rows->max('berat'), 2),
]];
});
// KPI bulan berjalan dibanding bulan sebelumnya.
$currentMonthKey = $now->format('Y-m');
$previousMonthKey = $now->copy()->subMonth()->format('Y-m');
$latestByCatMonth = $weightTrendRows
->groupBy(fn ($row) => $row->cat_id . '|' . $row->bulan_iso)
->map(fn ($rows) => $rows->last());
$currentMonthRows = $latestByCatMonth->where('bulan_iso', $currentMonthKey)->values();
$previousMonthRows = $latestByCatMonth->where('bulan_iso', $previousMonthKey)->values();
$percentChange = static function (float $current, float $previous): float {
if ($previous <= 0) {
return $current > 0 ? 100.0 : 0.0;
}
return round((($current - $previous) / $previous) * 100, 1);
};
$weightKpis = [
'weighed' => [
'value' => $currentMonthRows->pluck('cat_id')->unique()->count(),
'change' => $percentChange(
(float) $currentMonthRows->pluck('cat_id')->unique()->count(),
(float) $previousMonthRows->pluck('cat_id')->unique()->count()
),
],
'categories' => collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($currentMonthRows, $previousMonthRows, $latestPerCatInTrend, $percentChange) {
$currentRows = $currentMonthRows->where('kategori_usia', $category);
$previousRows = $previousMonthRows->where('kategori_usia', $category);
// Jika bulan berjalan belum memiliki data, tetap tampilkan posisi berat terakhir
// tanpa mengarang persentase perubahan.
$currentAverage = $currentRows->isNotEmpty()
? (float) $currentRows->avg('berat')
: (float) ($latestPerCatInTrend->where('kategori_usia', $category)->avg('berat') ?? 0);
$previousAverage = (float) ($previousRows->avg('berat') ?? 0);
return [$category => [
'average' => round($currentAverage, 2),
'change' => $currentRows->isNotEmpty() && $previousRows->isNotEmpty()
? $percentChange($currentAverage, $previousAverage)
: 0.0,
'has_comparison' => $currentRows->isNotEmpty() && $previousRows->isNotEmpty(),
]];
}),
];
// Grafik tren kategori terpilih: maksimal 6 kucing dengan data terlengkap.
$selectedCategoryGroups = $weightTrendRows
->where('kategori_usia', $weightTrendCategory)
->groupBy('cat_id')
->sort(function ($a, $b) {
$countComparison = $b->count() <=> $a->count();
if ($countComparison !== 0) {
return $countComparison;
}
return strcmp((string) $b->last()->tanggal_iso, (string) $a->last()->tanggal_iso);
})
->take(6);
$weightTrendLabels = $categoryMonthKeys->values();
$weightTrendSeries = $selectedCategoryGroups
->map(function ($rows) use ($categoryMonthKeys) {
$first = $rows->first();
$monthlyValues = $rows->groupBy('bulan_iso')->map(fn ($monthRows) => round((float) $monthRows->last()->berat, 2));
return [
'cat_id' => (int) $first->cat_id,
'name' => $first->nama_kucing,
'category' => $first->kategori_usia,
'data' => $categoryMonthKeys->map(fn ($month) => $monthlyValues->get($month))->values(),
'latest' => round((float) $rows->last()->berat, 2),
];
})
->values();
// Tabel berat terbaru. Perubahan membandingkan dua catatan terakhir pada periode terpilih.
$weightLatestTable = $weightTrendRows
->groupBy('cat_id')
->map(function ($rows) {
$rows = $rows->sortBy('tanggal_timbang')->values();
$latest = $rows->last();
$previous = $rows->count() > 1 ? $rows->get($rows->count() - 2) : null;
return [
'cat_id' => (int) $latest->cat_id,
'name' => $latest->nama_kucing,
'gender' => $latest->jenis_kelamin,
'category' => $latest->kategori_usia,
'photo' => $latest->foto,
'latest' => round((float) $latest->berat, 2),
'previous' => $previous ? round((float) $previous->berat, 2) : null,
'change' => $previous ? round((float) $latest->berat - (float) $previous->berat, 2) : null,
'date' => $latest->tanggal_iso,
];
})
->when($weightTableCategory !== 'Semua', fn ($rows) => $rows->where('category', $weightTableCategory))
->sortByDesc('date')
->values();
// Inventory
$allExpiringItems = collect();
$lowStock = collect();
$inventoryTypes = [
'medicines' => ['model' => Medicine::class, 'name' => 'nama_obat'],
'foods' => ['model' => Food::class, 'name' => 'nama_makanan'],
'supplies' => ['model' => Supply::class, 'name' => 'nama_perlengkapan'],
];
foreach ($inventoryTypes as $type => $config) {
$modelClass = $config['model'];
$nameColumn = $config['name'];
$modelClass::where('qty', '<', 5)
->select("$nameColumn as name", 'qty', 'unit')
->get()
->each(function ($item) use ($lowStock, $type) {
$item->url = route("inventory.$type.index", ['search' => $item->name]);
$lowStock->push($item);
});
if (Schema::hasColumn((new $modelClass)->getTable(), 'expired_at')) {
$modelClass::whereNotNull('expired_at')
->where('expired_at', '<=', $now->copy()->addDays(30))
->get()
->each(function ($item) use ($allExpiringItems, $now, $type, $nameColumn) {
$expiredDate = Carbon::parse($item->expired_at);
$allExpiringItems->push((object) [
'name' => $item->$nameColumn,
'qty' => $item->qty,
'unit' => $item->unit,
'expired_at' => $item->expired_at,
'url' => route("inventory.$type.index", ['search' => $item->$nameColumn]),
'formatted_date' => $expiredDate->format('d M Y'),
'days_left' => (int) $now->copy()->startOfDay()->diffInDays($expiredDate->copy()->startOfDay(), false),
'is_expired' => $expiredDate->isPast(),
]);
});
}
}
$totalLowStock = $lowStock->count();
$sortedItems = $allExpiringItems->sortBy('days_left');
$perPage = 5;
$currentPage = $request->input('expiry_page', 1);
$offset = ($currentPage * $perPage) - $perPage;
$expiringItems = new LengthAwarePaginator(
$sortedItems->slice($offset, $perPage)->values(),
$sortedItems->count(),
$perPage,
$currentPage,
[
'path' => $request->url(),
'query' => $request->query(),
'pageName' => 'expiry_page',
]
);
$topDiseasesWeek = Treatment::select('diagnosa', DB::raw('count(*) as total'))
->where('tanggal', '>=', $now->copy()->subWeek())
->groupBy('diagnosa')
->orderBy('total', 'desc')
->get();
$topDiseasesMonth = Treatment::select('diagnosa', DB::raw('count(*) as total'))
->where('tanggal', '>=', $now->copy()->subMonth())
->groupBy('diagnosa')
->orderBy('total', 'desc')
->get();
$recentTreatments = Treatment::with(['cat'])->latest()->take(5)->get();
return view('pages.dashboard.index', compact(
'stats',
'upcomingBirths',
'medicalAlerts',
'lowStock',
'totalLowStock',
'chartData',
'weightTrends',
'recentTreatments'
));
'stats',
'upcomingBirths',
'overdueAlerts',
'individualWeightData',
'weightSummary',
'weightTrendLabels',
'weightTrendSeries',
'categoryAverageTrend',
'weightCategorySummary',
'weightKpis',
'weightLatestTable',
'weightTableCategory',
'weightTrendCategory',
'weightTrendMonths',
'lowStock',
'totalLowStock',
'expiringItems',
'selectedMonth',
'selectedYear',
'selectedGender',
'selectedStatus',
'topDiseasesWeek',
'topDiseasesMonth',
'recentTreatments'
));
}
}
private function ageCategory($birthDate, ?string $storedStatus, Carbon $referenceDate): string
{
if ($birthDate) {
$birth = Carbon::parse($birthDate);
$months = $birth->diffInMonths($referenceDate);
if ($months < 7) {
return 'Kitten';
}
if ($months < 12) {
return 'Junior';
}
return 'Dewasa';
}
$status = strtolower(trim((string) $storedStatus));
return match ($status) {
'kitten' => 'Kitten',
'junior' => 'Junior',
'adult', 'dewasa' => 'Dewasa',
default => 'Dewasa',
};
}
private function formatAge($birthDate, Carbon $referenceDate): string
{
if (!$birthDate) {
return '-';
}
$diff = Carbon::parse($birthDate)->diff($referenceDate);
$parts = [];
if ($diff->y > 0) {
$parts[] = $diff->y . ' th';
}
if ($diff->m > 0) {
$parts[] = $diff->m . ' bln';
}
if ($diff->y === 0 && $diff->d > 0) {
$parts[] = $diff->d . ' hr';
}
return $parts ? implode(' ', $parts) : '0 hr';
}
}
+59 -7
View File
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
class FoodController extends Controller
{
@@ -18,17 +19,68 @@ class FoodController extends Controller
* Tampilkan daftar stok makanan.
*/
public function index(Request $request): View
{
$query = Food::with('category');
{
// Proteksi Akses
Gate::authorize('check-access', ['foods', 'read']);
if ($request->filled('search')) {
$query->where('nama_makanan', 'like', '%' . $request->search . '%');
$now = now();
$query = Food::with('category');
// 1. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
if ($request->filled('filter')) {
$filter = $request->get('filter');
if ($filter === 'expired') {
// Menampilkan makanan yang sudah kadaluwarsa
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
} elseif ($filter === 'expiring_soon') {
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
$query->whereNotNull('expired_at')
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
} elseif ($filter === 'low_stock') {
// Menampilkan stok kritis
$query->where('qty', '<=', 5);
}
$foods = $query->latest()->paginate(10);
return view('pages.inventory.foods.index', compact('foods'));
}
// 2. SEARCH GLOBAL
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_makanan', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%");
});
}
// 3. SORTING DINAMIS (Menambahkan kembali expired_at)
$allowedSorts = ['nama_makanan', 'qty', 'expired_at', 'created_at'];
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_makanan';
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
$foods = $query->orderBy($sort, $direction)
->paginate(15)
->withQueryString();
// 4. STATISTIK (Komprehensif)
$totalStock = Food::sum('qty');
$lowStockCount = Food::where('qty', '<=', 5)->count();
// Hitung item yang expired (Wajib ada kolom expired_at di database)
$expiredCount = Food::whereNotNull('expired_at')
->where('expired_at', '<', $now)
->count();
$totalItems = Food::count();
return view('pages.inventory.foods.index', compact(
'foods',
'totalStock',
'lowStockCount',
'expiredCount',
'totalItems'
));
}
/**
* Tampilkan form tambah.
*/
@@ -3,15 +3,12 @@
namespace App\Http\Controllers;
use App\Models\Treatment;
use App\Models\Medicine;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MedicalDashboardController extends Controller
{
public function index()
{
// 1. Tren Penyakit Bulanan (Data 6 Bulan Terakhir)
$monthlyTrends = Treatment::select(
DB::raw('MONTHNAME(tanggal) as month'),
DB::raw('COUNT(*) as total')
@@ -21,20 +18,28 @@ class MedicalDashboardController extends Controller
->orderBy(DB::raw('MONTH(tanggal)'))
->get();
// 2. Daftar Obat Paling Sering Digunakan (Top 5)
$topMedicines = Treatment::select('nama_obat', DB::raw('SUM(qty_used) as total_used'))
->groupBy('nama_obat')
// treatments memakai medicine_id. Nama obat diambil dari tabel medicines.
$topObats = Treatment::query()
->join('medicines', 'treatments.medicine_id', '=', 'medicines.id')
->select(
'medicines.id',
'medicines.nama_obat',
DB::raw('SUM(treatments.qty_used) as total_used')
)
->whereNotNull('treatments.medicine_id')
->groupBy('medicines.id', 'medicines.nama_obat')
->orderByDesc('total_used')
->take(5)
->limit(5)
->get();
// 3. Keluhan/Penyakit yang Paling Sering Muncul
$topComplaints = Treatment::select('keluhan', DB::raw('COUNT(*) as total'))
->whereNotNull('keluhan')
->where('keluhan', '!=', '')
->groupBy('keluhan')
->orderByDesc('total')
->take(5)
->limit(5)
->get();
return view('pages.dashboard.medical', compact('monthlyTrends', 'topMedicines', 'topComplaints'));
return view('pages.dashboard.medical', compact('monthlyTrends', 'topObats', 'topComplaints'));
}
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Http\Controllers;
use App\Models\Cat;
use App\Models\Medicine;
use App\Models\MedicalRecord;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
use Exception;
class MedicalRecordController extends Controller
{
/**
* Simpan rekam medis individu (dari halaman show cat)
*/
public function store(Request $request)
{
Gate::authorize('check-access', ['cats', 'update']);
// Tambahan Validasi
$request->validate([
'cat_id' => 'required|exists:cats,id',
'type' => 'required|string',
'date_given' => 'required|date',
'next_schedule' => 'nullable|date|after_or_equal:date_given',
'medicines' => 'nullable|array',
]);
try {
return DB::transaction(function () use ($request) {
$type = $request->type;
$medicines = $request->input('medicines', []);
if ($type === 'Grooming') {
$this->createRecord($request->cat_id, $type, 'Routine Grooming', null, 'SESSION', $request);
} else {
foreach ($medicines as $med) {
if (empty($med['name'])) continue;
$doseValue = $this->parseFraction($med['dosage']);
// Pengecekan Stok Sebelum Potong
$item = Medicine::where('nama_obat', $med['name'])->first();
if ($item) {
if ($item->qty < $doseValue) {
throw new Exception("Stok obat '{$med['name']}' tidak mencukupi. Sisa: {$item->qty}");
}
$item->decrement('qty', $doseValue);
}
$this->createRecord($request->cat_id, $type, $med['name'], $med['dosage'], $med['unit'], $request);
}
}
// Update pengingat di tabel cats
$this->updateCatReminder($request->cat_id, $type, $request->next_schedule);
return back()->with('success', 'Medical Ledger berhasil dicatat dan jadwal otomatis diperbarui.');
});
} catch (Exception $e) {
return back()->with('error', 'Gagal mencatat rekam medis: ' . $e->getMessage())->withInput();
}
}
/**
* Tampilkan form Bulk Treatment
*/
/**
* Tampilkan form Bulk Treatment
*/
public function bulkCreate()
{
Gate::authorize('check-access', ['cats', 'update']);
// Mengambil kucing yang Sehat (2) maupun yang sedang Treatment (1)
$cats = Cat::whereIn('treatment_status', [1, 2])
->orderBy('nama_kucing', 'asc')
->get();
// Mengambil obat dengan stok yang valid (qty > 0)
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
return view('pages.cats.medical-bulk', compact('cats', 'availableMedicines'));
}
/**
* Simpan Bulk Treatment (Massal)
*/
public function bulkStore(Request $request)
{
Gate::authorize('check-access', ['cats', 'update']);
$request->validate([
'cat_ids' => 'required|array|min:1',
'type' => 'required',
'date_given' => 'required|date',
'next_schedule' => 'nullable|date|after_or_equal:date_given',
]);
try {
return DB::transaction(function () use ($request) {
$catIds = $request->cat_ids;
$medicines = $request->input('medicines', []);
$totalCats = count($catIds);
foreach ($catIds as $catId) {
if ($request->type === 'Grooming') {
$this->createRecord($catId, $request->type, 'Routine Grooming', null, 'SESSION', $request);
} else {
foreach ($medicines as $med) {
if (empty($med['name'])) continue;
$doseValue = $this->parseFraction($med['dosage']);
// Validasi stok untuk bulk (kebutuhan = dosis * jumlah kucing)
$item = Medicine::where('nama_obat', $med['name'])->first();
if ($item) {
if ($item->qty < $doseValue) {
throw new Exception("Stok '{$med['name']}' tidak cukup untuk pemrosesan massal.");
}
$item->decrement('qty', $doseValue);
}
$this->createRecord($catId, $request->type, $med['name'], $med['dosage'], $med['unit'], $request);
}
}
$this->updateCatReminder($catId, $request->type, $request->next_schedule);
}
return redirect()->route('cats.index')->with('success', "Bulk Treatment berhasil diterapkan pada $totalCats kucing.");
});
} catch (Exception $e) {
return back()->with('error', 'Gagal memproses bulk treatment: ' . $e->getMessage())->withInput();
}
}
/**
* Helper: Simpan data ke tabel medical_records
*/
/**
* Helper: Simpan data ke tabel medical_records
*/
private function createRecord($catId, $type, $name, $dosage, $unit, $request)
{
return MedicalRecord::create([
'cat_id' => $catId,
'user_id' => auth()->id(), // Ini adalah kolom yang benar sesuai migration kita
'type' => $type,
'treatment_name' => $name,
'dosage' => $this->parseFraction($dosage),
'unit' => $unit,
'date_given' => $request->date_given,
'next_schedule' => $request->next_schedule,
'notes' => $request->notes
// HAPUS baris 'performed_by' di sini
]);
}
/**
* Helper: Update kolom jadwal di tabel cats
*/
private function updateCatReminder($catId, $type, $nextDate)
{
if (!$nextDate) return;
$mapping = [
'Vaccine' => 'jadwal_vaksin',
'Deworming' => 'jadwal_obat_cacing',
'Flea Treatment' => 'jadwal_obat_kutu',
'Grooming' => 'jadwal_grooming',
];
if (isset($mapping[$type])) {
Cat::where('id', $catId)->update([$mapping[$type] => $nextDate]);
}
}
/**
* Helper: Konversi pecahan (1/2) ke desimal (0.5)
*/
private function parseFraction($fraction)
{
if (is_null($fraction)) return 0;
$fraction = str_replace(' ', '', $fraction); // Hapus spasi jika ada
if (str_contains($fraction, '/')) {
list($numerator, $denominator) = explode('/', $fraction);
return $denominator > 0 ? (float)$numerator / (float)$denominator : 0;
}
return (float)$fraction;
}
public function create(Cat $cat)
{
Gate::authorize('check-access', ['cats', 'update']);
$availableMedicines = Medicine::where('qty', '>', 0)->orderBy('nama_obat')->get();
return view('pages.cats.medical-ledger-create', compact('cat', 'availableMedicines'));
}
}
+65 -8
View File
@@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use App\Imports\MedicineImport;
use App\Exports\MedicineTemplateExport;
use Illuminate\Support\Facades\Gate;
class MedicineController extends Controller
{
@@ -20,18 +21,74 @@ class MedicineController extends Controller
* Tampilkan daftar obat-obatan (Index).
*/
public function index(Request $request): View
{
$query = Medicine::query();
{
// Proteksi Akses
Gate::authorize('check-access', ['medicines', 'read']);
if ($request->filled('search')) {
$query->where('nama_obat', 'like', '%' . $request->search . '%');
$now = now();
$query = Medicine::query();
// 1. FILTER KHUSUS (DARI DASHBOARD)
if ($request->filled('filter')) {
$filter = $request->get('filter');
if ($filter === 'expired') {
// Menampilkan yang sudah lewat tanggal kadaluwarsa
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
} elseif ($filter === 'expiring_soon') {
// Menampilkan yang akan kadaluwarsa dalam 7 hari kedepan
$query->whereNotNull('expired_at')
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
} elseif ($filter === 'low_stock') {
// Menampilkan stok kritis (<= 5)
$query->where('qty', '<=', 5);
}
$medicines = $query->latest()->paginate(10);
return view('pages.inventory.medicines.index', compact('medicines'));
}
// 2. SEARCH GLOBAL
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_obat', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%");
});
}
// 3. FILTER KATEGORI (Opsional jika ada dropdown di UI)
if ($request->filled('category')) {
$query->where('kategori', $request->category);
}
// 4. SORTING DINAMIS
// Gunakan 'expired_at' sebagai default sorting jika dari Dashboard 'expired'
$allowedSorts = ['nama_obat', 'kategori', 'qty', 'expired_at', 'created_at'];
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_obat';
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
$medicines = $query->orderBy($sort, $direction)
->paginate(15)
->withQueryString();
// 5. STATISTIK BOX (Selalu hitung total keseluruhan, bukan hasil filter)
$totalStock = Medicine::sum('qty');
$lowStockCount = Medicine::where('qty', '<=', 5)->count();
// Menghitung barang yang sudah expired (merah)
$expiredCount = Medicine::whereNotNull('expired_at')
->where('expired_at', '<', $now)
->count();
$categories = Medicine::distinct()->whereNotNull('kategori')->pluck('kategori');
return view('pages.inventory.medicines.index', compact(
'medicines',
'totalStock',
'lowStockCount',
'expiredCount', // Tambahan statistik baru
'categories'
));
}
/**
* Tampilkan form tambah obat.
*/
+7 -8
View File
@@ -74,17 +74,16 @@ class ReportController extends Controller
protected function applyModuleFilters($query, $request, $module)
{
$status = $request->status;
$status = $request->status;
// TAMBAHAN: Logika Filter untuk Modul Cats (Sehat/Sakit)
// Ini yang membuat filter status di populasi kucing bekerja
// PERBAIKAN 1: Gunakan $request->filled('status') sesuai dengan <select name="status"> di Blade
if ($module === 'cats' && $request->filled('status')) {
match($status) {
'sakit' => $query->whereNotNull('riwayat_sakit_terakhir')->where('riwayat_sakit_terakhir', '!=', ''),
'sehat' => $query->where(function($q) {
$q->whereNull('riwayat_sakit_terakhir')
->orWhere('riwayat_sakit_terakhir', '');
}),
// PERBAIKAN 2: Tangkap semua kemungkinan value (Bahasa Indonesia, Inggris, maupun angka)
match(strtolower($status)) {
'sakit', 'treatment', '1' => $query->where('treatment_status', 1),
'sehat', 'healthy', '2' => $query->where('treatment_status', 2),
default => null
};
}
+6
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Role;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class RoleController extends Controller
{
@@ -13,6 +14,7 @@ class RoleController extends Controller
*/
public function index()
{
Gate::authorize('check-access', ['roles', 'read']);
$roles = Role::all();
// Menambahkan prefix 'pages.' sesuai struktur folder Anda
return view('pages.admin.roles.index', compact('roles'));
@@ -20,12 +22,14 @@ class RoleController extends Controller
public function create()
{
Gate::authorize('check-access', ['roles', 'create']);
// Mengarahkan ke folder pages sesuai standarisasi Anda
return view('pages.admin.roles.create');
}
public function store(Request $request)
{
Gate::authorize('check-access', ['roles', 'create']);
$request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:roles,slug',
@@ -53,6 +57,7 @@ class RoleController extends Controller
public function edit($id)
{
Gate::authorize('check-access', ['roles', 'update']);
$role = Role::with('permissions')->findOrFail($id);
/**
@@ -94,6 +99,7 @@ class RoleController extends Controller
*/
public function update(Request $request, $id)
{
Gate::authorize('check-access', ['roles', 'update']);
$role = Role::findOrFail($id);
DB::transaction(function () use ($request, $role) {
@@ -9,6 +9,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
use Intervention\Image\Laravel\Facades\Image;
class SettingController extends Controller
@@ -28,6 +29,7 @@ class SettingController extends Controller
public function index()
{
Gate::authorize('check-access', ['settings', 'read']);
$settings = Setting::pluck('value', 'key')->all();
// Tambahkan default jika version belum ada
@@ -49,6 +51,7 @@ class SettingController extends Controller
public function update(Request $request)
{
Gate::authorize('check-access', ['settings', 'update']);
$request->validate([
'app_logo' => 'nullable|image|mimes:png,jpg,jpeg|max:2048',
'app_favicon' => 'nullable|image|mimes:png,jpg,ico|max:512',
+93 -18
View File
@@ -9,6 +9,8 @@ use Illuminate\Http\Request;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
class SupplyController extends Controller
{
@@ -17,18 +19,74 @@ class SupplyController extends Controller
/**
* Tampilkan daftar perlengkapan.
*/
public function index(Request $request): View
{
$query = Supply::with('category');
public function index(Request $request): View
{
// 1. Proteksi Akses (Sesuai Matriks Role Management)
Gate::authorize('check-access', ['supplies', 'read']);
if ($request->filled('search')) {
$query->where('nama_perlengkapan', 'like', '%' . $request->search . '%');
$now = now();
$query = Supply::with('category');
// 2. FILTER KHUSUS (SINKRONISASI DENGAN DASHBOARD)
if ($request->filled('filter')) {
$filter = $request->get('filter');
if ($filter === 'expired') {
// Menampilkan perlengkapan yang sudah kadaluwarsa
$query->whereNotNull('expired_at')->where('expired_at', '<', $now);
} elseif ($filter === 'expiring_soon') {
// Menampilkan yang kadaluwarsa dalam 7 hari kedepan
$query->whereNotNull('expired_at')
->whereBetween('expired_at', [$now, $now->copy()->addDays(7)]);
} elseif ($filter === 'low_stock') {
// Menampilkan stok kritis
$query->where('qty', '<=', 5);
}
$supplies = $query->latest()->paginate(10);
return view('pages.inventory.supplies.index', compact('supplies'));
}
// 3. SEARCH GLOBAL (Mencari di Nama, Kategori, dan Unit)
if ($request->filled('search')) {
$search = $request->search;
$query->where(function($q) use ($search) {
$q->where('nama_perlengkapan', 'like', "%{$search}%")
->orWhere('kategori', 'like', "%{$search}%")
->orWhere('unit', 'like', "%{$search}%")
->orWhereHas('category', function($sub) use ($search) {
$sub->where('name', 'like', "%{$search}%");
});
});
}
// 4. LOGIKA SORTING DINAMIS (Menambahkan expired_at dalam daftar sort)
$allowedSorts = ['nama_perlengkapan', 'qty', 'kategori', 'expired_at', 'created_at'];
$sort = in_array($request->get('sort'), $allowedSorts) ? $request->get('sort') : 'nama_perlengkapan';
$direction = strtolower($request->get('direction')) === 'desc' ? 'desc' : 'asc';
// Eksekusi Query dengan Pagination
$supplies = $query->orderBy($sort, $direction)
->paginate(15)
->withQueryString();
// 5. STATISTIK RINGKAS (Komprehensif)
$totalStock = Supply::sum('qty');
$lowStockCount = Supply::where('qty', '<=', 5)->count();
// Hitung item yang expired (Wajib ada kolom expired_at di database)
$expiredCount = Supply::whereNotNull('expired_at')
->where('expired_at', '<', $now)
->count();
$totalItems = Supply::count();
return view('pages.inventory.supplies.index', compact(
'supplies',
'totalStock',
'lowStockCount',
'expiredCount', // Variabel statistik baru
'totalItems'
));
}
/**
* Tampilkan form tambah.
*/
@@ -103,24 +161,41 @@ class SupplyController extends Controller
/**
* Hapus data perlengkapan.
*/
public function destroy(Supply $supply): RedirectResponse
{
if (auth()->user()->role !== 'admin') {
abort(403, 'Akses Ditolak.');
}
public function destroy(Supply $supply): RedirectResponse
{
// 1. OTORISASI DINAMIS (Inilah perbaikan untuk akses Supervisor)
// Menggunakan Gate 'check-access' agar sinkron dengan Role Management Anda
Gate::authorize('check-access', ['supplies', 'delete']);
try {
DB::beginTransaction();
$supplyName = $supply->nama_perlengkapan;
if ($supply->foto_produk) {
// 2. PEMBERSIHAN FILE (STORAGE)
if ($supply->foto_produk && Storage::disk('public')->exists($supply->foto_produk)) {
Storage::disk('public')->delete($supply->foto_produk);
}
// PENTING: Log dilakukan SEBELUM delete agar ID tidak null
$this->logAction('DELETED_SUPPLY', $supply, "Menghapus perlengkapan: {$supplyName}");
// 3. AUDIT TRAIL
// Dicatat sebelum delete agar konteks data $supply masih tersedia secara lengkap
if (method_exists($this, 'logAction')) {
$this->logAction('DELETE', 'Inventory', "Menghapus perlengkapan: {$supplyName}");
}
// 4. EKSEKUSI HAPUS
$supply->delete();
// FIX: Menggunakan .index
return redirect()->route('inventory.supplies.index')->with('success', 'Data perlengkapan berhasil dihapus.');
DB::commit();
return redirect()->route('inventory.supplies.index')
->with('success', "Perlengkapan '{$supplyName}' berhasil dihapus dari sistem.");
} catch (\Exception $e) {
DB::rollBack();
// Reporting error jika terjadi kegagalan sistem
return back()->with('error', 'Gagal menghapus data. Silakan coba lagi atau hubungi IT.');
}
}
}
+228 -80
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Cat;
use App\Models\Treatment;
use App\Models\Medicine;
use App\Models\TreatmentDetail; // Menambahkan model Detail
use App\Traits\LogsActivity;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -18,167 +19,314 @@ class TreatmentController extends Controller
use LogsActivity;
/**
* Menampilkan daftar rekam medis dengan optimasi Eager Loading.
* Menampilkan daftar rekam medis dengan optimasi Eager Loading & Filtering.
*/
public function index(): View
{
$treatments = Treatment::with([
'cat:id,nama_kucing,foto',
'medicine:id,nama_obat,unit',
'user:id,name'
])
->latest('tanggal')
->paginate(15);
public function index(Request $request): View|\Symfony\Component\HttpFoundation\BinaryFileResponse
{
// 1. QUERY DASAR DENGAN OPTIMASI EAGER LOADING
$query = Treatment::with([
'cat:id,nama_kucing,foto,treatment_status',
'details.medicine:id,nama_obat,unit',
'user:id,name'
])
->whereHas('cat', function($q) {
$q->where('treatment_status', 1);
});
return view('pages.treatments.index', compact('treatments'));
// 2. LOGIKA FILTERING DINAMIS
if ($request->filled('search')) {
$query->whereHas('cat', function($q) use ($request) {
$q->where('nama_kucing', 'like', '%' . $request->search . '%');
});
}
if ($request->filled('pic_id')) {
$query->where('user_id', $request->pic_id);
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('tanggal', [$request->start_date, $request->end_date]);
}
// 3. LOGIKA EXPORT (Pastikan blok IF ditutup dengan benar)
if ($request->filled('export')) {
$exportData = new \App\Exports\TreatmentExport($request);
$fileName = 'Report_Active_Treatment_' . now()->format('Ymd_His');
if ($request->export === 'excel') {
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.xlsx");
}
if ($request->export === 'pdf') {
return \Maatwebsite\Excel\Facades\Excel::download($exportData, "{$fileName}.pdf", \Maatwebsite\Excel\Excel::DOMPDF);
}
} // <--- Pastikan kurung ini menutup blok export, bukan menutup fungsi index
// 4. EKSEKUSI DATA UNTUK VIEW (Line 67)
$treatments = $query->latest('tanggal')->paginate(20)->withQueryString();
$pics = \App\Models\User::select('id', 'name')->orderBy('name')->get();
return view('pages.treatments.index', compact('treatments', 'pics'));
}
/**
* Form tambah rekam medis.
*/
public function create(Request $request): View
{
$selectedCat = $request->filled('cat_id') ? Cat::findOrFail($request->cat_id) : null;
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
$medicines = Medicine::where('qty', '>', 0)->get();
$selectedCat = null;
if ($request->filled('cat_id')) {
$selectedCat = Cat::select('id', 'nama_kucing', 'ras')->findOrFail($request->cat_id);
$cats = collect([$selectedCat]);
} else {
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
}
// Menampilkan semua obat (termasuk stok 0 untuk kebutuhan edit/view,
// namun di filter di frontend untuk input baru)
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
return view('pages.treatments.create', compact('selectedCat', 'cats', 'medicines'));
}
/**
* Simpan rekam medis baru & potong stok (mendukung desimal).
* Simpan rekam medis baru & potong stok (Mendukung Multi-Obat).
*/
public function store(Request $request): RedirectResponse
public function store(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $this->validateTreatment($request);
$fotoPath = null;
if ($request->hasFile('foto_bukti')) {
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
}
try {
DB::transaction(function () use ($request, &$validated) {
$medicine = Medicine::findOrFail($request->medicine_id);
// Validasi kecukupan stok desimal
if ($medicine->qty < $request->qty_used) {
throw new Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty} {$medicine->unit}).");
}
if ($request->hasFile('foto_bukti')) {
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
}
// Otomatisasi data sistem
$validated['user_id'] = auth()->id();
$validated['nama_obat'] = $medicine->nama_obat;
return \Illuminate\Support\Facades\DB::transaction(function () use ($request, $fotoPath) {
$treatment = Treatment::create($validated);
$userId = auth()->id() ?? 1;
$firstTreatment = null;
// Potong stok (decrement mendukung nilai float/decimal)
$medicine->decrement('qty', $request->qty_used);
if ($request->filled('items')) {
foreach ($request->items as $item) {
$medicine = \App\Models\Medicine::findOrFail($item['medicine_id']);
$this->logAction('TREATED', $treatment, "Mencatat pengobatan oleh " . auth()->user()->name);
if ($medicine->qty < $item['qty_used']) {
throw new \Exception("Stok {$medicine->nama_obat} tidak mencukupi (Sisa: {$medicine->qty}).");
}
$treatment = \App\Models\Treatment::create([
'cat_id' => $request->cat_id,
'user_id' => $userId,
'tanggal' => $request->tanggal,
'keluhan' => $request->keluhan,
// Langsung masukkan diagnosa tanpa embel-embel
'diagnosa' => $request->diagnosa,
'foto_bukti' => $fotoPath,
'status_perawatan' => 1,
'medicine_id' => $item['medicine_id'],
'qty_used' => $item['qty_used'],
]);
if (!$firstTreatment) {
$firstTreatment = $treatment;
}
$medicine->decrement('qty', $item['qty_used']);
}
}
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
if ($firstTreatment) {
$this->logAction('TREATED', $firstTreatment, "Mencatat pengobatan klinis untuk " . $firstTreatment->cat->nama_kucing);
}
return redirect()->route('cats.show', $request->cat_id)
->with('success', 'Rekam medis klinis berhasil dicatat dan stok dipotong otomatis.');
});
return redirect()->route('cats.show', $request->cat_id)
->with('success', 'Rekam medis berhasil dicatat dengan presisi.');
} catch (Exception $e) {
if (isset($validated['foto_bukti'])) {
Storage::disk('public')->delete($validated['foto_bukti']);
} catch (\Exception $e) {
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
}
return back()->withInput()->with('error', 'Gagal menyimpan: ' . $e->getMessage());
}
}
/**
* Menampilkan detail spesifik satu rekam medis.
*/
public function show(Treatment $treatment): View
{
// Load details untuk menampilkan list obat
$treatment->load(['cat', 'medicine', 'user', 'details.medicine']);
return view('pages.treatments.show', compact('treatment'));
}
/**
* Form edit rekam medis.
*/
public function edit(Treatment $treatment): View
{
$treatment->load('details');
$cats = Cat::select('id', 'nama_kucing', 'ras')->get();
$medicines = Medicine::all();
$medicines = Medicine::orderBy('nama_obat', 'asc')->get();
return view('pages.treatments.edit', compact('treatment', 'cats', 'medicines'));
}
/**
* Update rekam medis & sinkronisasi ulang stok desimal.
* Update rekam medis & sinkronisasi ulang stok desimal (Multi-Obat).
*/
public function update(Request $request, Treatment $treatment): RedirectResponse
public function update(Request $request, Treatment $treatment)
{
// 1. Validasi Input
$validated = $this->validateTreatment($request);
try {
DB::transaction(function () use ($request, $treatment, &$validated) {
// 1. Kembalikan stok lama (Desimal Revert)
$oldMedicine = Medicine::find($treatment->medicine_id);
return DB::transaction(function () use ($request, $treatment) {
// 2. Kelola Foto Bukti (Hapus yang lama jika ada foto baru)
$fotoPath = $treatment->foto_bukti;
if ($request->hasFile('foto_bukti')) {
if ($fotoPath && \Illuminate\Support\Facades\Storage::disk('public')->exists($fotoPath)) {
\Illuminate\Support\Facades\Storage::disk('public')->delete($fotoPath);
}
$fotoPath = $request->file('foto_bukti')->store('treatments', 'public');
}
// 3. KEMBALIKAN STOK OBAT LAMA (Penting agar stok tidak bocor)
$oldMedicine = \App\Models\Medicine::find($treatment->medicine_id);
if ($oldMedicine) {
$oldMedicine->increment('qty', $treatment->qty_used);
}
// 2. Potong stok baru
$newMedicine = Medicine::findOrFail($request->medicine_id);
if ($newMedicine->qty < $request->qty_used) {
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi untuk perubahan ini.");
}
$newMedicine->decrement('qty', $request->qty_used);
// 4. Ambil data obat PERTAMA dari form untuk memperbarui data saat ini (ID 9)
$firstItem = $request->items[0];
$newMedicine = \App\Models\Medicine::findOrFail($firstItem['medicine_id']);
// 3. Kelola Foto
if ($request->hasFile('foto_bukti')) {
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
$validated['foto_bukti'] = $request->file('foto_bukti')->store('treatments', 'public');
if ($newMedicine->qty < $firstItem['qty_used']) {
throw new Exception("Stok {$newMedicine->nama_obat} tidak mencukupi (Sisa: {$newMedicine->qty}).");
}
// 4. Update data sistem
$validated['user_id'] = auth()->id();
$validated['nama_obat'] = $newMedicine->nama_obat;
$treatment->update($validated);
// Format Diagnosa (Gabungan Diagnosa Utama + Dosis/Ket)
$catatanDosis = isset($firstItem['dosis']) ? " | Dosis: " . $firstItem['dosis'] : "";
$catatanKet = isset($firstItem['keterangan']) && $firstItem['keterangan'] != '' ? " (" . $firstItem['keterangan'] . ")" : "";
$diagnosaFinal = $request->diagnosa . $catatanDosis . $catatanKet;
$this->logAction('UPDATED_TREATMENT', $treatment, "Diperbarui oleh " . auth()->user()->name);
// EKSEKUSI UPDATE REKAM MEDIS (Tanpa kolom nama_obat!)
$treatment->update([
'cat_id' => $request->cat_id,
'tanggal' => $request->tanggal,
'keluhan' => $request->keluhan,
'diagnosa' => $diagnosaFinal,
'foto_bukti' => $fotoPath,
'medicine_id' => $firstItem['medicine_id'],
'qty_used' => $firstItem['qty_used'],
]);
// Potong stok obat pertama
$newMedicine->decrement('qty', $firstItem['qty_used']);
// 5. JIKA STAF MENAMBAHKAN OBAT EKSTRA DI HALAMAN EDIT
// Buat sebagai record baru agar data tidak hilang
if (count($request->items) > 1) {
for ($i = 1; $i < count($request->items); $i++) {
$extraItem = $request->items[$i];
$extraMed = \App\Models\Medicine::findOrFail($extraItem['medicine_id']);
if ($extraMed->qty < $extraItem['qty_used']) {
throw new Exception("Stok {$extraMed->nama_obat} tidak mencukupi.");
}
$catatanDosisEx = isset($extraItem['dosis']) ? " | Dosis: " . $extraItem['dosis'] : "";
$catatanKetEx = isset($extraItem['keterangan']) && $extraItem['keterangan'] != '' ? " (" . $extraItem['keterangan'] . ")" : "";
$diagnosaFinalEx = $request->diagnosa . $catatanDosisEx . $catatanKetEx;
\App\Models\Treatment::create([
'cat_id' => $request->cat_id,
'user_id' => auth()->id() ?? 1,
'tanggal' => $request->tanggal,
'keluhan' => $request->keluhan,
'diagnosa' => $diagnosaFinalEx,
'foto_bukti' => $fotoPath,
'status_perawatan' => $treatment->status_perawatan, // Ikuti status (Misal: 1 / Aktif)
'medicine_id' => $extraItem['medicine_id'],
'qty_used' => $extraItem['qty_used'],
]);
$extraMed->decrement('qty', $extraItem['qty_used']);
}
}
// 6. Sinkronisasi Kucing & Log Activity
\App\Models\Cat::where('id', $request->cat_id)->update(['treatment_status' => 1]);
$this->logAction('UPDATED', $treatment, "Memperbarui rekam medis klinis untuk " . $treatment->cat->nama_kucing);
return redirect()->route('cats.show', $request->cat_id)
->with('success', 'Rekam medis berhasil diperbarui dan stok obat disesuaikan ulang secara otomatis!');
});
return redirect()->route('cats.show', $request->cat_id)->with('success', 'Rekam medis & inventori berhasil di-sinkronisasi.');
} catch (Exception $e) {
return back()->withInput()->with('error', 'Gagal memperbarui: ' . $e->getMessage());
}
}
/**
* Hapus rekam medis & kembalikan stok.
* Hapus rekam medis & kembalikan semua stok obat terkait.
*/
public function destroy(Treatment $treatment): RedirectResponse
{
try {
DB::transaction(function () use ($treatment) {
if ($treatment->medicine_id) {
Medicine::where('id', $treatment->medicine_id)->increment('qty', $treatment->qty_used);
// Kembalikan semua stok dari tabel detail
foreach ($treatment->details as $detail) {
Medicine::where('id', $detail->medicine_id)->increment('qty', $detail->qty_used);
}
if ($treatment->foto_bukti) {
Storage::disk('public')->delete($treatment->foto_bukti);
}
if ($treatment->foto_bukti) Storage::disk('public')->delete($treatment->foto_bukti);
$this->logAction('DELETED_TREATMENT', $treatment, "Menghapus catatan medis kucing: {$treatment->cat->nama_kucing}");
// Delete otomatis menghapus details karena cascade di migration
$treatment->delete();
});
return back()->with('success', 'Data rekam medis dihapus dan stok dikembalikan.');
return back()->with('success', 'Data rekam medis dihapus dan semua stok obat dikembalikan.');
} catch (Exception $e) {
return back()->with('error', 'Gagal menghapus data.');
return back()->with('error', 'Gagal menghapus data: ' . $e->getMessage());
}
}
/**
* Helper Validasi: Terpusat untuk Clean Code & Mendukung Desimal.
*/
private function validateTreatment(Request $request)
private function validateTreatment(Request $request)
{
return $request->validate([
'cat_id' => 'required|exists:cats,id',
'medicine_id' => 'required|exists:medicines,id',
// 'numeric' mengizinkan input desimal (koma)
'qty_used' => 'required|numeric|min:0.01',
'tanggal' => 'required|date',
'keluhan' => 'required|string|max:255',
'diagnosa' => 'required|string',
'dosis' => 'required|string',
'keterangan' => 'nullable|string',
'foto_bukti' => 'nullable|image|max:2048',
// Validasi untuk Single Medicine
'medicine_id' => 'required_without:items|nullable|exists:medicines,id',
'qty_used' => 'required_without:items|nullable|numeric|min:0',
// Validasi untuk Multi Medicine
// FIX: Wajibkan array items jika mode single (medicine_id) kosong
'items' => 'required_without:medicine_id|nullable|array',
'items.*.medicine_id' => 'required_with:items|exists:medicines,id',
'items.*.qty_used' => 'required_with:items|numeric|min:0.01',
], [
// Pesan Error Custom (Opsional, agar lebih mudah di-debug)
'items.required_without' => 'Anda harus memasukkan minimal 1 obat.',
'items.*.medicine_id.required_with' => 'Ada pilihan obat yang masih kosong di daftar.',
'items.*.qty_used.required_with' => 'Jumlah (Qty) obat harus diisi.',
]);
}
}
+5 -4
View File
@@ -29,10 +29,12 @@ class UserController extends Controller
Gate::authorize('check-access', ['users', 'read']);
$users = User::query()
->with('role')
->with('roleModel')
->when($request->search, function ($query, $search) {
$query->where('name', 'like', "%{$search}%")
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
})
->latest()
->paginate(10);
@@ -194,7 +196,6 @@ class UserController extends Controller
public function clearLogs()
{
Gate::authorize('check-access', ['logs', 'delete']);
ActivityLog::truncate();
return back()->with('success', 'Seluruh riwayat aktivitas telah dibersihkan.');
return back()->with('error', 'Penghapusan massal audit trail dinonaktifkan untuk menjaga jejak perubahan data. Gunakan kebijakan retensi/arsip bila diperlukan.');
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\WebAuthn;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Response;
use Laragear\WebAuthn\Http\Requests\AssertedRequest;
use Laragear\WebAuthn\Http\Requests\AssertionRequest;
use function response;
class WebAuthnLoginController
{
/**
* Returns the challenge to assertion.
*/
public function options(AssertionRequest $request): Responsable
{
return $request->toVerify($request->validate(['email' => 'sometimes|email|string']));
}
/**
* Log the user in.
*/
public function login(AssertedRequest $request): Response
{
return response()->noContent($request->login() ? 204 : 422);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\WebAuthn;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\Response;
use Laragear\WebAuthn\Http\Requests\AttestationRequest;
use Laragear\WebAuthn\Http\Requests\AttestedRequest;
use function response;
class WebAuthnRegisterController
{
/**
* Returns a challenge to be verified by the user device.
*/
public function options(AttestationRequest $request): Responsable
{
return $request
->fastRegistration()
// ->userless()
// ->allowDuplicates()
->toCreate();
}
/**
* Registers a device for further WebAuthn authentication.
*/
public function register(AttestedRequest $request): Response
{
$request->save();
return response()->noContent();
}
}