470 lines
20 KiB
PHP
470 lines
20 KiB
PHP
<?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';
|
||
}
|
||
}
|