Files
aplikasi-ketertiban/app/Http/Controllers/AttendanceController.php
T
Novy San87 9e1b5c34e5 feat: rekap presensi harian, mingguan, bulanan + export excel
- 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
2026-07-23 11:11:28 +07:00

569 lines
22 KiB
PHP
Raw 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 App\Models\Attendance;
use App\Models\Student;
use App\Models\ViolationType;
use App\Services\ViolationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AttendanceController extends Controller
{
protected ViolationService $violationService;
public function __construct(ViolationService $violationService)
{
$this->violationService = $violationService;
}
public function index(Request $request): View
{
$today = now()->toDateString();
$thisMonth = now()->month;
$thisYear = now()->year;
// Hitung berdasarkan jumlah siswa unik, bukan total record per jam
$todayStudents = \App\Models\Attendance::where('date', $today)
->distinct('student_id')
->count('student_id');
$todayAlphaStudents = \App\Models\Attendance::where('date', $today)
->where('status', 'alpha')
->distinct('student_id')
->count('student_id');
$monthStudents = \App\Models\Attendance::whereMonth('date', $thisMonth)
->whereYear('date', $thisYear)
->distinct('student_id')
->count('student_id');
$monthAlphaStudents = \App\Models\Attendance::whereMonth('date', $thisMonth)
->whereYear('date', $thisYear)
->where('status', 'alpha')
->distinct('student_id')
->count('student_id');
// Calendar data for initial month
$calendarStart = now()->startOfMonth()->toDateString();
$calendarEnd = now()->endOfMonth()->toDateString();
$totalRows = \App\Models\Attendance::selectRaw('date, COUNT(DISTINCT student_id) as total')
->whereBetween('date', [$calendarStart, $calendarEnd])
->groupBy('date')
->pluck('total', 'date');
$alphaRows = \App\Models\Attendance::selectRaw('date, COUNT(DISTINCT student_id) as alpha')
->whereBetween('date', [$calendarStart, $calendarEnd])
->where('status', 'alpha')
->groupBy('date')
->pluck('alpha', 'date');
$calendarData = [];
foreach ($totalRows as $date => $total) {
$calendarData[$date] = [
'total' => $total,
'alpha' => $alphaRows[$date] ?? 0,
];
}
return view('attendances.index', compact(
'todayStudents', 'todayAlphaStudents',
'monthStudents', 'monthAlphaStudents',
'calendarData'
));
}
public function create(Request $request): View
{
$date = $request->input('date', now()->toDateString());
$className = $request->input('class_name');
$students = collect();
if ($className) {
$students = Student::where('is_active', true)
->where('class_name', $className)
->orderBy('full_name')
->get();
}
// Load existing attendances for this class + date
$existing = collect();
if ($students->isNotEmpty()) {
$existing = Attendance::where('date', $date)
->whereIn('student_id', $students->pluck('id'))
->get()
->keyBy(fn($a) => $a->student_id . '-' . $a->lesson_hour);
}
$classNames = Student::where('is_active', true)
->whereNotNull('class_name')
->distinct()
->orderBy('class_name')
->pluck('class_name');
$lessonHours = range(1, 10);
return view('attendances.create', compact(
'students', 'date', 'className',
'classNames', 'lessonHours', 'existing'
));
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'date' => ['required', 'date'],
'class_name' => ['nullable', 'string'],
'attendances' => ['required', 'array'],
'auto_violation' => ['nullable', 'boolean'],
]);
$date = $request->input('date');
$class_name = $request->input('class_name');
$saved = 0;
$autoViolations = 0;
$alphaIds = [];
$terlambatIds = [];
foreach ($request->input('attendances') as $studentId => $studentData) {
foreach ($studentData as $hour => $data) {
if (!isset($data['student_id'], $data['lesson_hour'], $data['status'])) {
continue;
}
$data['lesson_hour'] = (int) $data['lesson_hour'];
Attendance::updateOrCreate(
[
'student_id' => $data['student_id'],
'date' => $date,
'lesson_hour' => $data['lesson_hour'],
],
[
'status' => $data['status'],
'recorded_by' => $request->user()->id,
]
);
$saved++;
if ($data['status'] === 'alpha') {
$alphaIds[$data['student_id']] = ($alphaIds[$data['student_id']] ?? 0) + 1;
} elseif ($data['status'] === 'terlambat') {
$terlambatIds[$data['student_id']] = ($terlambatIds[$data['student_id']] ?? 0) + 1;
}
}
}
// Auto-generate violation untuk alpha & terlambat
if ($request->boolean('auto_violation') && (!empty($alphaIds) || !empty($terlambatIds))) {
$alphaType = ViolationType::where('slug', 'alpha')->first();
$terlambatType = ViolationType::where('slug', 'terlambat')->first();
foreach ($alphaIds as $studentId => $count) {
if ($alphaType) {
$this->violationService->recordViolation([
'student_id' => $studentId,
'violation_type_id' => $alphaType->id,
'violation_date' => $date,
'points' => max(1, (int) round(($alphaType->points / 10) * $count)),
'description' => "Alpha - Tidak hadir tanpa keterangan ({$count} jam pelajaran)",
'notes' => 'Dibuat otomatis dari presensi.',
'evidences' => [],
], $request->user()->id);
$autoViolations++;
}
}
foreach ($terlambatIds as $studentId => $count) {
if ($terlambatType) {
$this->violationService->recordViolation([
'student_id' => $studentId,
'violation_type_id' => $terlambatType->id,
'violation_date' => $date,
'points' => $terlambatType->points,
'description' => "Terlambat ({$count} jam pelajaran)",
'notes' => 'Dibuat otomatis dari presensi.',
'evidences' => [],
], $request->user()->id);
$autoViolations++;
}
}
if (!$alphaType) {
$msgExtra = 'Jenis pelanggaran "Alpha" tidak ditemukan. Buat jenis pelanggaran dengan slug "alpha".';
}
if (!$terlambatType) {
$msgExtra = (empty($msgExtra) ? '' : $msgExtra . ' ')
. 'Jenis pelanggaran "Terlambat" tidak ditemukan. Buat dengan slug "terlambat".';
}
}
$studentCount = count($request->input('attendances'));
$lessonHourCount = $saved / max($studentCount, 1);
$msg = "Presensi berhasil disimpan — {$studentCount} siswa × " . round($lessonHourCount) . " jam pelajaran.";
if ($autoViolations > 0) {
$msg .= " {$autoViolations} pelanggaran dibuat otomatis (alpha/terlambat).";
} elseif (!empty($msgExtra)) {
$msg .= " Peringatan: {$msgExtra}";
}
return redirect()->route('attendances.create', [
'date' => $date,
'class_name' => $class_name,
])->with('success', $msg);
}
public function recap(Request $request): View
{
$type = $request->input('type', 'daily');
$className = $request->input('class_name');
$classNames = Student::where('is_active', true)
->whereNotNull('class_name')
->distinct()
->orderBy('class_name')
->pluck('class_name');
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
{
$year = (int) $request->input('year', now()->year);
$month = (int) $request->input('month', now()->month);
$daysInMonth = now()->setDate($year, $month, 1)->daysInMonth;
$dateFrom = sprintf('%04d-%02d-01', $year, $month);
$dateTo = sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth);
// Total unique students per date
$totalRows = Attendance::selectRaw('date, COUNT(DISTINCT student_id) as total')
->whereBetween('date', [$dateFrom, $dateTo])
->groupBy('date')
->pluck('total', 'date');
// Alpha unique students per date
$alphaRows = Attendance::selectRaw('date, COUNT(DISTINCT student_id) as alpha')
->whereBetween('date', [$dateFrom, $dateTo])
->where('status', 'alpha')
->groupBy('date')
->pluck('alpha', 'date');
$result = [];
foreach ($totalRows as $date => $total) {
$result[$date] = [
'total' => $total,
'alpha' => $alphaRows[$date] ?? 0,
];
}
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;
}
}