456 lines
19 KiB
PHP
456 lines
19 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::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';
|
|
}
|
|
}
|