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_kittens' => Kitten::count(), 'sick_cats' => Cat::where('treatment_status', 1)->count(), 'total_mating' => Matting::where('status', 'proses')->count(), ]; $upcomingBirths = Matting::with(['dam', 'sire']) ->where('status', 'proses') ->whereNotNull('tgl_kawin_awal') ->get() ->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 ($mating) => $mating->days_to_go >= -2 && $mating->days_to_go <= 7) ->sortBy('days_to_go'); $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']), ], ]; /* * 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( 'cats.id as cat_id', 'cats.nama_kucing', 'cats.jenis_kelamin', 'cats.status', 'cats.tanggal_lahir', 'weight_histories.berat', 'weight_histories.tanggal_timbang' ) ->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 ($selectedGender) { $weightQuery->where('cats.jenis_kelamin', $selectedGender); } 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', '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'; } }