Files
ilycats/app/Http/Controllers/DashboardController.php

470 lines
20 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
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(Request $request)
{
$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::activeInCattery()->count(),
'total_kittens' => Kitten::count(),
'sick_cats' => Cat::activeInCattery()->where('treatment_status', 1)->count(),
'total_mating' => Matting::whereIn('status', ['proses', 'hamil'])->count(),
];
$upcomingBirths = Matting::with(['dam', 'sire'])
->whereIn('status', ['proses', 'hamil'])
->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::activeInCattery()->whereNotNull('jadwal_vaksin')->where('jadwal_vaksin', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'vaccine']),
],
'flea' => [
'count' => Cat::activeInCattery()->whereNotNull('jadwal_obat_kutu')->where('jadwal_obat_kutu', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'flea']),
],
'deworming' => [
'count' => Cat::activeInCattery()->whereNotNull('jadwal_obat_cacing')->where('jadwal_obat_cacing', '<', $now)->count(),
'url' => route('cats.index', ['filter_overdue' => 'deworming']),
],
'grooming' => [
'count' => Cat::activeInCattery()->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'
)
->where(function ($q) { $q->whereNull('cats.cattery_status')->orWhere('cats.cattery_status', 'active'); })
->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')
->where(function ($q) { $q->whereNull('cats.cattery_status')->orWhere('cats.cattery_status', 'active'); })
->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();
// Daftar kenaikan/penurunan berat berdasarkan kategori pada periode yang dipilih.
$weightMovements = collect(['Kitten', 'Junior', 'Dewasa'])->mapWithKeys(function ($category) use ($weightLatestTable) {
$rows = $weightLatestTable->where('category', $category)->whereNotNull('change');
return [$category => [
'gainers' => $rows->where('change', '>', 0)->sortByDesc('change')->take(5)->values(),
'losers' => $rows->where('change', '<', 0)->sortBy('change')->take(5)->values(),
]];
});
$weightPeriodLabel = $trendStart->translatedFormat('M Y') . ' – ' . $now->translatedFormat('M Y');
// 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',
'weightMovements',
'weightPeriodLabel',
'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';
}
}