get('periode', 'minggu_ini'); [$startDate, $endDate] = $this->getPeriodeRange($periode, $request); [$prevStart, $prevEnd] = $this->getPreviousPeriodeRange($periode, $startDate, $endDate); $periodeLabel = $this->getPeriodeLabel($periode, $startDate, $endDate); // 2. KPI Cards $kpi = $this->calculateKpi($startDate, $endDate); $kpiPrev = $this->calculateKpi($prevStart, $prevEnd); $kpiComparison = [ 'total_kegiatan' => $kpi['total_kegiatan'] - $kpiPrev['total_kegiatan'], 'avg_kehadiran' => round($kpi['avg_kehadiran'] - $kpiPrev['avg_kehadiran'], 1), 'santri_perlu_perhatian' => $kpi['santri_perlu_perhatian'] - $kpiPrev['santri_perlu_perhatian'], ]; // 3. Trend Data (line chart) $trendData = $this->getTrendData($startDate, $endDate); // 4. Distribusi Santri (funnel chart) $distribusiSantri = $this->getDistribusiSantri($startDate, $endDate); // 5. Top & Bottom Kegiatan $topKegiatan = $this->getTopBottomKegiatan($startDate, $endDate, 'top', 5); $bottomKegiatan = $this->getTopBottomKegiatan($startDate, $endDate, 'bottom', 5); // 6. Kehadiran Per Kelas $kehadiranPerKelas = $this->getKehadiranPerKelas($startDate, $endDate); // 7. Heatmap Kelas vs Kategori $heatmapData = Cache::remember( 'laporan_heatmap_' . $startDate->format('Ymd') . '_' . $endDate->format('Ymd'), 1800, fn() => $this->getHeatmapData($startDate, $endDate) ); // 8. Pattern & Anomaly Detection $patterns = Cache::remember( 'laporan_patterns_' . now()->format('Ymd'), 3600, fn() => $this->patternDetection(new Request) ); // 9. Santri Perlu Perhatian list (for Tab 3) $santriPerluPerhatianList = $this->getSantriPerluPerhatianList($startDate, $endDate, 10); // 10. Leaderboard (for Tab 3) $leaderboard = $this->getLeaderboard($startDate, $endDate, 10); // 11. Kegiatan Performance (for Tab 4) $kegiatanPerformance = $this->getKegiatanPerformance($startDate, $endDate); // Data filter $kategoris = KategoriKegiatan::select('kategori_id', 'nama_kategori')->get(); $kelasList = Kelas::active()->ordered()->with('kelompok')->get(); return view('admin.kegiatan.laporan.index', compact( 'periode', 'startDate', 'endDate', 'periodeLabel', 'kpi', 'kpiPrev', 'kpiComparison', 'trendData', 'distribusiSantri', 'topKegiatan', 'bottomKegiatan', 'kehadiranPerKelas', 'heatmapData', 'patterns', 'santriPerluPerhatianList', 'leaderboard', 'kegiatanPerformance', 'kategoris', 'kelasList' )); } /** * ═══════════════════════════════════════════ * B. ANALISIS PER KELAS (AJAX) * ═══════════════════════════════════════════ */ public function analisPerKelas(Request $request) { $idKelas = $request->get('id_kelas'); $kelas = Kelas::with('kelompok')->findOrFail($idKelas); [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'bulan_ini'), $request ); $santriIds = SantriKelas::where('id_kelas', $idKelas)->pluck('id_santri'); // Kehadiran per santri $kehadiranPerSantri = []; if ($santriIds->isNotEmpty()) { $kehadiranPerSantri = AbsensiKegiatan::whereIn('id_santri', $santriIds) ->whereBetween('tanggal', [$startDate, $endDate]) ->join('santris', 'absensi_kegiatans.id_santri', '=', 'santris.id_santri') ->select( 'santris.id_santri', 'santris.nama_lengkap', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Izin" THEN 1 ELSE 0 END) as izin'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Alpa" THEN 1 ELSE 0 END) as alpa') ) ->groupBy('santris.id_santri', 'santris.nama_lengkap') ->orderBy('hadir', 'desc') ->get() ->map(function ($s) { $s->persen = $s->total > 0 ? round(($s->hadir / $s->total) * 100, 1) : 0; return $s; }); } // Distribusi status $distribusi = AbsensiKegiatan::whereIn('id_santri', $santriIds) ->whereBetween('tanggal', [$startDate, $endDate]) ->select('status', DB::raw('COUNT(*) as total')) ->groupBy('status') ->pluck('total', 'status') ->toArray(); // Trend 4 minggu $trend = []; for ($i = 3; $i >= 0; $i--) { $weekStart = Carbon::parse($endDate)->subWeeks($i)->startOfWeek(); $weekEnd = Carbon::parse($endDate)->subWeeks($i)->endOfWeek(); $weekData = AbsensiKegiatan::whereIn('id_santri', $santriIds) ->whereBetween('tanggal', [$weekStart, $weekEnd]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status = "Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $trend[] = [ 'label' => 'Minggu ' . (4 - $i), 'persen' => ($weekData->total ?? 0) > 0 ? round(($weekData->hadir / $weekData->total) * 100, 1) : 0, ]; } return response()->json([ 'kelas' => $kelas, 'kehadiran_per_santri' => $kehadiranPerSantri, 'distribusi' => $distribusi, 'trend' => $trend, 'jumlah_santri' => $santriIds->count(), ]); } /** * ═══════════════════════════════════════════ * C. DETAIL SANTRI - Individual Report * ═══════════════════════════════════════════ */ public function detailSantri($id_santri, Request $request) { $santri = Santri::where('id_santri', $id_santri) ->with('kelasSantri.kelas.kelompok') ->firstOrFail(); [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'bulan_ini'), $request ); $periodeLabel = $this->getPeriodeLabel($request->get('periode', 'bulan_ini'), $startDate, $endDate); // Stats summary $stats = AbsensiKegiatan::where('id_santri', $id_santri) ->whereBetween('tanggal', [$startDate, $endDate]) ->select( DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN status = "Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('SUM(CASE WHEN status = "Izin" THEN 1 ELSE 0 END) as izin'), DB::raw('SUM(CASE WHEN status = "Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('SUM(CASE WHEN status = "Alpa" THEN 1 ELSE 0 END) as alpa') ) ->first(); $persenKehadiran = $stats->total > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0; // Kehadiran per kegiatan $perKegiatan = AbsensiKegiatan::where('id_santri', $id_santri) ->whereBetween('tanggal', [$startDate, $endDate]) ->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id') ->select( 'kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Izin" THEN 1 ELSE 0 END) as izin'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Alpa" THEN 1 ELSE 0 END) as alpa') ) ->groupBy('kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan') ->get() ->map(function ($k) { $k->persen = $k->total > 0 ? round(($k->hadir / $k->total) * 100, 1) : 0; return $k; }); // Trend 4 minggu $trend = []; for ($i = 3; $i >= 0; $i--) { $ws = Carbon::parse($endDate)->subWeeks($i)->startOfWeek(); $we = Carbon::parse($endDate)->subWeeks($i)->endOfWeek(); $wd = AbsensiKegiatan::where('id_santri', $id_santri) ->whereBetween('tanggal', [$ws, $we]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $trend[] = [ 'label' => 'Mg ' . (4 - $i), 'persen' => ($wd->total ?? 0) > 0 ? round(($wd->hadir / $wd->total) * 100, 1) : 0, ]; } // Kegiatan paling sering bolos $kegiatanBolos = AbsensiKegiatan::where('id_santri', $id_santri) ->whereBetween('tanggal', [$startDate, $endDate]) ->where('status', 'Alpa') ->select('kegiatan_id', DB::raw('COUNT(*) as total_alpa')) ->groupBy('kegiatan_id') ->orderByDesc('total_alpa') ->with('kegiatan:kegiatan_id,nama_kegiatan') ->first(); // Streak: consecutive days hadir $streak = $this->calculateStreak($id_santri); // Riwayat absensi terbaru $riwayatTerbaru = AbsensiKegiatan::where('id_santri', $id_santri) ->with('kegiatan.kategori') ->whereBetween('tanggal', [$startDate, $endDate]) ->orderByDesc('tanggal') ->orderByDesc('waktu_absen') ->limit(30) ->get(); return view('admin.kegiatan.laporan.detail-santri', compact( 'santri', 'stats', 'persenKehadiran', 'perKegiatan', 'trend', 'kegiatanBolos', 'streak', 'riwayatTerbaru', 'startDate', 'endDate', 'periodeLabel' )); } /** * ═══════════════════════════════════════════ * D. SANTRI PERLU PERHATIAN * ═══════════════════════════════════════════ */ public function santriPerluPerhatian(Request $request) { [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'bulan_ini'), $request ); $periodeLabel = $this->getPeriodeLabel($request->get('periode', 'bulan_ini'), $startDate, $endDate); $query = AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->join('santris', 'absensi_kegiatans.id_santri', '=', 'santris.id_santri') ->where('santris.status', 'Aktif') ->select( 'santris.id_santri', 'santris.nama_lengkap', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Alpa" THEN 1 ELSE 0 END) as alpa'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Izin" THEN 1 ELSE 0 END) as izin'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status = "Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status = "Hadir" THEN 1 ELSE 0 END) / COUNT(*) * 100, 1) as persen') ) ->groupBy('santris.id_santri', 'santris.nama_lengkap') ->having('persen', '<', 70) ->orderBy('persen', 'asc'); // Filter kelas if ($request->filled('id_kelas')) { $santriIdsInKelas = SantriKelas::where('id_kelas', $request->id_kelas)->pluck('id_santri'); $query->whereIn('santris.id_santri', $santriIdsInKelas); } $santris = $query->paginate(20)->appends(request()->query()); $kelasList = Kelas::active()->ordered()->with('kelompok')->get(); return view('admin.kegiatan.laporan.santri-perlu-perhatian', compact( 'santris', 'kelasList', 'startDate', 'endDate', 'periodeLabel' )); } /** * ═══════════════════════════════════════════ * E. LEADERBOARD * ═══════════════════════════════════════════ */ public function leaderboard(Request $request) { [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'bulan_ini'), $request ); $limit = $request->get('limit', 10); $data = $this->getLeaderboard($startDate, $endDate, $limit, $request->get('id_kelas')); if ($request->ajax()) { return response()->json($data); } return view('admin.kegiatan.laporan.leaderboard', [ 'leaderboard' => $data, 'startDate' => $startDate, 'endDate' => $endDate, ]); } /** * ═══════════════════════════════════════════ * F. ANALISIS KEGIATAN (Deep Dive) * ═══════════════════════════════════════════ */ public function analisKegiatan($kegiatan_id, Request $request) { $kegiatan = Kegiatan::with(['kategori', 'kelasKegiatan.kelompok']) ->where('kegiatan_id', $kegiatan_id) ->firstOrFail(); [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'bulan_ini'), $request ); $periodeLabel = $this->getPeriodeLabel($request->get('periode', 'bulan_ini'), $startDate, $endDate); // Stats overview $stats = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id) ->whereBetween('tanggal', [$startDate, $endDate]) ->select( DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('SUM(CASE WHEN status="Izin" THEN 1 ELSE 0 END) as izin'), DB::raw('SUM(CASE WHEN status="Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('SUM(CASE WHEN status="Alpa" THEN 1 ELSE 0 END) as alpa') ) ->first(); $stats->persen = $stats->total > 0 ? round(($stats->hadir / $stats->total) * 100, 1) : 0; // Trend 4 minggu $trend = []; for ($i = 3; $i >= 0; $i--) { $ws = Carbon::parse($endDate)->subWeeks($i)->startOfWeek(); $we = Carbon::parse($endDate)->subWeeks($i)->endOfWeek(); $wd = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id) ->whereBetween('tanggal', [$ws, $we]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $trend[] = [ 'label' => 'Mg ' . (4 - $i), 'persen' => ($wd->total ?? 0) > 0 ? round(($wd->hadir / $wd->total) * 100, 1) : 0, ]; } // Breakdown per kelas (if multiple) $breakdownPerKelas = []; if (!$kegiatan->isForAllClasses()) { foreach ($kegiatan->kelasKegiatan as $kelas) { $sIds = SantriKelas::where('id_kelas', $kelas->id)->pluck('id_santri'); if ($sIds->isEmpty()) continue; $kd = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id) ->whereIn('id_santri', $sIds) ->whereBetween('tanggal', [$startDate, $endDate]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $breakdownPerKelas[] = [ 'kelas' => $kelas->nama_kelas, 'total' => $kd->total ?? 0, 'hadir' => $kd->hadir ?? 0, 'persen' => ($kd->total ?? 0) > 0 ? round(($kd->hadir / $kd->total) * 100, 1) : 0, ]; } } // Punctuality (RFID data) $punctuality = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id) ->whereBetween('tanggal', [$startDate, $endDate]) ->where('status', 'Hadir') ->whereNotNull('waktu_absen') ->select( DB::raw('SUM(CASE WHEN TIME(waktu_absen) <= TIME( (SELECT waktu_mulai FROM kegiatans WHERE kegiatan_id = absensi_kegiatans.kegiatan_id) ) THEN 1 ELSE 0 END) as tepat_waktu'), DB::raw('SUM(CASE WHEN TIME(waktu_absen) > TIME( (SELECT waktu_mulai FROM kegiatans WHERE kegiatan_id = absensi_kegiatans.kegiatan_id) ) THEN 1 ELSE 0 END) as terlambat'), DB::raw('COUNT(*) as total') ) ->first(); // Santri tidak pernah hadir $santriTidakPernahHadir = AbsensiKegiatan::where('kegiatan_id', $kegiatan_id) ->whereBetween('tanggal', [$startDate, $endDate]) ->select('id_santri', DB::raw('SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir')) ->groupBy('id_santri') ->having('hadir', '=', 0) ->with('santri:id_santri,nama_lengkap') ->get(); // Insights $insights = $this->generateKegiatanInsights($kegiatan, $stats, $trend, $breakdownPerKelas); return view('admin.kegiatan.laporan.analisis-kegiatan', compact( 'kegiatan', 'stats', 'trend', 'breakdownPerKelas', 'punctuality', 'santriTidakPernahHadir', 'insights', 'startDate', 'endDate', 'periodeLabel' )); } /** * ═══════════════════════════════════════════ * G. PATTERN DETECTION * ═══════════════════════════════════════════ */ public function patternDetection(Request $request) { $patterns = []; // Pattern 1: Consistent Low Attendance (kegiatan <75% for >3 weeks) $threeWeeksAgo = Carbon::now()->subWeeks(3); $lowAttendance = AbsensiKegiatan::where('tanggal', '>=', $threeWeeksAgo) ->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id') ->select( 'kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan') ->having('persen', '<', 75) ->get(); foreach ($lowAttendance as $la) { $patterns[] = [ 'type' => 'warning', 'category' => 'Kehadiran Rendah Konsisten', 'title' => $la->nama_kegiatan . ' (' . $la->persen . '%)', 'description' => "Kegiatan {$la->nama_kegiatan} memiliki kehadiran konsisten di bawah 75% selama 3 minggu terakhir.", 'action_url' => route('admin.laporan-kegiatan.analisis-kegiatan', $la->kegiatan_id), 'action_text' => 'Analisis Detail', ]; } // Pattern 2: Day-Specific Low Attendance $dayStats = AbsensiKegiatan::where('tanggal', '>=', Carbon::now()->subMonth()) ->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id') ->select( 'kegiatans.hari', DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('kegiatans.hari') ->having('persen', '<', 70) ->get(); foreach ($dayStats as $ds) { $patterns[] = [ 'type' => 'info', 'category' => 'Pola Hari Tertentu', 'title' => "Kehadiran rendah di hari {$ds->hari} ({$ds->persen}%)", 'description' => "Rata-rata kehadiran di hari {$ds->hari} hanya {$ds->persen}% dalam sebulan terakhir.", 'action_url' => null, 'action_text' => null, ]; } // Pattern 3: Class Attendance Drop (>10% drop vs last week) $thisWeekStart = Carbon::now()->startOfWeek(); $thisWeekEnd = Carbon::now()->endOfWeek(); $lastWeekStart = Carbon::now()->subWeek()->startOfWeek(); $lastWeekEnd = Carbon::now()->subWeek()->endOfWeek(); $kelasAll = Kelas::active()->get(); foreach ($kelasAll as $kelas) { $sIds = SantriKelas::where('id_kelas', $kelas->id)->pluck('id_santri'); if ($sIds->isEmpty()) continue; $thisWeek = AbsensiKegiatan::whereIn('id_santri', $sIds) ->whereBetween('tanggal', [$thisWeekStart, $thisWeekEnd]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $lastWeek = AbsensiKegiatan::whereIn('id_santri', $sIds) ->whereBetween('tanggal', [$lastWeekStart, $lastWeekEnd]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $persenThis = ($thisWeek->total ?? 0) > 0 ? round(($thisWeek->hadir / $thisWeek->total) * 100, 1) : null; $persenLast = ($lastWeek->total ?? 0) > 0 ? round(($lastWeek->hadir / $lastWeek->total) * 100, 1) : null; if ($persenThis !== null && $persenLast !== null && ($persenLast - $persenThis) > 10) { $drop = round($persenLast - $persenThis, 1); $patterns[] = [ 'type' => 'danger', 'category' => 'Penurunan Kelas', 'title' => "Kelas {$kelas->nama_kelas} turun {$drop}%", 'description' => "Kehadiran kelas {$kelas->nama_kelas} turun dari {$persenLast}% ke {$persenThis}% dalam seminggu.", 'action_url' => null, 'action_text' => 'Lihat Detail Kelas', ]; } } // Pattern 4: Santri Absent Streak (3+ consecutive absences) $santriAbsentStreak = DB::select(" SELECT s.id_santri, s.nama_lengkap, COUNT(*) as consecutive_absent FROM absensi_kegiatans a JOIN santris s ON a.id_santri = s.id_santri WHERE a.status = 'Alpa' AND a.tanggal >= ? AND s.status = 'Aktif' GROUP BY s.id_santri, s.nama_lengkap HAVING consecutive_absent >= 3 ORDER BY consecutive_absent DESC LIMIT 10 ", [Carbon::now()->subWeeks(2)->format('Y-m-d')]); foreach ($santriAbsentStreak as $sas) { $patterns[] = [ 'type' => 'danger', 'category' => 'Absen Beruntun', 'title' => "{$sas->nama_lengkap} ({$sas->consecutive_absent}x Alpa)", 'description' => "Santri {$sas->nama_lengkap} tercatat {$sas->consecutive_absent} kali Alpa dalam 2 minggu terakhir.", 'action_url' => route('admin.laporan-kegiatan.detail-santri', $sas->id_santri), 'action_text' => 'Lihat Detail', ]; } if ($request->ajax()) { return response()->json($patterns); } return $patterns; } /** * ═══════════════════════════════════════════ * H. EXPORT EXCEL (CSV Fallback) * ═══════════════════════════════════════════ */ public function exportExcel(Request $request) { [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'bulan_ini'), $request ); $contents = $request->get('content', ['summary']); $filename = 'laporan_kegiatan_' . $startDate->format('Ymd') . '_' . $endDate->format('Ymd') . '.csv'; return response()->streamDownload(function () use ($startDate, $endDate, $contents) { $handle = fopen('php://output', 'w'); // BOM for Excel UTF-8 fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF)); // Sheet 1: Summary if (in_array('summary', $contents)) { fputcsv($handle, ['=== RINGKASAN LAPORAN KEGIATAN ===']); fputcsv($handle, ['Periode', $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y')]); $kpi = $this->calculateKpi($startDate, $endDate); fputcsv($handle, ['Total Kegiatan', $kpi['total_kegiatan']]); fputcsv($handle, ['Rata-rata Kehadiran', $kpi['avg_kehadiran'] . '%']); fputcsv($handle, ['Kegiatan Terbaik', $kpi['kegiatan_terbaik']['nama'] ?? '-', ($kpi['kegiatan_terbaik']['persen'] ?? 0) . '%']); fputcsv($handle, ['Santri Perlu Perhatian', $kpi['santri_perlu_perhatian']]); fputcsv($handle, []); } // Per Kelas if (in_array('per_kelas', $contents)) { fputcsv($handle, ['=== KEHADIRAN PER KELAS ===']); fputcsv($handle, ['Kelompok', 'Kelas', 'Jumlah Santri', 'Total Absensi', 'Hadir', 'Izin', 'Sakit', 'Alpa', '% Kehadiran']); $perKelas = $this->getKehadiranPerKelas($startDate, $endDate); foreach ($perKelas as $kelompok) { foreach ($kelompok['kelas'] as $k) { fputcsv($handle, [ $kelompok['nama_kelompok'], $k['nama_kelas'], $k['jumlah_santri'], $k['total'], $k['hadir'], $k['izin'], $k['sakit'], $k['alpa'], $k['persen'] . '%' ]); } } fputcsv($handle, []); } // Per Santri (warning: large) if (in_array('per_santri', $contents)) { fputcsv($handle, ['=== DETAIL PER SANTRI ===']); fputcsv($handle, ['ID Santri', 'Nama', 'Total', 'Hadir', 'Izin', 'Sakit', 'Alpa', '% Kehadiran']); $perSantri = AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->join('santris', 'absensi_kegiatans.id_santri', '=', 'santris.id_santri') ->where('santris.status', 'Aktif') ->select( 'santris.id_santri', 'santris.nama_lengkap', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Izin" THEN 1 ELSE 0 END) as izin'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Sakit" THEN 1 ELSE 0 END) as sakit'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Alpa" THEN 1 ELSE 0 END) as alpa') ) ->groupBy('santris.id_santri', 'santris.nama_lengkap') ->orderBy('santris.nama_lengkap') ->get(); foreach ($perSantri as $s) { $persen = $s->total > 0 ? round(($s->hadir / $s->total) * 100, 1) : 0; fputcsv($handle, [$s->id_santri, $s->nama_lengkap, $s->total, $s->hadir, $s->izin, $s->sakit, $s->alpa, $persen . '%']); } fputcsv($handle, []); } fclose($handle); }, $filename, [ 'Content-Type' => 'text/csv', ]); } /** * ═══════════════════════════════════════════ * I. EXPORT PDF * ═══════════════════════════════════════════ */ public function exportPdf(Request $request) { [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'bulan_ini'), $request ); $periodeLabel = $this->getPeriodeLabel($request->get('periode', 'bulan_ini'), $startDate, $endDate); $kpi = $this->calculateKpi($startDate, $endDate); $kehadiranPerKelas = $this->getKehadiranPerKelas($startDate, $endDate); $topKegiatan = $this->getTopBottomKegiatan($startDate, $endDate, 'top', 5); $bottomKegiatan = $this->getTopBottomKegiatan($startDate, $endDate, 'bottom', 5); $distribusiSantri = $this->getDistribusiSantri($startDate, $endDate); $santriPerluPerhatianList = $this->getSantriPerluPerhatianList($startDate, $endDate, 15); $pdf = Pdf::loadView('admin.kegiatan.laporan.pdf-template', compact( 'kpi', 'periodeLabel', 'startDate', 'endDate', 'kehadiranPerKelas', 'topKegiatan', 'bottomKegiatan', 'distribusiSantri', 'santriPerluPerhatianList' )); $pdf->setPaper('A4', 'portrait'); return $pdf->download('laporan_kegiatan_' . $startDate->format('Ymd') . '_' . $endDate->format('Ymd') . '.pdf'); } /** * ═══════════════════════════════════════════ * J. REFRESH KPI (AJAX) * ═══════════════════════════════════════════ */ public function refreshKpi(Request $request) { [$startDate, $endDate] = $this->getPeriodeRange( $request->get('periode', 'minggu_ini'), $request ); $kpi = $this->calculateKpi($startDate, $endDate); return response()->json($kpi); } // ═══════════════════════════════════════════ // PRIVATE HELPER METHODS // ═══════════════════════════════════════════ /** * Get date range for a given period */ private function getPeriodeRange($periode, Request $request = null) { switch ($periode) { case 'hari_ini': return [Carbon::today(), Carbon::today()]; case 'minggu_ini': return [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()]; case 'bulan_ini': return [Carbon::now()->startOfMonth(), Carbon::now()->endOfMonth()]; case 'semester_ini': $now = Carbon::now(); if ($now->month >= 7) { return [Carbon::create($now->year, 7, 1), Carbon::create($now->year, 12, 31)]; } return [Carbon::create($now->year, 1, 1), Carbon::create($now->year, 6, 30)]; case 'custom': $dari = $request ? $request->get('tanggal_dari', Carbon::now()->startOfMonth()->format('Y-m-d')) : Carbon::now()->startOfMonth()->format('Y-m-d'); $sampai = $request ? $request->get('tanggal_sampai', Carbon::now()->format('Y-m-d')) : Carbon::now()->format('Y-m-d'); return [Carbon::parse($dari), Carbon::parse($sampai)]; default: return [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()]; } } /** * Get the previous period range (same length, shifted back) */ private function getPreviousPeriodeRange($periode, $startDate, $endDate) { $diff = $startDate->diffInDays($endDate) + 1; return [ Carbon::parse($startDate)->subDays($diff), Carbon::parse($endDate)->subDays($diff), ]; } /** * Human-readable period label */ private function getPeriodeLabel($periode, $startDate, $endDate) { $labels = [ 'hari_ini' => 'Hari Ini (' . Carbon::today()->locale('id')->isoFormat('D MMMM YYYY') . ')', 'minggu_ini' => 'Minggu Ini', 'bulan_ini' => Carbon::now()->locale('id')->isoFormat('MMMM YYYY'), 'semester_ini' => 'Semester ' . (Carbon::now()->month >= 7 ? 'Ganjil' : 'Genap') . ' ' . Carbon::now()->year, 'custom' => $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y'), ]; return $labels[$periode] ?? $labels['minggu_ini']; } /** * Calculate KPI metrics */ private function calculateKpi($startDate, $endDate) { // Total kegiatan unik $totalKegiatan = AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->distinct('kegiatan_id') ->count('kegiatan_id'); // Rata-rata kehadiran $avgData = AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $avgKehadiran = ($avgData->total ?? 0) > 0 ? round(($avgData->hadir / $avgData->total) * 100, 1) : 0; // Kegiatan terbaik $kegiatanTerbaik = AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id') ->select( 'kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan') ->orderByDesc('persen') ->first(); // Santri perlu perhatian (<70%) $santriPerluPerhatian = DB::table('absensi_kegiatans') ->whereBetween('tanggal', [$startDate, $endDate]) ->join('santris', 'absensi_kegiatans.id_santri', '=', 'santris.id_santri') ->where('santris.status', 'Aktif') ->select( 'santris.id_santri', DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('santris.id_santri') ->having('persen', '<', 70) ->get() ->count(); return [ 'total_kegiatan' => $totalKegiatan, 'avg_kehadiran' => $avgKehadiran, 'kegiatan_terbaik' => $kegiatanTerbaik ? [ 'nama' => $kegiatanTerbaik->nama_kegiatan, 'persen' => $kegiatanTerbaik->persen, ] : ['nama' => '-', 'persen' => 0], 'santri_perlu_perhatian' => $santriPerluPerhatian, ]; } /** * Get trend data for line chart */ private function getTrendData($startDate, $endDate) { $diffDays = $startDate->diffInDays($endDate); $groupBy = $diffDays > 14 ? 'week' : 'day'; $kategoris = KategoriKegiatan::all(); $labels = []; $datasets = []; if ($groupBy === 'week') { $current = Carbon::parse($startDate)->startOfWeek(); $weekNum = 1; while ($current->lte($endDate)) { $labels[] = 'Mg ' . $weekNum; $weekNum++; $current->addWeek(); } foreach ($kategoris as $kategori) { $data = []; $current = Carbon::parse($startDate)->startOfWeek(); while ($current->lte($endDate)) { $ws = Carbon::parse($current); $we = Carbon::parse($current)->endOfWeek(); $wd = AbsensiKegiatan::whereBetween('tanggal', [$ws, $we]) ->whereHas('kegiatan', fn($q) => $q->where('kategori_id', $kategori->kategori_id)) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $data[] = ($wd->total ?? 0) > 0 ? round(($wd->hadir / $wd->total) * 100, 1) : null; $current->addWeek(); } $datasets[] = ['kategori' => $kategori->nama_kategori, 'data' => $data]; } } else { $current = Carbon::parse($startDate); while ($current->lte($endDate)) { $labels[] = $current->format('d/m'); $current->addDay(); } foreach ($kategoris as $kategori) { $data = []; $current = Carbon::parse($startDate); while ($current->lte($endDate)) { $wd = AbsensiKegiatan::whereDate('tanggal', $current) ->whereHas('kegiatan', fn($q) => $q->where('kategori_id', $kategori->kategori_id)) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $data[] = ($wd->total ?? 0) > 0 ? round(($wd->hadir / $wd->total) * 100, 1) : null; $current->addDay(); } $datasets[] = ['kategori' => $kategori->nama_kategori, 'data' => $data]; } } return ['labels' => $labels, 'datasets' => $datasets]; } /** * Get santri distribution by attendance percentage */ private function getDistribusiSantri($startDate, $endDate) { $santriStats = DB::table('absensi_kegiatans') ->whereBetween('tanggal', [$startDate, $endDate]) ->join('santris', 'absensi_kegiatans.id_santri', '=', 'santris.id_santri') ->where('santris.status', 'Aktif') ->select( 'santris.id_santri', DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('santris.id_santri') ->get(); $distribusi = [ 'Perfect (100%)' => 0, 'Sangat Baik (95-99%)' => 0, 'Baik (85-94%)' => 0, 'Cukup (75-84%)' => 0, 'Perlu Perhatian (<75%)' => 0, ]; foreach ($santriStats as $s) { if ($s->persen >= 100) $distribusi['Perfect (100%)']++; elseif ($s->persen >= 95) $distribusi['Sangat Baik (95-99%)']++; elseif ($s->persen >= 85) $distribusi['Baik (85-94%)']++; elseif ($s->persen >= 75) $distribusi['Cukup (75-84%)']++; else $distribusi['Perlu Perhatian (<75%)']++; } $total = $santriStats->count(); return collect($distribusi)->map(function ($count, $label) use ($total) { return [ 'label' => $label, 'count' => $count, 'percentage' => $total > 0 ? round(($count / $total) * 100, 1) : 0, ]; })->values()->toArray(); } /** * Get top or bottom kegiatan by attendance */ private function getTopBottomKegiatan($startDate, $endDate, $type = 'top', $limit = 5) { $order = $type === 'top' ? 'desc' : 'asc'; return AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id') ->leftJoin('kategori_kegiatans', 'kegiatans.kategori_id', '=', 'kategori_kegiatans.kategori_id') ->select( 'kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', 'kategori_kegiatans.nama_kategori', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', 'kategori_kegiatans.nama_kategori') ->orderBy('persen', $order) ->limit($limit) ->get() ->toArray(); } /** * Get kehadiran per kelas grouped by kelompok */ private function getKehadiranPerKelas($startDate, $endDate) { $kelompoks = KelompokKelas::active()->ordered() ->with(['kelas' => fn($q) => $q->active()->ordered()]) ->get(); $result = []; foreach ($kelompoks as $kelompok) { $kelasData = []; foreach ($kelompok->kelas as $kelas) { $santriIds = SantriKelas::where('id_kelas', $kelas->id)->pluck('id_santri'); if ($santriIds->isEmpty()) continue; $absensi = AbsensiKegiatan::whereIn('id_santri', $santriIds) ->whereBetween('tanggal', [$startDate, $endDate]) ->selectRaw(' COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir, SUM(CASE WHEN status="Izin" THEN 1 ELSE 0 END) as izin, SUM(CASE WHEN status="Sakit" THEN 1 ELSE 0 END) as sakit, SUM(CASE WHEN status="Alpa" THEN 1 ELSE 0 END) as alpa ') ->first(); $kelasData[] = [ 'id_kelas' => $kelas->id, 'nama_kelas' => $kelas->nama_kelas, 'jumlah_santri' => $santriIds->count(), 'total' => $absensi->total ?? 0, 'hadir' => $absensi->hadir ?? 0, 'izin' => $absensi->izin ?? 0, 'sakit' => $absensi->sakit ?? 0, 'alpa' => $absensi->alpa ?? 0, 'persen' => ($absensi->total ?? 0) > 0 ? round(($absensi->hadir / $absensi->total) * 100, 1) : 0, ]; } if (!empty($kelasData)) { $result[] = [ 'nama_kelompok' => $kelompok->nama_kelompok, 'kelas' => $kelasData, ]; } } return $result; } /** * Get heatmap data: Kelas vs Kategori */ private function getHeatmapData($startDate, $endDate) { $kategoris = KategoriKegiatan::all(); $kelasList = Kelas::active()->ordered()->with('kelompok')->get(); $heatmap = []; foreach ($kelasList as $kelas) { $santriIds = SantriKelas::where('id_kelas', $kelas->id)->pluck('id_santri'); $row = ['kelas' => $kelas->nama_kelas, 'kelompok' => $kelas->kelompok->nama_kelompok ?? '-', 'data' => []]; foreach ($kategoris as $kategori) { if ($santriIds->isEmpty()) { $row['data'][$kategori->nama_kategori] = null; continue; } // Check if this kelas has kegiatan in this kategori $kegiatanIds = Kegiatan::where('kategori_id', $kategori->kategori_id) ->pluck('kegiatan_id'); $absensi = AbsensiKegiatan::whereIn('id_santri', $santriIds) ->whereIn('kegiatan_id', $kegiatanIds) ->whereBetween('tanggal', [$startDate, $endDate]) ->selectRaw('COUNT(*) as total, SUM(CASE WHEN status="Hadir" THEN 1 ELSE 0 END) as hadir') ->first(); $row['data'][$kategori->nama_kategori] = ($absensi->total ?? 0) > 0 ? round(($absensi->hadir / $absensi->total) * 100, 1) : null; } $heatmap[] = $row; } return ['rows' => $heatmap, 'columns' => $kategoris->pluck('nama_kategori')->toArray()]; } /** * Get santri perlu perhatian list */ private function getSantriPerluPerhatianList($startDate, $endDate, $limit = 10) { return AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->join('santris', 'absensi_kegiatans.id_santri', '=', 'santris.id_santri') ->where('santris.status', 'Aktif') ->select( 'santris.id_santri', 'santris.nama_lengkap', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Alpa" THEN 1 ELSE 0 END) as alpa'), DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('santris.id_santri', 'santris.nama_lengkap') ->having('persen', '<', 70) ->orderBy('persen', 'asc') ->limit($limit) ->get(); } /** * Get leaderboard (top santri) */ private function getLeaderboard($startDate, $endDate, $limit = 10, $idKelas = null) { $query = AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->join('santris', 'absensi_kegiatans.id_santri', '=', 'santris.id_santri') ->where('santris.status', 'Aktif') ->select( 'santris.id_santri', 'santris.nama_lengkap', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('santris.id_santri', 'santris.nama_lengkap') ->orderByDesc('persen') ->orderByDesc('hadir') ->limit($limit); if ($idKelas) { $sIds = SantriKelas::where('id_kelas', $idKelas)->pluck('id_santri'); $query->whereIn('santris.id_santri', $sIds); } return $query->get()->map(function ($s) { $s->streak = $this->calculateStreak($s->id_santri); return $s; }); } /** * Get kegiatan performance table */ private function getKegiatanPerformance($startDate, $endDate) { return AbsensiKegiatan::whereBetween('tanggal', [$startDate, $endDate]) ->join('kegiatans', 'absensi_kegiatans.kegiatan_id', '=', 'kegiatans.kegiatan_id') ->leftJoin('kategori_kegiatans', 'kegiatans.kategori_id', '=', 'kategori_kegiatans.kategori_id') ->select( 'kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', 'kegiatans.hari', 'kategori_kegiatans.nama_kategori', DB::raw('COUNT(*) as total'), DB::raw('SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END) as hadir'), DB::raw('ROUND(SUM(CASE WHEN absensi_kegiatans.status="Hadir" THEN 1 ELSE 0 END)/COUNT(*)*100,1) as persen') ) ->groupBy('kegiatans.kegiatan_id', 'kegiatans.nama_kegiatan', 'kegiatans.hari', 'kategori_kegiatans.nama_kategori') ->orderByDesc('persen') ->get(); } /** * Calculate consecutive hadir streak for a santri */ private function calculateStreak($id_santri) { $absensis = AbsensiKegiatan::where('id_santri', $id_santri) ->orderByDesc('tanggal') ->orderByDesc('waktu_absen') ->select('status') ->limit(50) ->get(); $streak = 0; foreach ($absensis as $a) { if ($a->status === 'Hadir') { $streak++; } else { break; } } return $streak; } /** * Generate insights for a specific kegiatan */ private function generateKegiatanInsights($kegiatan, $stats, $trend, $breakdownPerKelas) { $insights = []; // Insight: Overall performance if ($stats->persen >= 90) { $insights[] = [ 'type' => 'success', 'icon' => 'fas fa-star', 'text' => "Kegiatan {$kegiatan->nama_kegiatan} memiliki kehadiran sangat baik ({$stats->persen}%).", ]; } elseif ($stats->persen < 70) { $insights[] = [ 'type' => 'danger', 'icon' => 'fas fa-exclamation-triangle', 'text' => "Kehadiran {$kegiatan->nama_kegiatan} di bawah standar ({$stats->persen}%). Perlu evaluasi.", ]; } // Insight: Trend direction if (count($trend) >= 2) { $last = end($trend)['persen']; $prev = $trend[count($trend) - 2]['persen']; $diff = $last - $prev; if ($diff > 5) { $insights[] = [ 'type' => 'success', 'icon' => 'fas fa-arrow-up', 'text' => "Tren kehadiran meningkat +{$diff}% dalam minggu terakhir.", ]; } elseif ($diff < -5) { $insights[] = [ 'type' => 'warning', 'icon' => 'fas fa-arrow-down', 'text' => "Tren kehadiran menurun {$diff}% dalam minggu terakhir.", ]; } } // Insight: Kelas with lowest attendance if (!empty($breakdownPerKelas)) { $lowest = collect($breakdownPerKelas)->sortBy('persen')->first(); if ($lowest && $lowest['persen'] < 70) { $insights[] = [ 'type' => 'warning', 'icon' => 'fas fa-users', 'text' => "Kelas {$lowest['kelas']} memiliki kehadiran terendah ({$lowest['persen']}%).", ]; } } // Insight: Alpa count if ($stats->alpa > 0) { $alpaPercent = $stats->total > 0 ? round(($stats->alpa / $stats->total) * 100, 1) : 0; if ($alpaPercent > 10) { $insights[] = [ 'type' => 'danger', 'icon' => 'fas fa-user-times', 'text' => "Tingkat Alpa mencapai {$alpaPercent}% ({$stats->alpa} kali). Perlu tindakan segera.", ]; } } return $insights; } }