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