feat: replace attendance history list with interactive calendar
- Calendar shows per-date student count badge (green=all hadir, red=ada alpha) - Click badge to go to attendance input for that date - Prev/next month navigation with API fetch - Controller passes initial month data inline (no flash on load)
This commit is contained in:
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -46,16 +47,33 @@ class AttendanceController extends Controller
|
||||
->distinct('student_id')
|
||||
->count('student_id');
|
||||
|
||||
$recentDates = \App\Models\Attendance::select('date')
|
||||
->distinct()
|
||||
->orderByDesc('date')
|
||||
->take(10)
|
||||
->get();
|
||||
// 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',
|
||||
'recentDates'
|
||||
'calendarData'
|
||||
));
|
||||
}
|
||||
|
||||
@@ -237,4 +255,38 @@ class AttendanceController extends Controller
|
||||
|
||||
return view('attendances.recap', compact('recap', 'month', 'year', 'className', 'classNames'));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -60,46 +60,165 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Recent --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-100">
|
||||
{{-- Kalender Presensi --}}
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden"
|
||||
x-data="attendanceCalendar({{ json_encode($calendarData) }})" x-init="init">
|
||||
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 flex items-center justify-center shadow-sm">
|
||||
<i class="fa-solid fa-clock-rotate-left text-white text-sm"></i>
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-600 to-emerald-500 flex items-center justify-center shadow-sm">
|
||||
<i class="fa-solid fa-calendar text-white text-sm"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Riwayat Presensi</h3>
|
||||
<p class="text-xs text-gray-400">10 hari terakhir</p>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Kalender Presensi</h3>
|
||||
<p class="text-base font-bold text-gray-700" x-text="monthLabel + ' ' + year"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button @click="prevMonth()" class="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition">
|
||||
<i class="fa-solid fa-chevron-left text-xs"></i>
|
||||
</button>
|
||||
<button @click="nextMonth()" class="p-2 rounded-lg hover:bg-gray-100 text-gray-400 hover:text-gray-600 transition">
|
||||
<i class="fa-solid fa-chevron-right text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@if($recentDates->count() > 0)
|
||||
<div class="divide-y divide-gray-50">
|
||||
@foreach($recentDates as $rd)
|
||||
<a href="{{ route('attendances.create', ['date' => $rd->date->format('Y-m-d'), 'class_name' => request('class_name')]) }}"
|
||||
class="flex items-center justify-between px-6 py-3.5 hover:bg-gray-50/50 transition group">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-lg bg-emerald-50 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fa-solid fa-calendar text-emerald-500 text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900">{{ $rd->date->translatedFormat('l, d M Y') }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $rd->date->diffForHumans() }}</p>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
{{-- Day headers --}}
|
||||
<div class="grid grid-cols-7 mb-2">
|
||||
<template x-for="day in ['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab']">
|
||||
<div class="text-center text-xs font-semibold text-gray-400 uppercase tracking-wider py-2" x-text="day"></div>
|
||||
</template>
|
||||
</div>
|
||||
{{-- Calendar grid --}}
|
||||
<div class="grid grid-cols-7 gap-1">
|
||||
<template x-for="(day, idx) in days" :key="idx">
|
||||
<div class="relative min-h-[70px] sm:min-h-[80px] rounded-xl border transition-all duration-150 p-1.5"
|
||||
:class="day.isToday
|
||||
? 'border-blue-300 bg-blue-50/50 ring-1 ring-blue-200'
|
||||
: day.isCurrentMonth && day.total > 0
|
||||
? 'border-emerald-100 hover:border-emerald-200 hover:bg-emerald-50'
|
||||
: day.isCurrentMonth
|
||||
? 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'
|
||||
: 'border-gray-50 bg-gray-50/30 text-gray-300'">
|
||||
{{-- Date number --}}
|
||||
<div class="text-xs font-semibold"
|
||||
:class="day.isToday ? 'text-blue-600' : (day.isCurrentMonth ? 'text-gray-700' : 'text-gray-300')"
|
||||
x-text="day.day">
|
||||
</div>
|
||||
<i class="fa-solid fa-arrow-right text-gray-300 group-hover:text-blue-500 transition"></i>
|
||||
</a>
|
||||
@endforeach
|
||||
{{-- Badge jumlah siswa --}}
|
||||
<template x-if="day.total > 0 && day.isCurrentMonth">
|
||||
<a :href="'{{ route('attendances.create', ['date' => '']) }}' + day.dateStr"
|
||||
class="absolute bottom-1.5 right-1.5 inline-flex items-center justify-center min-w-[22px] h-[22px] text-[10px] font-bold text-white rounded-full shadow-sm"
|
||||
:class="day.alpha > 0 ? 'bg-red-500' : 'bg-emerald-500'"
|
||||
x-text="day.total">
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@else
|
||||
<div class="px-5 py-12 text-center">
|
||||
<div class="w-14 h-14 rounded-2xl bg-gray-50 border border-gray-100 flex items-center justify-center mx-auto mb-3">
|
||||
<i class="fa-solid fa-clipboard text-gray-300 text-xl"></i>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-500 mb-0.5">Belum Ada Presensi</p>
|
||||
<p class="text-xs text-gray-400">Klik "Input Presensi" untuk memulai</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
{{-- Legend --}}
|
||||
<div class="px-6 py-3 border-t border-gray-100 bg-gray-50/50 flex items-center gap-4 text-[11px] text-gray-500">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-2.5 h-2.5 rounded bg-emerald-500"></span> Hadir semua
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-2.5 h-2.5 rounded bg-red-500"></span> Ada alpha
|
||||
</span>
|
||||
<span class="ml-auto text-gray-400">Klik badge untuk input presensi</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
function attendanceCalendar(initialData) {
|
||||
initialData = initialData || {};
|
||||
return {
|
||||
year: {{ now()->year }},
|
||||
month: {{ now()->month }},
|
||||
days: [],
|
||||
|
||||
get monthLabel() {
|
||||
const months = ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember'];
|
||||
return months[this.month - 1] || '';
|
||||
},
|
||||
|
||||
init() {
|
||||
this.attendanceData = Object.keys(initialData).length > 0 ? initialData : {};
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
const firstDay = new Date(this.year, this.month - 1, 1);
|
||||
const lastDay = new Date(this.year, this.month, 0);
|
||||
const startPad = firstDay.getDay();
|
||||
const daysInMonth = lastDay.getDate();
|
||||
const prevLastDay = new Date(this.year, this.month - 1, 0);
|
||||
const prevDaysInMonth = prevLastDay.getDate();
|
||||
const today = new Date();
|
||||
const todayStr = today.getFullYear() + '-' + String(today.getMonth() + 1).padStart(2, '0') + '-' + String(today.getDate()).padStart(2, '0');
|
||||
|
||||
this.days = [];
|
||||
|
||||
// Previous month trailing days
|
||||
for (let i = startPad - 1; i >= 0; i--) {
|
||||
const pd = prevDaysInMonth - i;
|
||||
const m = this.month === 1 ? 12 : this.month - 1;
|
||||
const y = this.month === 1 ? this.year - 1 : this.year;
|
||||
const dateStr = y + '-' + String(m).padStart(2, '0') + '-' + String(pd).padStart(2, '0');
|
||||
this.days.push({ day: pd, isCurrentMonth: false, isToday: false, total: 0, alpha: 0, dateStr: dateStr });
|
||||
}
|
||||
|
||||
// Current month days
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = this.year + '-' + String(this.month).padStart(2, '0') + '-' + String(d).padStart(2, '0');
|
||||
const isToday = dateStr === todayStr;
|
||||
const data = this.attendanceData?.[dateStr] || { total: 0, alpha: 0 };
|
||||
this.days.push({
|
||||
day: d,
|
||||
isCurrentMonth: true,
|
||||
isToday: isToday,
|
||||
total: data.total,
|
||||
alpha: data.alpha,
|
||||
dateStr: dateStr
|
||||
});
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const remaining = 7 - (this.days.length % 7);
|
||||
if (remaining < 7) {
|
||||
for (let d = 1; d <= remaining; d++) {
|
||||
const m = this.month === 12 ? 1 : this.month + 1;
|
||||
const y = this.month === 12 ? this.year + 1 : this.year;
|
||||
const dateStr = y + '-' + String(m).padStart(2, '0') + '-' + String(d).padStart(2, '0');
|
||||
this.days.push({ day: d, isCurrentMonth: false, isToday: false, total: 0, alpha: 0, dateStr: dateStr });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
prevMonth() {
|
||||
if (this.month === 1) { this.month = 12; this.year--; }
|
||||
else { this.month--; }
|
||||
this.fetchAndRender();
|
||||
},
|
||||
|
||||
nextMonth() {
|
||||
if (this.month === 12) { this.month = 1; this.year++; }
|
||||
else { this.month++; }
|
||||
this.fetchAndRender();
|
||||
},
|
||||
|
||||
fetchAndRender() {
|
||||
fetch('{{ route('attendances.calendar-data') }}?year=' + this.year + '&month=' + this.month)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.attendanceData = data;
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -33,6 +33,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('/attendances/create', [\App\Http\Controllers\AttendanceController::class, 'create'])->name('attendances.create');
|
||||
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('/notifications/recent', [DashboardController::class, 'getRecentNotifications'])->name('notifications.recent');
|
||||
Route::post('/notifications/{id}/read', [DashboardController::class, 'markNotificationRead'])->name('notifications.read');
|
||||
|
||||
Reference in New Issue
Block a user