From 9e1b5c34e5e26d3dc030c1353f09d68d83b89dfe Mon Sep 17 00:00:00 2001 From: Novy San87 Date: Thu, 23 Jul 2026 11:11:28 +0700 Subject: [PATCH] feat: rekap presensi harian, mingguan, bulanan + export excel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 3 jenis rekap presensi (harian/mingguan/bulanan) per kelas - Weekly recap: tabel H,S,I,A per hari + kolom TOTAL - Alpha proporsional: A = jam_alpha / total_jam - Pemisah border antar hari dan kolom TOTAL - Export Excel rekap mingguan (.xlsx) - Fix max_input_vars PHP limit (1000→5000) - Fix Carbon date object issue di groupBy --- app/Http/Controllers/AttendanceController.php | 335 ++++++++++++++-- resources/views/attendances/recap.blade.php | 366 +++++++++++++++--- routes/web.php | 1 + 3 files changed, 619 insertions(+), 83 deletions(-) diff --git a/app/Http/Controllers/AttendanceController.php b/app/Http/Controllers/AttendanceController.php index c04edc2..4acee76 100644 --- a/app/Http/Controllers/AttendanceController.php +++ b/app/Http/Controllers/AttendanceController.php @@ -217,43 +217,197 @@ class AttendanceController extends Controller public function recap(Request $request): View { - $month = $request->input('month', now()->month); - $year = $request->input('year', now()->year); + $type = $request->input('type', 'daily'); $className = $request->input('class_name'); - $query = Student::where('is_active', true); - if ($className) { - $query->where('class_name', $className); - } - $students = $query->orderBy('class_name')->orderBy('full_name')->get(); - - $recap = []; - foreach ($students as $student) { - $attendances = Attendance::where('student_id', $student->id) - ->whereMonth('date', $month) - ->whereYear('date', $year) - ->get() - ->groupBy('status') - ->map(fn($g) => $g->count()); - - $recap[] = (object) [ - 'student' => $student, - 'hadir' => $attendances->get('hadir', 0), - 'sakit' => $attendances->get('sakit', 0), - 'izin' => $attendances->get('izin', 0), - 'alpha' => $attendances->get('alpha', 0), - 'terlambat' => $attendances->get('terlambat', 0), - 'total' => $attendances->sum(), - ]; - } - $classNames = Student::where('is_active', true) ->whereNotNull('class_name') ->distinct() ->orderBy('class_name') ->pluck('class_name'); - return view('attendances.recap', compact('recap', 'month', 'year', 'className', 'classNames')); + if ($type === 'daily') { + return $this->recapDaily($request, $classNames, $type); + } elseif ($type === 'weekly') { + return $this->recapWeekly($request, $classNames, $type); + } else { + return $this->recapMonthly($request, $classNames, $type); + } + } + + protected function recapDaily(Request $request, $classNames, $type = 'daily'): View + { + $date = $request->input('date', now()->toDateString()); + $className = $request->input('class_name'); + + $students = collect(); + $attendances = collect(); + if ($className) { + $students = Student::where('is_active', true) + ->where('class_name', $className) + ->orderBy('full_name') + ->get(); + + $attendances = Attendance::where('date', $date) + ->whereIn('student_id', $students->pluck('id')) + ->get() + ->keyBy(fn($a) => $a->student_id . '-' . $a->lesson_hour); + } + + $lessonHours = range(1, 10); + + return view('attendances.recap', compact( + 'type', 'date', 'className', 'classNames', + 'students', 'attendances', 'lessonHours' + )); + } + + protected function recapWeekly(Request $request, $classNames, $type = 'weekly'): View + { + $weekStart = $request->input('week_start', now()->startOfWeek()->toDateString()); + $start = \Carbon\Carbon::parse($weekStart)->startOfWeek(); + $end = (clone $start)->addDays(5); // Senin - Sabtu + $className = $request->input('class_name'); + + $students = collect(); + $recap = collect(); + $totals = []; + if ($className) { + $students = Student::where('is_active', true) + ->where('class_name', $className) + ->orderBy('full_name') + ->get(); + + $allAttendances = Attendance::whereBetween('date', [$start->toDateString(), $end->toDateString()]) + ->whereIn('student_id', $students->pluck('id')) + ->get() + ->groupBy(fn($a) => \Illuminate\Support\Carbon::parse($a->date)->toDateString() . '-' . $a->student_id); + + $dayLabels = ['Senin', 'Selasa', 'Rabu', 'Kamis', "Jum'at", 'Sabtu']; + for ($i = 0; $i <= 5; $i++) { + $day = (clone $start)->addDays($i); + $dateStr = $day->toDateString(); + + $row = ['label' => $dayLabels[$i], 'date' => $dateStr, 'students' => []]; + foreach ($students as $student) { + $key = $dateStr . '-' . $student->id; + $studentAtt = $allAttendances->get($key, collect()); + + // Per hari: 1 jika ada minimal 1 record status tsb + $hadir = $studentAtt->where('status', 'hadir')->count() > 0 ? 1 : 0; + // Alpha proporsional: jumlah jam alpha / total jam + $total = $studentAtt->count(); + $alphaCount = $studentAtt->where('status', 'alpha')->count(); + $alpha = $total > 0 ? round($alphaCount / $total, 1) : 0; + $sakit = $studentAtt->where('status', 'sakit')->count() > 0 ? 1 : 0; + $izin = $studentAtt->where('status', 'izin')->count() > 0 ? 1 : 0; + $terlambat = $studentAtt->where('status', 'terlambat')->count() > 0 ? 1 : 0; + + $row['students'][$student->id] = [ + 'name' => $student->full_name, + 'hadir' => $hadir, + 'alpha' => $alpha, + 'sakit' => $sakit, + 'izin' => $izin, + 'terlambat' => $terlambat, + 'total' => $studentAtt->count(), + ]; + } + $recap->push($row); + } + + // Hitung total per siswa selama seminggu + $totals = []; + foreach ($students as $student) { + $totals[$student->id] = ['hadir' => 0, 'sakit' => 0, 'izin' => 0, 'alpha' => 0, 'terlambat' => 0]; + } + foreach ($recap as $row) { + foreach ($row['students'] as $sid => $s) { + $totals[$sid]['hadir'] += $s['hadir']; + $totals[$sid]['sakit'] += $s['sakit']; + $totals[$sid]['izin'] += $s['izin']; + $totals[$sid]['alpha'] += $s['alpha']; + $totals[$sid]['terlambat'] += $s['terlambat']; + } + } + } + + return view('attendances.recap', compact( + 'type', 'start', 'end', 'className', 'classNames', + 'students', 'recap', 'totals' + )); + } + + protected function recapMonthly(Request $request, $classNames, $type = 'monthly'): View + { + $month = (int) $request->input('month', now()->month); + $year = (int) $request->input('year', now()->year); + $className = $request->input('class_name'); + + $firstDay = \Carbon\Carbon::create($year, $month, 1); + $lastDay = \Carbon\Carbon::create($year, $month, $firstDay->daysInMonth); + + // Generate minggu dalam bulan + $weeks = []; + $weekStart = (clone $firstDay)->startOfWeek(); + $weekNum = 1; + while ($weekStart <= $lastDay) { + $weekEnd = (clone $weekStart)->addDays(6); + $weeks[$weekNum] = [ + 'label' => 'Minggu ' . $weekNum, + 'start' => $weekStart->copy()->max($firstDay)->toDateString(), + 'end' => $weekEnd->copy()->min($lastDay)->toDateString(), + ]; + $weekStart->addWeek(); + $weekNum++; + } + + $students = collect(); + $recap = collect(); + if ($className) { + $students = Student::where('is_active', true) + ->where('class_name', $className) + ->orderBy('full_name') + ->get(); + + $allAttendances = Attendance::whereMonth('date', $month) + ->whereYear('date', $year) + ->whereIn('student_id', $students->pluck('id')) + ->get(); + + foreach ($weeks as $weekNum => $week) { + $weekAtt = $allAttendances->filter(fn($a) => + $a->date >= $week['start'] && $a->date <= $week['end'] + )->groupBy('student_id'); + + $row = ['label' => $week['label'], 'start' => $week['start'], 'end' => $week['end'], 'students' => []]; + foreach ($students as $student) { + $studentAtt = $weekAtt->get($student->id, collect()); + + $hadir = $studentAtt->where('status', 'hadir')->count(); + $alpha = $studentAtt->where('status', 'alpha')->count(); + $sakit = $studentAtt->where('status', 'sakit')->count(); + $izin = $studentAtt->where('status', 'izin')->count(); + $terlambat = $studentAtt->where('status', 'terlambat')->count(); + + $row['students'][$student->id] = [ + 'name' => $student->full_name, + 'hadir' => $hadir, + 'alpha' => $alpha, + 'sakit' => $sakit, + 'izin' => $izin, + 'terlambat' => $terlambat, + 'total' => $studentAtt->count(), + ]; + } + $recap->push($row); + } + } + + return view('attendances.recap', compact( + 'type', 'month', 'year', 'className', 'classNames', + 'students', 'recap', 'weeks' + )); } public function calendarData(Request $request): JsonResponse @@ -289,4 +443,127 @@ class AttendanceController extends Controller return response()->json($result); } + public function exportWeekly(Request $request) + { + $classNames = Student::where('is_active', true) + ->whereNotNull('class_name') + ->distinct() + ->orderBy('class_name') + ->pluck('class_name'); + + $view = $this->recapWeekly($request, $classNames); + $data = $view->getData(); + + $spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + + // Title + $title = 'Rekap Presensi Mingguan'; + if ($data['className']) { + $title .= ' - ' . $data['className']; + } + $title .= ' (' . \Carbon\Carbon::parse($data['start'])->format('d/m/Y') . ' - ' . \Carbon\Carbon::parse($data['end'])->format('d/m/Y') . ')'; + + $sheet->setCellValue('A1', $title); + $totalCols = 1 + $data['recap']->count() * 4 + 4; // +4 untuk kolom TOTAL + $sheet->mergeCells('A1:' . $this->getColLetter($totalCols - 1) . '1'); + $sheet->getStyle('A1')->getFont()->setBold(true)->setSize(14); + + // Header row 2: day names + TOTAL + $sheet->setCellValue('A2', 'Siswa'); + $sheet->getStyle('A2')->getFont()->setBold(true); + $col = 2; + foreach ($data['recap'] as $row) { + $cell = $this->getColLetter($col) . '2'; + $sheet->setCellValue($cell, $row['label'] . ' ' . \Carbon\Carbon::parse($row['date'])->format('d/m')); + $endCell = $this->getColLetter($col + 3) . '2'; + $sheet->mergeCells($cell . ':' . $endCell); + $sheet->getStyle($cell)->getFont()->setBold(true); + $col += 4; + } + // Kolom TOTAL + $totalCell = $this->getColLetter($col) . '2'; + $sheet->setCellValue($totalCell, 'TOTAL'); + $sheet->mergeCells($totalCell . ':' . $this->getColLetter($col + 3) . '2'); + $sheet->getStyle($totalCell)->getFont()->setBold(true); + + // Header row 3: H, S, I, A per day + TOTAL + $sheet->setCellValue('A3', ''); + $col = 2; + foreach ($data['recap'] as $row) { + $sheet->setCellValue($this->getColLetter($col) . '3', 'H'); + $sheet->setCellValue($this->getColLetter($col + 1) . '3', 'S'); + $sheet->setCellValue($this->getColLetter($col + 2) . '3', 'I'); + $sheet->setCellValue($this->getColLetter($col + 3) . '3', 'A'); + foreach (range(0, 3) as $i) { + $sheet->getStyle($this->getColLetter($col + $i) . '3')->getFont()->setBold(true); + } + $col += 4; + } + // Sub-header TOTAL + $sheet->setCellValue($this->getColLetter($col) . '3', 'H'); + $sheet->setCellValue($this->getColLetter($col + 1) . '3', 'S'); + $sheet->setCellValue($this->getColLetter($col + 2) . '3', 'I'); + $sheet->setCellValue($this->getColLetter($col + 3) . '3', 'A'); + foreach (range(0, 3) as $i) { + $sheet->getStyle($this->getColLetter($col + $i) . '3')->getFont()->setBold(true); + } + + // Data rows + $rowIdx = 4; + foreach ($data['students'] as $student) { + $sheet->setCellValue('A' . $rowIdx, $student->full_name); + $col = 2; + foreach ($data['recap'] as $row) { + $s = $row['students'][$student->id] ?? null; + if ($s) { + $sheet->setCellValue($this->getColLetter($col) . $rowIdx, $s['hadir']); + $sheet->setCellValue($this->getColLetter($col + 1) . $rowIdx, $s['sakit']); + $sheet->setCellValue($this->getColLetter($col + 2) . $rowIdx, $s['izin']); + $sheet->setCellValue($this->getColLetter($col + 3) . $rowIdx, $s['alpha']); + } else { + foreach (range(0, 3) as $i) { + $sheet->setCellValue($this->getColLetter($col + $i) . $rowIdx, '-'); + } + } + $col += 4; + } + // TOTAL per siswa + $t = $data['totals'][$student->id] ?? ['hadir' => 0, 'sakit' => 0, 'izin' => 0, 'alpha' => 0]; + $sheet->setCellValue($this->getColLetter($col) . $rowIdx, $t['hadir']); + $sheet->setCellValue($this->getColLetter($col + 1) . $rowIdx, $t['sakit']); + $sheet->setCellValue($this->getColLetter($col + 2) . $rowIdx, $t['izin']); + $sheet->setCellValue($this->getColLetter($col + 3) . $rowIdx, $t['alpha']); + + $rowIdx++; + } + + // Auto width + for ($c = 1; $c < $totalCols; $c++) { + $sheet->getColumnDimension($this->getColLetter($c))->setAutoSize(true); + } + + $writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet); + + $filename = 'rekap-mingguan' . ($data['className'] ? '-' . $data['className'] : '') . '.xlsx'; + $filename = str_replace(['/', '\\', ' '], '-', $filename); + + return response()->streamDownload(function () use ($writer) { + $writer->save('php://output'); + }, $filename, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]); + } + + private function getColLetter(int $col): string + { + $letter = ''; + while ($col > 0) { + $col--; + $letter = chr(65 + $col % 26) . $letter; + $col = intval($col / 26); + } + return $letter; + } + } \ No newline at end of file diff --git a/resources/views/attendances/recap.blade.php b/resources/views/attendances/recap.blade.php index 9fda07f..f4633f4 100644 --- a/resources/views/attendances/recap.blade.php +++ b/resources/views/attendances/recap.blade.php @@ -10,10 +10,14 @@ -

Rekap Presensi Bulanan

-

Rekapitulasi kehadiran siswa per jam pelajaran

+

Rekap Presensi

+

+ @if($type == 'daily') Rekap per jam pelajaran + @elseif($type == 'weekly') Rekap per hari (Senin - Sabtu) + @else Rekap per minggu @endif +

@@ -22,11 +26,28 @@ - {{-- Filter --}} -
-
-
-
+ {{-- Tabs --}} +
+ + + {{-- Filter --}} +
+ + +
+
+ @elseif($type == 'weekly') +
+ + +
+ @else
-
- - -
+ +
+ @endif + +
+
-
- + +
- {{-- Table --}} + {{-- ===== REKAP HARIAN ===== --}} + @if($type == 'daily')
-
- +
+
-

- Rekap {{ \Carbon\Carbon::create()->month($month)->format('F') }} {{ $year }} -

-

{{ count($recap) }} siswa

+

Rekap Harian — {{ \Carbon\Carbon::parse($date ?? now())->translatedFormat('l, d F Y') }}

+

{{ $students->count() }} siswa @if($className) • Kelas {{ $className }} @endif

- @if(count($recap) > 0) + @if($students->isNotEmpty())
- - - - - - - + + @foreach($lessonHours as $hour) + + @endforeach - @foreach($recap as $r) + @foreach($students as $student) - - - - - - - + @foreach($lessonHours as $hour) + @php + $key = $student->id . '-' . $hour; + $att = $attendances->get($key); + $status = $att ? $att->status : '-'; + $color = match($status) { + 'hadir' => 'text-emerald-600 bg-emerald-50', + 'alpha' => 'text-red-600 bg-red-50', + 'sakit' => 'text-purple-600 bg-purple-50', + 'izin' => 'text-blue-600 bg-blue-50', + 'terlambat' => 'text-yellow-600 bg-yellow-50', + default => 'text-gray-300', + }; + $icon = match($status) { + 'hadir' => 'H', + 'alpha' => 'A', + 'sakit' => 'S', + 'izin' => 'I', + 'terlambat' => 'T', + default => '-', + }; + @endphp + + @endforeach @endforeach
Siswa✅ Hadir🟣 Sakit🔵 Izin❌ Alpha🟡 TerlambatTotalSiswaJam {{ $hour }}
-

{{ $r->student->full_name }}

-

{{ $r->student->class_name }}

+
+

{{ $student->full_name }}

+

{{ $student->class_name }}

{{ $r->hadir }}{{ $r->sakit }}{{ $r->izin }}{{ $r->alpha }}{{ $r->terlambat }}{{ $r->total }} + + {{ $icon }} + +
-
- Menampilkan rekap {{ \Carbon\Carbon::create()->month($month)->format('F') }} {{ $year }} - @if($className) • Kelas {{ $className }} @endif +
+ H Hadir + A Alpha + S Sakit + I Izin + T Terlambat
@else
- +
-

Belum Ada Data Presensi

-

Belum ada catatan presensi untuk bulan ini

+

Pilih Kelas & Tanggal

+

Pilih kelas dan tanggal untuk melihat rekap harian

@endif
+ + {{-- ===== REKAP MINGGUAN ===== --}} + @elseif($type == 'weekly') +
+
+
+
+
+ +
+
+

+ Rekap Mingguan — {{ \Carbon\Carbon::parse($start)->translatedFormat('d M') }} - {{ \Carbon\Carbon::parse($end)->translatedFormat('d M Y') }} +

+

{{ $students->count() }} siswa @if($className) • Kelas {{ $className }} @endif

+
+
+ + + Export + +
+
+ + @if($students->isNotEmpty()) +
+ + + + + @foreach($recap as $row) + @php + $sep = !$loop->last ? 'border-r-2 border-gray-300' : ''; + @endphp + + @endforeach + + + + @foreach($recap as $row) + @php + $sep = !$loop->last ? 'border-r-2 border-gray-300' : ''; + @endphp + + + + + @endforeach + + + + + + + + @foreach($students as $student) + + + @foreach($recap as $row) + @php + $s = $row['students'][$student->id] ?? null; + $sep = !$loop->last ? 'border-r-2 border-gray-200' : ''; + @endphp + @if($s) + + + + + @else + + + + + @endif + @endforeach + {{-- TOTAL --}} + @php + $t = $totals[$student->id] ?? ['hadir' => 0, 'sakit' => 0, 'izin' => 0, 'alpha' => 0]; + @endphp + + + + + + @endforeach + +
Siswa + {{ $row['label'] }}
+ {{ \Carbon\Carbon::parse($row['date'])->format('d/m') }} +
+ TOTAL
+ Hari +
HSIAHSIA
+

{{ $student->full_name }}

+
{{ $s['hadir'] }}{{ $s['sakit'] }}{{ $s['izin'] }}{{ $s['alpha'] }}————{{ $t['hadir'] }}{{ $t['sakit'] }}{{ $t['izin'] }}{{ $t['alpha'] }}
+
+
+ H Hadir + S Sakit + I Izin + A Alpha +
+ @else +
+
+ +
+

Pilih Kelas & Minggu

+

Pilih kelas dan minggu untuk melihat rekap mingguan

+
+ @endif +
+ + {{-- ===== REKAP BULANAN ===== --}} + @else +
+
+
+
+ +
+
+

+ Rekap Bulanan — {{ \Carbon\Carbon::create()->month($month)->format('F') }} {{ $year }} +

+

{{ $students->count() }} siswa @if($className) • Kelas {{ $className }} @endif

+
+
+
+ + @if($students->isNotEmpty()) +
+ + + + + @foreach($recap as $row) + + @endforeach + + + + @foreach($students as $student) + + + @foreach($recap as $row) + @php + $s = $row['students'][$student->id] ?? null; + @endphp + + @endforeach + + @endforeach + +
Siswa + {{ $row['label'] }}
+ {{ \Carbon\Carbon::parse($row['start'])->format('d/m') }}-{{ \Carbon\Carbon::parse($row['end'])->format('d/m') }} +
+

{{ $student->full_name }}

+
+ @if($s) +
+
+ {{ $s['hadir'] }} + @if($s['alpha'] > 0) + A{{ $s['alpha'] }} + @endif +
+ @if($s['sakit'] > 0 || $s['izin'] > 0 || $s['terlambat'] > 0) +
+ @if($s['sakit'] > 0)S{{ $s['sakit'] }}@endif + @if($s['izin'] > 0)I{{ $s['izin'] }}@endif + @if($s['terlambat'] > 0)T{{ $s['terlambat'] }}@endif +
+ @endif +
+ @else + — + @endif +
+
+
+ H Hadir + A Alpha + S Sakit + I Izin + T Terlambat +
+ @else +
+
+ +
+

Pilih Kelas & Bulan

+

Pilih kelas dan bulan untuk melihat rekap bulanan

+
+ @endif +
+ @endif
@endsection diff --git a/routes/web.php b/routes/web.php index 2fc8259..5633dcc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -68,6 +68,7 @@ Route::middleware('auth')->group(function () { Route::post('/attendances', [\App\Http\Controllers\AttendanceController::class, 'store'])->name('attendances.store'); Route::get('/attendances/recap', [\App\Http\Controllers\AttendanceController::class, 'recap'])->name('attendances.recap'); Route::get('/attendances/calendar-data', [\App\Http\Controllers\AttendanceController::class, 'calendarData'])->name('attendances.calendar-data'); + Route::get('/attendances/export-weekly', [\App\Http\Controllers\AttendanceController::class, 'exportWeekly'])->name('attendances.export-weekly'); }); Route::middleware('permission:view-sp-letters')->group(function () {